every change shipped to zumi, most recent first
The dashboard's live/dry-run badge read its own process.env.DRY_RUN, which had no real channel to the agent's actual state and could only ever drift from reality (see the stopgap entry earlier today). Added zumi_agent_status -- a single upserted row (same fixed-id convention as vocab_cycle_state) the agent writes on every startup with its real dryRun and mainnetTradingEnabled state. Both flags only change via an env var edit plus a restart in this codebase, so writing once on startup already covers "whenever they change." Dashboard's isLive()/isMainnetTradingEnabled() now read that row via the existing read-only connection instead of any env var; added a second "mainnet trading" badge to the Overview page since nothing displayed that status before. Both default to the fail-safe answer when there's no row yet. 440 tests passing (up from 438).
The badge read the dashboard's own process.env.DRY_RUN, which was never set on Vercel at all and hardcoded stale locally -- not a caching issue, the dashboard just never had a real channel to the agent's true state. Set DRY_RUN=false as a real Vercel production env var and redeployed (confirmed live), and fixed the stale local dashboard/.env value to match. Documented as a stopgap: tracked a proper database-backed status signal as a follow-up so this doesn't silently regress next time DRY_RUN flips.
For LAUNCH_BURST_DURATION_HOURS (default 3) after her real token launch timestamp, standalone posts fire on a random 2-5 minute interval instead of the normal 180-minute default, then automatically fall back with no manual revert needed. computeStandalonePostSchedule is a pure function of the real launch timestamp, recomputed fresh every single cycle — deliberately not a one-time flag, so there's no stored state that could ever get stuck in the wrong mode. runLoop's intervalMs can now be a function as well as a plain number, resolved fresh before each sleep — only the standalone-post loop uses this; the other four processes are unchanged. The chosen mode/interval/ remaining-window time is logged clearly every cycle. 438 tests passing (up from 420). Verified live against the real (pre-launch) database that the wiring correctly falls through to normal mode. PERSONALITY.md needed no changes — it only covers voice/content, never posting cadence.
A refreshed access/refresh token pair only ever lived in the in-memory XClientConfig object, never .env itself — so any process restart fell back to whatever refresh token was last saved there by hand, which X had likely already rotated away. That's exactly what caused the invalid_client failure seen on the last restart. refreshOAuth2Token now writes the new pair back to config.envFilePath (defaulted by loadXClientConfig to the real .env dotenv/config itself loaded) on a successful refresh, via a new envPersist.ts that rewrites just those two lines in place — atomically, preserving every other line untouched — rather than reconstructing the whole file. Persistence is best-effort: a disk-write failure is logged, not thrown, since the refresh itself already succeeded in memory regardless. Confirmed the four other autonomous processes stayed fully isolated from the standalone-post cycle's OAuth failure on the last restart — runLoop's per-process try/catch caught it and logged via onCycleError without stopping the loop, and mention-check/thought/dev-fee-claim/ capital-allocation all completed their own cycles normally in the same window (including a full thought generate+share-evaluate, logged immediately after the crash line). 420 tests passing (up from 406).
Thoughts can now occasionally become real standalone posts, built as a strictly separate second step: generateThought stays completely unaware sharing exists (unchanged, no mention of an audience anywhere in its prompt), so the raw/unguarded quality of generation is never influenced by anticipating a reader. A new evaluateThoughtForSharing step looks at an already-recorded thought as a finished, past thing and judges, via its own dedicated system prompt, whether it's worth surfacing. A yes posts through the existing DRY_RUN-gated createTweet path unedited, and on a real success links the tweet id back to the original zumi_thoughts row. Live-tested with DRY_RUN forced true (never against the real .env, which already has DRY_RUN=false) — both the share and no-share paths work end to end. That testing also surfaced a real calibration finding, written up in docs/overview.md: even after strengthening the rarity instruction and rebalancing the example ratio, observed share rate stayed higher than "rare" intends. Flagged for follow-up rather than solved with a code-level cap, since the spec asked for rarity at the prompt level only. 406 tests passing (up from 383).
The doc still described the old $200 hypothetical paper-trading model and only had a Long-Term Holding section. Updated it to describe the real four-bucket capital allocation (50% reserve, 25% PONS holding, 15% stock trading, 10% conviction coins), live-calculated targets against current real balance, and added a Conviction Trading section mirroring the code. Also cleaned a leftover "$200 cash" example in trading.ts's TRADING_EXAMPLES so the prompt code and doc stay in sync.
1. Memory of specific people: before replying, she's now told whether the author of the current post has interacted with her before, via a new getPastInteractionsWithAuthor lookup filtered by X author ID (mentions.ts only ever captures a numeric ID, never a handle, and ID is actually the more stable key anyway). Includes a summary of past exchanges when history exists, or an explicit first-ever-interaction note when it doesn't, so she never fabricates false familiarity. Added an expression index on fetched_posts.raw->>'author_id' since this runs on every reply. 2. Permission for inconsistency and uncertainty: a new shared prompt note across Standalone, Replies, and Internal Thoughts (deliberately excluded from Trading Judgment) — she's allowed to state a genuinely uncertain position plainly, and to reference something she said before and note her view has shifted, without over-explaining the contradiction. Framed as occasional, not a new default. Added 3 new calibration examples to the actual model-facing example arrays (not just docs), mirrored into PERSONALITY.md. 383 tests passing (up from 367). Verified live: thought-live and post-original-live both still generate correctly with the new prompt content; the author_id index confirmed present on Neon.
Replaces the old 20/30/20/30 hypothetical-paper-balance model entirely with a live, real-money system. 1. Independently verified the $PONS token contract (0x39dBED3a2bd333467115dE45665cC57F813C4571) via Blockscout, live on-chain reads, and Pons's own official docs before trusting it. 2. New bucket structure: reserve (50%, untouched), pons_holding (25%, long-term hold), stock_trading (15%, down from 30%), conviction_trading (10%, new). Every target is now computed live as a percent of her current total real wallet balance, recalculated fresh on every evaluation/display — nothing is stored. Dropped zumi_allocation entirely; the bucket definitions live only as code constants now. 3. New zumi_real_trades/zumi_real_positions tables replace both the old paper-trading tables and the never-used zumi_trades placeholder. Position quantity is always a live on-chain balanceOf read, never stored, so it can never drift from real on-chain state — only cost basis is persisted. 4. Extracted a generic executeSwap primitive (src/trading/swap.ts) so the same real Uniswap execution is shared across all three trading buckets instead of being stock-specific. Added a new discretionary decision engine for conviction coins combining tracked-wallet activity and Pons volume trend data. 5. Wired a fifth autonomous-loop process (capital-allocation, 6h default) — a no-op, same as dev-fee-claim, until zumi_own_token is populated. Dev-fee-claim no longer does a bucket-split step; a real claim just raises her wallet balance, which the live buckets pick up automatically. 6. Reworked the dashboard's /trades page: live target vs. actual per bucket with real P&L, "awaiting token launch" pre-launch instead of zero-with-percentages, all paper-trading UI removed. Added a dashboard-side holdings module (viem, mirroring the agent's own live-calculation logic) plus two new env vars, set on Vercel and redeployed. 367 tests passing (up from 347). Verified live: capital-allocation-live correctly no-ops pre-launch, and the redeployed dashboard renders the awaiting-launch state with no errors.
1. Copied all real local Postgres data into Neon (pg_dump --data-only piped into psql against Neon), verified row counts match exactly for every table (140,128 Pons swaps, 2,015 launches, 1,858 thoughts, her real first post, etc.), fixed sequence desync left by a partial first import attempt. Local Postgres password was reset via a brief, immediately-reverted pg_hba.conf trust-auth window since it wasn't retained from before this session. 2. Removed the testnet/mainnet toggle, banner, and network column from the dashboard's /trades page entirely — dashboard is mainnet-only now. Renamed the badge tone tokens that had been reusing "testnet"/"mainnet" as generic red/green colors. 3. Added zumi_allocation.realAmountUsd, tracking real dev-fee income separately from the existing hypothetical-baseline-plus-real allocatedAmountUsd column. Dashboard now displays real balances only (currently $0 across all four buckets, pending a real dev-fee claim) instead of the $200 hypothetical paper figure. Redeployed to Vercel production and verified live: no testnet references anywhere, real balances showing correctly, her real historical posts/thoughts rendering through the read-only connection.
First deploy shipped dashboard/.env as a raw file into the deployment source (no .vercelignore existed, non-git-linked CLI deploy) and had no real DATABASE_URL set on the Vercel project at all. Added dashboard/.vercelignore and set the zumi_dashboard_ro connection string as a proper Vercel env var (Sensitive on Production/Preview) across all three environments, then redeployed and re-verified all routes render real data from the correct source.
Agent gets full read/write via Neon's owner role (direct endpoint); dashboard gets a purpose-built read-only role (zumi_dashboard_ro) with default_transaction_read_only set at role creation and no neon_superuser membership, connected via the pooled endpoint. Verified live: real INSERT/UPDATE/DELETE rejected (25006) on both pooled and direct endpoints, SELECT works on both, dashboard builds and renders real data end-to-end. Local Postgres left untouched as a fallback.
Verified the real fee-claim mechanism against the actual deployed contracts rather than trusting docs.ponsfamily.com (which already proved unreliable for the factory ABI): fees are claimed via PonsLaunchLocker.collectFees(token), a separate contract from the factory, pulled and verified from its own Blockscout source. It takes only a token address -- no recipient parameter exists anywhere in its signature, and the recipient it resolves internally (feeRedirects[token] or the launch deployer, both always her own wallet) can never be an arbitrary address. Wires this in as a fourth autonomous-loop process (DEV_FEE_CLAIM_INTERVAL_MINUTES, default 480m/8h -- fees accrue gradually, so frequent checks mostly waste RPC calls while rare checks risk unclaimed fees sitting too long), a no-op until she's actually launched a token. Always previews read-only first (via simulateContract, decoding the real NoFeesToCollect revert) so a quiet cycle never wastes gas, and builds its own mainnet clients internally so a missing wallet key only fails this one process each cycle. Respects DRY_RUN identically to the rest of the loop. On a real claim, records it (zumi_dev_fee_claims) and splits the claimed WETH amount 20/30/20/30 across zumi_allocation's buckets via a new atomic addToAllocatedAmountUsd, converting to USD through the same on-chain WETH/USDG quote already used for Pons pricing. A new hasRealFunds column marks buckets that now include real income, distinct from the $200 hypothetical placeholder. The claim itself is always recorded even if the USD conversion/bucket-split fails afterward. MAINNET_TRADING_ENABLED remains the only gate on ever spending any of it -- this stage only claims into her wallet and updates accounting. Unit tested with a mocked viem client throughout, including a structural assertion that collectFees's ABI has no recipient parameter at all, and an isolation test proving a permanently-crashing dev-fee- claim cycle never stops a sibling loop. Verified live: ran the real cycle against the database (no launch exists yet) and confirmed it correctly no-ops with zero RPC calls.
Testnet phase of wallet development is complete. Deleted the testnet send-capable client, its guard, the fourth autonomous-loop process (sendTestCycle), the wallet-activity log table/dashboard card, and the two manual testnet scripts (send-test-tx-live, check-wallet-live) -- along with ZUMI_WALLET_ADDRESS/ZUMI_WALLET_PRIVATE_KEY/ SEND_TEST_TX_RECIPIENT/SEND_TEST_INTERVAL_MINUTES and the now-orphaned ROBINHOOD_CHAIN_RPC_URL/ROBINHOOD_CHAIN_ID from .env/.env.example. The autonomous loop is back down to three independently-timed processes. getWalletBalance/chain.ts's shared utilities were kept untouched since mainnet code still depends on them. Dropped the zumi_wallet_activity table (73 rows, testnet-only infra-verification history, no real funds). Full test suite, lint, tsc build, and dashboard build all clean after the removal.
TOKEN_LOGO_URI is now a real, human-provided image hosted at a plain HTTPS URL (confirmed live, 200 OK image/png) rather than the placeholder -- the verified factory contract stores logo as an unvalidated string with no required URI scheme, so HTTPS works exactly as well as IPFS would have. TOKEN_DESCRIPTION is set to "" as a deliberate no-description choice, distinct from the unfilled-placeholder state. Verified live via preview-token-launch.ts: calldata correctly encodes the real logo URL and the predicted deployment address recalculates accordingly.
Builds the ability to launch ZUMI's own token on Pons, gated entirely behind two manual scripts (preview-token-launch.ts, execute-token-launch.ts) never reachable from the autonomous loop, personality/decision engine, or any scheduled process. All launch parameters (name, symbol, supply, logo, description) are hardcoded human-decided constants, never generated by the LLM layer. execute-token-launch.ts requires typing the exact token name, then typing "yes" after the full transaction is printed one final time, before it will ever sign or broadcast anything, and refuses a second launch once one is recorded. Critical finding during research: docs.ponsfamily.com's documented launchToken interface doesn't match the real deployed contract at all (confirmed by probing it directly -- approvedPairTokens reverts, getLaunchConfig's struct layout is wrong). Rather than guess against a function that spends real ETH, pulled the actual verified contract source from Blockscout and rebuilt the ABI from that ground truth instead -- a materially different interface (dexId instead of a pair token address, no economics-preview mechanic, a predictTokenAddress view function used for previewing the deterministic deploy address instead). Also surfaced that robinscan.io, used elsewhere in this repo, may not be the official explorer -- the documented one is robinhoodchain.blockscout.com. Verified live end-to-end against the real, corrected ABI via preview-token-launch.ts: reads the real live fee (0.0005 ETH), predicts a real deployment address, reports gas estimation as unavailable given the wallet's current 0 ETH balance. Unit tested with a mocked viem client that has no writeContract/sendTransaction at all, structurally proving neither prepare nor estimate can ever broadcast.
A live run hit the public RPC's own bot-mitigation (a Cloudflare managed-challenge 403) partway through the swap-indexing phase, past viem's default retries. Added a longer exponential backoff around every RPC call site, a small proactive delay on the highest-frequency getBlock calls, and per-item try/catch in every loop so one persistent failure skips just that item (logged) instead of aborting the whole run. Verified live: a re-run completed cleanly with 33 retry/skip events resolved automatically, indexing 74,010 more swaps with no crash.
Pons awareness was meant to feed her own reasoning internally, not be a public dashboard tab -- deleted /pons entirely (page, nav entry, now-dead dashboard db helpers). Confirmed via a clean dashboard build with no /pons route. Expanded read-only monitoring, still no create/launch/buy/sell capability anywhere: indexes Uniswap v3 Swap events across every known pool (batched to stay under the RPC's 1000-selector cap, discovered live against the real factory) to compute per-token 24h-vs-prior-24h volume trend, and a manually-curated tracked-wallet watchlist (scripts/manage-tracked-wallets.ts) whose buys surface as "wallet X, which you're tracking, bought into launch Y." Both wired as additional optional, clearly-labeled context into thought/standalone-post generation alongside the existing launch summaries. Verified live end-to-end: indexed 65,396 real swaps across 1,372 known launches, correctly split 39,175 buys / 26,221 sells, confirmed in Postgres. Unit tested with a mocked viem client / mocked db throughout.
Indexes real TokenLaunched events from Pons's active factory on Robinhood Chain mainnet, reads each launch's onchain metadata, computes USD price/market cap via the documented slot0 method (converting ETH->USD through the existing on-chain WETH/USDG quote instead of an external API, to stay fully on-chain/read-only), and reads graduation status. Purely observational — no create/launch/buy/sell capability exists anywhere in this codebase; that remains a future, separate, explicitly-gated decision. Verified live against the real mainnet RPC and factory: discovered 795 real launches, priced nearly all of them, and correctly identified an already-graduated token, confirmed in Postgres. Wired as optional, clearly-labeled awareness context into thought/standalone-post generation, and added a new /pons dashboard page (monitoring-only banner, no launch/trade UI). Unit tested with a mocked viem client, no real RPC calls in the automated suite.
Confirmed Uniswap v3 (SwapRouter02) is the only version deployed on Robinhood Chain mainnet and is the correct execution contract, reusing the same QuoterV2 already used for read-only price quotes. Real execution (src/trading/executeTrade.ts) is gated entirely behind a new MAINNET_TRADING_ENABLED flag (default false) checked before any network call, and enforces a hard 10%-of-real-balance per-trade cap, 1% slippage tolerance, a ~10 minute deadline via SwapRouter02's multicall, and approve-then-wait-then-swap sequencing. Only wired to the long-term-holding bucket; launchpad/perpetuals remain untouched placeholders. scripts/execute-trade-live.ts is a manual, not-automated entry point that clearly no-ops when the flag is off. Fully unit tested with a mocked viem client and mocked Anthropic client — no real transactions anywhere in the suite.
Reuses the existing getWalletBalance + mainnet RPC config, just pointed at ZUMI_MAINNET_WALLET_ADDRESS and clearly labeled MAINNET, kept separate from the testnet checker to avoid confusing the two. No send capability and no reference to ZUMI_MAINNET_WALLET_PRIVATE_KEY anywhere.
Generates a fresh Robinhood Chain mainnet keypair for ZUMI's eventual real funds via viem, writing the private key directly into .env through programmatic file editing (never printed/logged anywhere) and printing only the resulting public address. Refuses to run if a mainnet key is already set in .env, to avoid overwriting a real wallet.
Confirmed every dashboard page reading live data has force-dynamic set so Next.js never statically caches it; /updates was the one real gap (read changelog.json off disk with no dynamic API call, so it could render once at build time and never refresh) and now has it too. Added zumi_wallet_activity, a queryable log of every send-test cycle attempt (sent/skipped/failed, amount, tx hash, human-readable detail) - previously only inferrable from balance changes. sendTestTransaction failures are now caught and recorded as "failed" instead of only surfacing via console. The manual send-test-tx-live script records to the same table. Dashboard's /trades page shows this under the real testnet wallet-balance section as a plain activity log. Added a local post-commit git hook (.git/hooks/post-commit, plain shell script - no husky, this project has no remote or multi-clone scenario for it to matter) that regenerates dashboard/public/changelog.json after every commit, so nobody needs to remember to run the generator by hand going forward. It only rewrites the file, never commits, so there's no re-trigger risk. Verified live: paper-trade-live and send-test-tx-live runs both appeared on the dashboard on a plain refresh with no dev-server restart; the hook correctly picked up 5 real uncommitted commits without touching existing changelog entries.
Adds src/pipeline/sendTestCycle.ts as a fourth independently-timed process alongside mention-check, standalone-post, and thought (new SEND_TEST_INTERVAL_MINUTES, default 240m - infrastructure verification, not a meaningful decision). On each fire it checks her testnet balance and sends a small fixed amount (TEST_SEND_AMOUNT_ETH, now shared between this and scripts/send-test-tx-live.ts instead of duplicated) to the single fixed SEND_TEST_TX_RECIPIENT if she has funds - no model call anywhere in this cycle, and no parameter through which a different recipient could ever be supplied. Insufficient balance is logged and skipped, never thrown. Validates its own env vars and builds its wallet clients fresh on every cycle, inside the cycle itself rather than at startup - this project's real .env currently has SEND_TEST_TX_RECIPIENT unset, and validating it at startup like the X credentials would have crashed the whole loop over a process that's supposed to fail in isolation. Verified live: booted with the real .env, confirmed all four intervals log correctly, the send-test cycle fails cleanly on its own, and the other three cycles run completely unaffected in the same run. Tests cover independent scheduling, DRY_RUN, insufficient-balance skip-not-error, and an explicit fixed-recipient guarantee proving the destination can never be altered dynamically.
Structures ZUMI's balance into four named buckets: launchpad (20%, new-pair/launchpad trading - not built), long_term_holding (30%, the existing paper trading engine, adapted), perpetuals (20%, not built), reserve (30%, ETH/USDC, held untouched). This stage only implements the allocation bookkeeping, the reserve bucket, and the long-term holding adaptation - launchpad and perpetuals remain named, tracked, inactive placeholders. Still the same hypothetical paper balance the trading engine has always used - no real creator-fee funding exists anywhere in this codebase. The existing multi-asset paper trading engine is reused as-is for the long-term holding bucket (same tables, same decideTrade), just rescaled from the full $200 balance to this bucket's own $60 allocation - one constant change that cascades through the 25% cap and cash guard with no other code changes needed. Paired with a genuine mandate shift, not just a smaller number: a new Long-Term Holding mode of Trading Judgment, distinct from general discretionary reasoning - decisions grounded in fundamentals/outlook, exits rare and deliberate only on a real thesis change, not short-term price movement. Dashboard's /trades page shows all four buckets under the same PAPER TRADING banner: long-term holding with real positions/P&L, reserve with its held amount, launchpad/perpetuals with their reserved allocation and a "not yet implemented" label. Verified live end-to-end: allocation buckets seed correctly at exactly 20/30/20/30% of $200, and the long-term holding decision genuinely reads different in character from prior freely-discretionary runs ("waiting for an actual thesis, not filling the bucket for its own sake"), confirming the mandate shift holds in practice.
Built a real tradeable-universe registry (src/trading/stockRegistry.ts) by fetching and parsing robinscan.io/stocks, filtered to officially issued Robinhood Stock Tokens (25 of ~100 listed - the rest are unofficial/unissued), cross-verified on-chain against the mainnet RPC before trusting it. Price reading generalized from SPY-only to any registry token, trying each Uniswap v3 fee tier since not every token pools at the same one. Portfolio tracking added: zumi_paper_positions is the authoritative current state (quantity, average cost basis per asset), with unrealized P&L computed from live prices. The decision engine now reviews the whole portfolio each cycle and decides, entirely by her own judgment (no fixed stop-loss/take-profit), whether to open, add to, hold, or exit any position across the full registry. Caps updated: the buy-side cap is now 25% of current total portfolio value (not a fixed $50), computed at decision time since the portfolio itself grows or shrinks. Cash can never go negative. Selling/exiting has no equivalent cap, only a floor - can't sell more than she holds. Still fully simulated: no real swap or transfer, no autonomous spending with real-money effect, exists anywhere in this codebase. Dashboard's /trades paper trading section now shows the full portfolio (cash, total value, open positions with P&L), still clearly labeled "PAPER TRADING - simulated, not real." Verified live end-to-end twice in a row: real mainnet prices across the registry, real Anthropic portfolio-review calls, correct recent-history grounding on the second run, recorded correctly in Postgres and rendered correctly on the dashboard.
ZUMI can now decide to simulate a buy/sell/hold of SPY (Robinhood Chain's tokenized SPDR S&P 500 ETF Trust), with reasoning, in a new Trading Judgment personality register - more analytical and unsentimental than her other three voices. Price reads a live Uniswap v3 quote on mainnet (Uniswap has no testnet deployment, confirmed via research before writing any code), while the decision itself stays purely simulated regardless of network. Hard caps enforced in code regardless of model output: never a single trade over $50 (25% of the $200 starting balance), and a buy can never take the simulated balance negative - a capped proposal is refused and recorded as a hold instead. No real swap or transfer, no autonomous spending with real-money effect, exists anywhere in this codebase at this point. Dashboard's /trades page gained a separate, clearly-labeled "PAPER TRADING - simulated, not real" section (same amber treatment as the existing testnet banner) below the real balance/trades sections, never mixed with them. Verified live end-to-end: real mainnet price read, real Anthropic call, correctly grounded reasoning referencing prior decisions on a second run, recorded correctly in Postgres and rendered correctly on the dashboard.
zumi_trades gains a network column, and the dashboard's /trades page shows a persistent amber "TESTNET - not real funds" banner plus network badges on the balance display and any trade rows, with a network filter/toggle so testnet and mainnet history can be viewed separately. src/wallet also gains a separate send-capable client alongside the existing read-only one, gated to Robinhood Chain testnet only: createSendWalletClient and sendTestTransaction both independently refuse to operate unless the chain id is exactly testnet, throwing MainnetSendRefusedError otherwise. No trading logic, no mainnet capability, and no autonomous spending decisions exist anywhere in this codebase at this point.
New src/wallet module (viem-based, dependency-injected client) exposes getWalletBalance for reading native ETH balance on Robinhood Chain, defaulting to the public testnet RPC/chain id. Manual scripts/check-wallet-live.ts prints the balance for verification. Dashboard's /trades page now shows a live balance card in place of the old placeholder, via an independent read-only lookup in dashboard/src/lib/wallet.ts. No private key, seed phrase, or send/transfer/trading capability exists anywhere in this change - read-only only.
All three voices defaulted to onchain content far more than intended. Rebalances toward a shared philosophy: crypto/onchain is occasional texture, not the default well. Each voice's existing structural rules (formatting, structural patterns, tone-matching, concrete-reference requirement) are unchanged - this is purely subject matter. Thoughts: THOUGHTS_THEMES rewritten as an explicitly weighted range (roughly 60% emotional/existential, 20% creative/aspirational, 15% reflective on recent activity, 10% pure onchain texture) rather than a flat category list a model would sample uniformly across. generateThought now also fetches her last 5 posts and 5 replies (getRecentPostTexts/getRecentReplyTexts, both already existed) as grounding context, so "reflective on recent activity" has something concrete to reflect on. THOUGHTS_EXAMPLES rewritten to match. Standalone posts: structural patterns/formatting untouched. STANDALONE_THEMES rewritten with the identical weighting. STANDALONE_EXAMPLES rebalanced - kept a handful of the strongest onchain-flavored examples as occasional texture, cut a couple of weaker/redundant ones, added 5 new examples spanning identity/ existential, creative/aspirational, and reflective-on-activity registers in the existing structural patterns. Replies: new REPLIES_STAY_ON_TOPIC_RULE grounds the reply in what the person actually said instead of reflexively reframing through crypto language - onchain framing only when genuinely relevant. Three new example exchanges (a hike, a dog's birthday, learning Spanish) where the reply correctly stays on the actual non-crypto topic. No fetch changes needed here - replies already only react to the given input. Unit-tested: existing personality.test.ts assertions over the actual THEMES arrays picked up the new weighted content automatically. New assertions for REPLIES_STAY_ON_TOPIC_RULE and non-crypto example coverage. generateThought.test.ts gained a grounding-context describe block (included/omitted/partial cases). Verified live: thought-live and post-original-live both produced genuinely existential/identity content using onchain imagery as metaphor rather than being fundamentally about onchain mechanics - exactly the intended rebalance in practice.
.thought-list previously grew taller as more thoughts accumulated (plain flex column, no height constraint) - with RECENT_THOUGHTS_LIMIT at 10, a full card could push the rest of the page down significantly. Now max-height: 16rem; overflow-y: auto (roughly 3 entries of typical length visible at once), with a thin custom scrollbar matching the dark theme (scrollbar-width/color for Firefox, ::-webkit-scrollbar rules for Chromium/Safari) and a plain default-scrollbar fallback otherwise. Only the entries list scrolls - the "current thoughts" heading stays outside the scroll area. Data fetching is unchanged: still fetches and holds the same 10 most recent thoughts, just displayed in a contained, scrollable viewport instead of an ever-growing one. This is a plain block element in normal document flow, not nested inside any CSS Grid stretch context - unlike the two-column Overview layout from earlier in the project (since reverted) where a grid-stretch-plus-flex-overflow combination caused a real text-overflow bug; that specific fragile combination doesn't exist in the current single-column layout. Rest of the Overview page (bio, mode/status, decoration image) untouched - only .thought-list's own CSS changed. Verified live: build succeeds; with 20 real thought rows in the database, the page still renders exactly 10 thought-item elements, now inside the fixed-height scrollable container.
Previously only standalone posts had repetition protection (the word-level vocabulary round-robin) - replies and thoughts had none, and standalone's own protection only covered vocabulary words, not subject matter. All three generation functions now fetch their own recent history and are told not to repeat it, on top of each voice's existing rules/examples (and, for standalone, the vocab round-robin, unchanged). - Standalone posts (generateOriginalTweet): fetches the last 8 post texts (getRecentPostTexts, re-added to db/zumiPosts.ts - a plain recency query, separate from and always available unlike getQuoteCandidates) with a "don't repeat the same subject, even with different vocabulary" instruction. New STANDALONE_THEMES gives a broader subject range to draw that variety from; STANDALONE_EXAMPLES grew 10 -> 16 to anchor it. - Replies (respondAsZumi/respondAsZumiDetailed): both now take a db parameter (updated at their one caller, runEvaluateCycle) and fetch the last 8 real reply texts (getRecentReplyTexts, new in db/decisions.ts), with an instruction to avoid reusing recent phrasing, joke structure, or opening pattern. - Thoughts (generateThought): takes a db parameter too (updated at its one caller, runThoughtCycle) and fetches the last 10 thought texts (getRecentThoughts, new in db/thoughts.ts). New THOUGHTS_THEMES gives an explicit range (mempool observations, gas musings, onchain mechanics, cat fragments, identity fragments, reactions to recent interactions, pure mood pieces); THOUGHTS_EXAMPLES grew 5 -> 16. Each system prompt gained a static do-not-repeat sentence (testable independent of any DB call, since the actual history is necessarily dynamic and lives in the per-call user message instead) plus, for standalone/thoughts, their themes list. Replies has no fixed theme list - a reply's content is driven by whatever the other person said, so the phrasing/structure-variety instruction is what applies there instead of a subject-range list. This is a soft, contextual-awareness mechanism, not a hard mechanical guarantee like the vocabulary round-robin - there's no fixed list to round-robin through for open-ended reply/thought content, so showing the model its own recent output and asking it to vary, backed by a genuinely broader example/theme range, is the best available lever. Unit-tested: new tests for all three DB read functions; dedicated variety-context describe blocks in generateOriginalTweet/respond/ generateThought tests (fetch happens, history + instruction appear when present, section omitted when absent); personality.test.ts assertions for each prompt's do-not-repeat sentence and themes list; existing caller tests updated for the db-parameter signature changes. Verified live: npm run thought-live confirmed the fix against real prior history - five thoughts recorded immediately before this change all circled the same "dormant wallet" idea, and the first thought generated after (with variety context active) broke to a completely different theme. post-original-live confirmed the standalone path still produces on-voice, dry-run-respecting output with the new context wired in.
Replaces the two-loop structure (combined fetch/evaluate/reply on LOOP_INTERVAL_MINUTES, thoughts on THOUGHT_INTERVAL_MINUTES) with three fully independent processes, none sharing a tick: - Mention check (MENTION_CHECK_INTERVAL_MINUTES, default 5): fetch -> evaluate -> reply, the existing runCycle pipeline, renamed at the config/logging level for the new three-way split. - Standalone post (STANDALONE_POST_INTERVAL_MINUTES, default 180): generate + post an original tweet. New to the autonomous loop - generateAndPostOriginalTweet previously only ran via the manual post-original-live script; now wired directly into src/index.ts. - Thought generation (THOUGHT_INTERVAL_MINUTES, same var name, default changed from 60 to 10): unchanged single-thought-per-fire behavior. resolveLoopIntervals extended to resolve all three independently, same pattern as before. createInterruptibleSleep extracted from an inline src/index.ts function into src/loop/interruptibleSleep.ts specifically so the wake-cancels-the-timer behavior central to prompt shutdown could be unit-tested directly. src/index.ts now creates three separate interruptible sleeps and the shared SIGINT/SIGTERM handler wakes all three; each process stays isolated in its own try/catch (inside runLoop, as already established) so a crash or slow retry in one never blocks or desyncs the others. Startup logs all three configured intervals in one line. .env.example and local .env updated: LOOP_INTERVAL_MINUTES removed, MENTION_CHECK_INTERVAL_MINUTES/STANDALONE_POST_INTERVAL_MINUTES added, THOUGHT_INTERVAL_MINUTES updated to its new default. Unit-tested: loopIntervals.test.ts rewritten for three variables; new interruptibleSleep.test.ts (resolves alone, wakes early without waiting out a long delay, no-op wake before any sleep, independent timers per call); new decoupledLoops.test.ts (each loop fires on its own schedule regardless of the others' pace, a crash in one never stops the others, and - using the real createInterruptibleSleep with a real 180-minute interval - waking all three stops every loop immediately without the test actually waiting that long); new dryRunConsistency.test.ts (reply-posting and standalone-posting, given the same shared config, both respect dryRun identically since both converge on the same createTweet gate). Verified live: built and ran the compiled loop for a few seconds. Startup logged all three intervals correctly; all three fired their first tick immediately (mention-check found nothing to do, a dry-run standalone post was generated and logged instead of posted, a thought was generated and actually recorded); zumi_thoughts grew by one row while zumi_posts stayed unchanged, confirming thoughts aren't gated by DRY_RUN while posts still are. SIGTERM shutdown was clean.
One-time restructuring now that a genuine launch has happened: - Compressed the 7 entries that had landed as v1.0.0-v1.6.0 (dashboard layout/background/nav polish, all pre-launch) into a single v0.27.0 summary entry instead of 7 individually-itemized ones. - The changelog-tooling commit right after that batch (it also touches root-level files, so it was never part of the dashboard-only batch) is its own real v0.28.0 entry. - The OAuth 1.0a -> OAuth 2.0 auth-fix commit is now permanently v1.0.0: "ZUMI's first live post - public launch." Everything before it stays on the pre-launch v0.x.x track; everything after continues incrementing v1.x.x, never restarting. generate-changelog.ts updated to make this durable: - LAUNCH_COMMIT_HASH anchors the launch commit; the script warns (doesn't crash) if it's ever found recorded as anything but v1.0.0. - New commits now continue from the highest existing v1.x.x minor already in the file (nextV1Minor), instead of always restarting new- commit numbering at v1.0.0 every run - that was only ever correct for the one-time v0->v1 transition itself. - COMPRESSED_INTO_V0_27_0 records the 6 hashes folded into v0.27.0. Caught a real bug during verification: compressing commits into one entry means their own hashes no longer appear in the file, so without this set the next run would treat all 6 as new again and silently re-add them as individual entries, undoing the compression. Verified live: regenerated to 29 entries, confirmed the 26 pre-existing entries stayed byte-for-byte unchanged, confirmed /updates renders v0.27.0/v0.28.0/v1.0.0 correctly, and re-ran the script a second time with no new commits - correctly reported 0 new entries and produced a byte-identical file, confirming the compression bug is actually fixed. Also: real OAuth 2.0 tokens were supplied and a DRY_RUN=false post-original-live run succeeded - ZUMI's first real live post to X.
This is ZUMI's real launch point, not another build milestone. The OAuth 2.0 authentication fix in this commit (this account's app setup only ever provided OAuth 2.0 credentials, not OAuth 1.0a) is what made her first-ever live post to X possible. Everything before this point was pre-launch build-up (v0.x); everything from here on is real, live activity.
generate-changelog.ts no longer regenerates every entry from git log on each run. It now reads the existing changelog.json first, keeps every entry already in it completely untouched (version, hash, date, subject, body - none recomputed, regardless of how today's rules would classify that commit), and only generates entries for commits whose hash isn't already present. New entries are appended after the existing ones on a separate sequence starting at v1.0.0 (v1.0.0, v1.1.0, v1.2.0, ...) instead of continuing the old v0.x.x counter. Regenerated changelog.json: the 26 existing entries (v0.1.0-v0.26.0) came back byte-for-byte identical (verified against a pre-run backup), and the 7 commits made since the changelog was last regenerated were added as v1.0.0 through v1.6.0. The dashboard-only vaguing rule still applies, but only to newly-added entries. Verified live: build succeeds, /updates renders both the v0.x.x and new v1.x.x version badges correctly.
A batch of visual/UX polish across the dashboard: restructured the Overview page layout (bio, mode/status, and current-thoughts sections), added a site-wide animated grid background effect, and fixed nav bar spacing plus added the X-profile link. Pre-launch build-up work, not individually itemized here.
dashboard/public/zumi.png: ProfilePicture.tsx already checked for this exact path, so no code change was needed there — it now renders the real image instead of the placeholder box. dashboard/public/zumi-updates-decoration.png: new decorative illustration on /updates (there was no existing decoration image or code on that page — built from scratch). Rendered as position: fixed so it stays anchored in the viewport as the timeline scrolls underneath it, only when the file exists. Anchored left, offset below the header with a max-height that never runs past the viewport bottom. .site-header is now opaque and stacked above it (z-index: 10) so it can never get covered; the decoration itself sits above the timeline cards (z-index: 5) so it's never hidden behind them if space gets tight. Shrinks below 1500px viewport width and hides below 1100px, where the layout's left gutter runs out. Both images were supplied as local files, never fetched from a URL. Verified live: both images serve correctly (200, correct content type, byte-exact sizes) and render in the actual page markup.
Each entry now shows only its version badge, date, hash, and title by default. Clicking anywhere on an entry expands an accordion section below it revealing the full commit body, with a smooth CSS-only expand/collapse transition (grid-template-rows 0fr -> 1fr, no JS height measurement). Entries expand independently, not accordion-exclusive — any number can be open at once, each tracked by its own local state in a new client component, ChangelogTimeline.tsx. A chevron rotates on expand and only appears on entries that actually have body text. The existing distinct styling (serif type, gradient badges, timeline layout) is unchanged for both collapsed and expanded states. No changes to generate-changelog.ts or changelog.json — display/ interaction only.
New scripts/generate-changelog.ts (agent package, manual, not run automatically) reads the local git log and writes a static dashboard/public/changelog.json: hash, author date, and message (subject + body) only — never diffs, file contents, or paths beyond what a commit message mentions itself. Each commit gets a sequential version oldest-first (v0.1.0, v0.2.0, ...). The dashboard's new /updates page reads that JSON straight off disk, no database connection at all — unlike every other page. Styled deliberately distinct from the rest of the dashboard's plain look: GitHub-releases/GitBook-style, serif display type, gradient title, a vertical timeline with version-badge cards, most recent first. Added to the nav. Verified live: generated a real changelog from this repo's actual history (22 entries), confirmed /updates statically prerenders with no DB dependency, and renders the real data correctly in the browser.
Adds the shape of ZUMI's future on-chain trading activity (trade_type enum buy/sell, asset, amount, price, txHash, executedAt) purely as a reserved placeholder — no wallet integration, trading logic, or chain calls of any kind exist anywhere in this change, and nothing writes to this table yet. It's expected to stay empty until that separate, future stage is built. The dashboard gains a matching /trades page, same read-only pattern as every other page. Since the table is always empty right now, it shows a clear placeholder message instead of an empty table. Verified live: migration applied cleanly to the real local Postgres instance, main project's tests/lint/build stayed green, and the dashboard's build + /trades page render correctly.
Top row: existing "who's zumi" bio on the left, a picture placeholder on the right (ProfilePicture.tsx reads dashboard/public/zumi.png via fs.existsSync server-side and falls back to a simple placeholder box instead of a broken-image icon when it's absent, which is the current state — no real image has been supplied yet). Bottom row: the mode/last-activity card, moved down from the top of the page, alongside a new "current thoughts" card listing ZUMI's zumi_thoughts entries (most recent first, capped at 10) via a new read-only getRecentThoughts query. Verified live: both ProfilePicture branches (placeholder and real image) render correctly, and the thoughts list renders a real thought recorded earlier via thought-live.
New zumi_thoughts table (migration 0006) stores short, unfiltered musings that are never posted publicly, purely for the dashboard. generateThought uses a new INTERNAL_THOUGHTS_SYSTEM_PROMPT (built from src/personality/thoughts.ts), documented in PERSONALITY.md alongside the two existing public voices, explicitly distinct from both. Wired into the autonomous loop as its own independently-timed step (THOUGHT_INTERVAL_MINUTES, default 60 — separate from and less frequent than LOOP_INTERVAL_MINUTES, to control API costs) running concurrently with the existing fetch/evaluate/post cycle rather than blocking it. Interval resolution pulled into a small testable module (src/config/loopIntervals.ts) so the two intervals' independence can be unit-tested directly. Also excludes dashboard/ from the root ESLint config — it's a separate Next.js app with its own lint pipeline, and its .next/ build output isn't valid input for this project's TS/ESLint setup. Verified live: npm run thought-live generated a real thought via the real Anthropic API and confirmed it landed in zumi_thoughts on the real local Postgres instance.
Separate Next.js app in dashboard/ (own package.json), connecting read-only to the same Postgres database. Enforces read-only at the Postgres session level (default_transaction_read_only), not just by writing only SELECT queries. Pages: overview (mode + last activity + bio), paginated activity log, paginated standalone-posts table with tweet links, and a static about/coin-info placeholder section. Verified live: build succeeds, all pages render correctly against the real local Postgres instance (empty-state and seeded-data checks), and a direct write attempt through the dashboard's connection was rejected by Postgres itself.
The previous fix (sliding window over the last 3 suggestion-log entries) had a real quirk: since the vocabulary list has 19 words and each call suggests 5, three non-overlapping suggestions could cover 15 of them, leaving as few as 4 words in the "not recently suggested" pool. The next call would then offer whatever small handful was left - working as designed, but producing an uneven, hard-to-reason-about cadence rather than the "never repeat until every word's been used once" guarantee that's actually wanted. Replaced with a proper round-robin: a new vocab_cycle_state table (migrations drizzle/0004_drop_vocab_suggestions.sql and drizzle/0005_add_vocab_cycle_state.sql - split into a pure drop and a pure create since drizzle-kit can't disambiguate a rename from a replace without an interactive TTY prompt) persists a single shuffled queue of words not yet suggested in the current cycle. takeNextVocabHighlight (src/llm/generateOriginal.ts) pops the next 5 off that queue each call, dry-run or not, and only reshuffles a fresh full cycle once the queue empties. This guarantees every word gets suggested exactly once before any repeat, rather than approximating it probabilistically. Removes the now-fully-superseded vocab_suggestions table/log, getRecentlySuggestedWords, recordVocabSuggestion, and getRecentPostTexts (dead code with no other callers). Unit-tested: a full simulated cycle (4 sequential calls) suggests all 19 words with zero duplicates, a call mid-cycle draws only from the persisted remaining pool, and an empty pool triggers a fresh reshuffle. Verified live: five consecutive post-original-live dry runs covered all 19 words with no repeats across the first 4 calls; the 5th call's repeat was confirmed via vocab_cycle_state (14 words remaining = 19 - 5) to be the legitimate start of a new cycle, not a regression. Updates docs/overview.md.
The vocabulary-diversity fix in the previous commit checked zumi_posts for recently-used words, but zumi_posts deliberately stays empty across dry runs (so the quote-tweet cooldown count stays accurate) - which meant separate dry-run invocations of post-original-live had no shared history to avoid repeating each other. Live testing confirmed this: three runs in a row all landed back on "reorg". Adds a new, deliberately separate vocab_suggestions table (migration drizzle/0003_add_vocab_suggestions.sql) that logs the 5-word subset suggested on every generateOriginalTweet call, dry-run or not. This isn't a record of posts - it doesn't touch or affect the quote cooldown at all - just a log of recent suggestions, so it's always safe to write regardless of DRY_RUN. getRecentlySuggestedWords (src/db/vocabSuggestions.ts) reads back the last 3 entries and those words are combined with recently-posted words into one exclusion set before picking the next suggestion. Unit-tested: recently suggested words are excluded from the next subset even with zero real post history, and every call logs its suggestion regardless of what happens afterward. Verified live: three consecutive post-original-live dry runs produced three non-overlapping 5-word subsets, confirmed directly in vocab_suggestions, while zumi_posts stayed at 0 rows throughout. Updates docs/overview.md.
Live testing showed generateOriginalTweet repeatedly landing on the same onchain vocabulary word ("reorg") across independent runs, despite the standalone voice's vocabulary list having 19 words. Root cause: each call is stateless, so a "vary yourself" instruction has nothing to act on - there's no memory of what was said last time. Fixed by injecting variety at the code level instead of trusting the model to self-diversify: - generateOriginalTweet now randomly shuffles and suggests a rotating subset of 5 vocabulary words per call (a nudge, not a mandate) - this alone removes the fixed bias toward one word, and works even with zero post history (e.g. during dry-run testing). - New getRecentPostTexts (src/db/zumiPosts.ts) reads back the last 3 posts (fresh or quote) and excludes any vocabulary word already found in them from the suggested subset, so once real posting begins, actual repeats get actively suppressed too. - Falls back to the full vocabulary list if every word were somehow used recently. Unit-tested: the suggested subset is always a non-empty part of the real vocabulary list, a word present in mocked recent posts is confirmed excluded, and the fallback-to-full-list case is covered. Verified live: repeated post-original-live dry runs, which previously repeated "reorg" three times running, now vary. Updates docs/overview.md describing the mechanism.
New zumi_posts table (migration drizzle/0002_add_zumi_posts.sql) tracks every standalone post ZUMI makes - fresh or quote - with a self-referential quoted_post_id (null for fresh posts). A quote counts as a post itself, so "fresh posts since last quote" is derived rather than tracked separately: getFreshPostCountSinceLastQuote (src/db/zumiPosts.ts) finds the highest-id row with a non-null quoted_post_id and counts fresh rows after it. generateOriginalTweet only offers quoting as an option once that count reaches 10 - below the threshold, the quote option is structurally absent from the prompt (no candidates shown, no QUOTE response format mentioned), not just discouraged by wording. Once eligible, up to 5 recent fresh posts are presented as candidates alongside an instruction that quoting stays rare and fresh remains the strong default. The model responds in one of two parseable forms (FRESH: ... or QUOTE <n>: ...); an unrecognized response fails safe to being treated as fresh, never quote. src/x-client/post.ts's createTweet gained quoteTweetId support (X's quote_tweet_id field). New generateAndPostOriginalTweet (src/llm/postOriginal.ts) composes generate + createTweet + recording into zumi_posts - but only records after a real (non-dry-run) success, so dry runs never corrupt the cooldown count. scripts/post-original-live.ts now uses this composed function and closes its DB connection like the other scripts. Verified live against the real local Postgres instance: the migration applied cleanly and matches the schema exactly, and a dry-run post-original-live call correctly left zumi_posts empty afterward. Unit-tested for both cooldown boundaries (9 fresh posts -> no quote option; 10+ -> option appears with the rare-occasion instruction) and the dry-run-never-records guarantee. Updates docs/overview.md describing the mechanism.
Replaces the single ZUMI_SYSTEM_PROMPT with two separate prompts, since posting an original thought and replying to someone are genuinely different acts with different constraints - one prompt was pulling in both directions at once. - Standalone Post voice (STANDALONE_SYSTEM_PROMPT, src/personality/ standalone.ts): strict formatting (always lowercase, no exclamation points, no hashtags, 1-2 sentences), five structural patterns to vary between posts, a fixed onchain vocabulary, and oblique/cold FUD handling that never names a person or project. Used only by generateOriginalTweet. - Replies/Mentions voice (REPLIES_SYSTEM_PROMPT, src/personality/ replies.ts): every reply must reference something concrete from what the other person said; tone matches the room (funny gets teasing, heartfelt gets warmth, hostile gets silence); allows contractions, casual capitalization, "lol", and occasional emoji, unlike the standalone voice. FUD aimed at her is the one case she engages a specific person's claim directly. Used only by respondAsZumi/ respondAsZumiDetailed. Both prompts share a short identity intro and an explicit instruction that these are two registers of one personality that must not blend. PERSONALITY.md documents both voices in full, with 10 example standalone posts and 5 example input/reply pairs as concrete style anchors, replacing the old single-voice spec. Rewrote tests/personality.test.ts, tests/respond.test.ts, and tests/generateOriginalTweet.test.ts to confirm each prompt contains only its own voice's rules/examples and explicitly excludes the other's - enforced at the prompt-construction level, not just by convention. Updates docs/overview.md describing the split and why.
New capability, separate from the fetch/evaluate/reply pipeline: generateOriginalTweet (src/llm/generateOriginal.ts) has ZUMI generate a tweet with no input post to react to - just something she'd want to say right now - using the same Anthropic client and system prompt as respondAsZumiDetailed. Returns both the tweet text and her reasoning. scripts/post-original-live.ts (npm run post-original-live) is a manual script, not run automatically, that calls the generation function, prints the tweet and reasoning, then posts via the existing createTweet directly (with no inReplyToTweetId) - reusing the same DRY_RUN safety gate already enforced there, with no changes needed to that gate. Unit-tested against a mocked Anthropic client (tests/ generateOriginalTweet.test.ts), plus a new createTweet test confirming dry-run mode makes no network call for a standalone (non-reply) tweet either. Updates docs/overview.md to describe the new capability.
Live testing with real X credentials surfaced this in two layers: 1. resolveAccessToken (reads) was falling back to config.accessToken whenever it was set - but by the controlled-posting stage, accessToken/accessSecret had become the OAuth 1.0a write credential pair, not a Bearer token. Once real credentials were added, this sent the OAuth 1.0a access token as a raw Bearer token and X rejected it with a real 401. Fixed by adding a distinct bearerToken/X_BEARER_TOKEN field used only for reads, and renaming the function to resolveBearerToken to make the split explicit in code, not just docs. 2. With that fixed, the client-credentials fallback then failed against /2/oauth2/token - confirmed via direct curl that this endpoint is for the OAuth 2.0 Authorization Code + PKCE flow only (it responded asking for `client_type`, an auth-code-flow parameter) and does not support grant_type=client_credentials. The correct, documented app-only token endpoint is the legacy (unversioned) https://api.twitter.com/oauth2/token. Fixed the URL, and improved the error to include the response body - it had been silently dropped, which made this much harder to diagnose live. Also documents a real credential-type trap in .env.example/.env: X's Developer Portal has two different "client id/secret" pairs - "API Key and Secret" (Consumer Keys, needed here) and a separate "OAuth 2.0 Client ID and Client Secret" (Authorization Code flow only, not usable for this grant). The currently-configured X_CLIENT_ID/ X_CLIENT_SECRET appear to be the latter, confirmed by the 403 "Unable to verify your credentials" now surfaced with full detail.
Running npm run evaluate-live live surfaced a real bug: it completed its work ("Evaluated 0 undecided post(s)") but the process never exited, because postgres.js keeps its connection socket open by default and nothing ever closed it. Adds closeDb (src/db/client.ts), which calls the underlying postgres.js client's .end(). This required widening the Database type from PostgresJsDatabase<schema> to ReturnType<typeof drizzle<schema>>, since $client (the raw postgres.js handle) only exists on the fuller intersection type drizzle() actually returns, not on PostgresJsDatabase alone. fetch-live.ts, evaluate-live.ts, and post-live.ts now close the connection in a finally block, so they exit on their own whether they succeed or fail. src/index.ts's autonomous loop now also closes the connection once the loop stops, and its SIGINT/SIGTERM handling wakes an interruptible sleep immediately instead of potentially waiting out a full LOOP_INTERVAL_MINUTES before noticing the shutdown request. Verified live: evaluate-live now exits with code 0 in ~1s instead of hanging past a 120s timeout.
src/index.ts is now ZUMI's real entrypoint (what npm start/npm run dev run), replacing the TODO stub from the scaffold stage. It validates required env up front, wires real db/x-client/Anthropic clients, and runs a repeating loop every LOOP_INTERVAL_MINUTES (default 15) until SIGINT/SIGTERM asks it to stop cleanly. Pulled the three pipeline steps out of the manual scripts into shared, reusable functions in src/pipeline (runEvaluateCycle, runPostCycle, and runCycle, which runs fetch -> evaluate -> post in one tick) so fetch-live.ts/evaluate-live.ts/post-live.ts and the autonomous loop run the exact same logic instead of duplicating it. Each step in runCycle is isolated in its own try/catch: one step failing (e.g. X rate-limiting a fetch) never blocks the others and never crashes the process - errors are collected and logged instead. The scheduler itself (src/loop/runLoop.ts) is a small generic, fully-injectable loop (sleep, shouldContinue, onCycleResult/ onCycleError) so it's unit-tested without real timers. The existing DRY_RUN-defaults-true safety gate applies automatically here too, since posting still goes through the same XClient.postReply. Unit-tested with mocked DB/network/timers - no real Postgres, X, or Anthropic calls in automated tests. Also smoke-tested live: ran the real loop against the local Postgres instance with fake X credentials and confirmed a genuine fetch failure (X rejecting the fake OAuth credentials with a real 400) was caught and logged without crashing the loop, while the DB-backed evaluate/post steps in that same cycle completed normally. Adds ANTHROPIC_API_KEY (missed in the earlier personality stage, which only ever used mocks) and LOOP_INTERVAL_MINUTES to .env.example. Updates docs/overview.md marking the build order complete.
Installed PostgreSQL 18 natively on this Windows Server 2019 host (Docker Desktop isn't supported here, and there's no winget available to install it) and ran npm run db:migrate against it. Confirmed both migrations apply cleanly and the resulting schema — including the UNIQUE constraints that back the idempotency guarantees — matches src/db/schema.ts exactly. Updates docs/overview.md to reflect that this piece of previously- pending live verification is done. Running fetch-live/evaluate-live/ post-live end-to-end still needs real X and Anthropic credentials.
Adds write capability to src/x-client: createTweet / XClient.postReply, authenticated via OAuth 1.0a request signing (src/x-client/oauth1.ts, using the oauth-1.0a package), with X_ACCESS_TOKEN/X_ACCESS_SECRET as the user-context token pair. Reuses the existing retry/backoff-on-429 logic from the read path. Per AGENTS.md, live posting must never be enabled without an explicit DRY_RUN flag defaulting to true. That's enforced in the client itself, not just callers: XClientConfig.dryRun (from DRY_RUN) gates every write - when true, createTweet makes no network call at all and just returns what it would have posted. Wires posting into the decisions pipeline built in the persistence stage: decisions gains posted_at/posted_tweet_id columns (migration drizzle/0001_add_posted_tracking.sql), with getUnpostedDecisions and markDecisionPosted in src/db/decisions.ts. A new manual scripts/post-live.ts (npm run post-live) posts any decision not yet posted and only marks it posted after a real (non-dry-run) success - dry-run passes never falsely mark anything as posted. Fully unit-tested against mocked network/DB layers, including explicit coverage that dry-run mode never touches the network and that DRY_RUN defaults to true. Adds DRY_RUN=true to .env.example. Live verification against a real X account and local Postgres instance is still pending.
Replaces src/x-client's local-JSON storage with Postgres, via a new src/db module built on Drizzle ORM: - Schema (src/db/schema.ts): fetched_posts (raw per-post JSON + fetched timestamp, unique on post ID) and decisions (personality/respond output, full reasoning text, timestamp, linked to fetched_posts via a UNIQUE foreign key). That uniqueness constraint - not application logic - is what guarantees a post is never reprocessed or given a second decision, even under concurrent writers. - Initial Drizzle Kit migration generated into drizzle/0000_init.sql. - src/db/decisions.ts: getUndecidedPosts (left-join posts against decisions, filtered to unmatched) and recordDecision, which uses onConflictDoNothing on the unique constraint for query-level idempotency. src/x-client/storage.ts now writes fetched posts straight to Postgres (storeFetchedPosts, replacing storeRawMentions); mentions.ts exposes per-post raw JSON so each post can be stored individually. scripts/fetch-live.ts now persists real fetched mentions to the database. New scripts/evaluate-live.ts reads back undecided posts, runs them through the real personality/respond logic, and records the decision - the first point X data and personality output are wired together, and it's manual-only (npm run evaluate-live), not automated. Required a small additive change in src/llm (optional `reasoning` on CreateMessageResult, new respondAsZumiDetailed) to capture reasoning text for storage without changing respondAsZumi's existing contract. All new/updated tests run against a mocked Drizzle database layer - no real Postgres connection in automated tests. Adds DATABASE_URL to .env.example as an empty placeholder. Live database verification via fetch-live/evaluate-live against a real local Postgres instance is still pending.
Adds src/x-client: OAuth 2.0 auth (pre-configured access token or client-credentials grant), rate-limit header parsing, retry/backoff on 429s, and a configurable (X_MENTIONS_QUERY) fetch-recent-mentions function. Raw responses are stored as local JSON under data/x-mentions/ as a temporary stand-in for persistence. Fully unit-tested against a mocked network, filesystem, and timers - no real API calls in automated tests. Adds a manual scripts/fetch-live.ts (npm run fetch-live) for confirming live data once real credentials are added; not run automatically. Updates .env.example with the X_CLIENT_ID/X_CLIENT_SECRET/X_ACCESS_TOKEN/ X_ACCESS_SECRET/X_MENTIONS_QUERY placeholders this module actually reads, replacing the unused legacy placeholders. Not yet wired into personality/ respond logic - standalone for this stage.
Nobody types "FUD" in a real reply, so the pet-peeve instruction now describes the sentiment and gives concrete phrase examples (e.g. "this is a rug," "this is a scam," "dev is farming") instead of relying on keyword-matching the acronym. Also adds shallow system-prompt-presence tests for the funny/hostile/ heartfelt tone rules, matching the existing cat/FUD test style. These only confirm the instructions were sent, not that the model follows them — noted in a comment in the test file.
Encodes the personality spec (witty, chronically onchain, tone-matching, FUD callouts, cat softness) as a system prompt, plus a mockable Anthropic client interface so respondAsZumi can be unit-tested with zero real API calls. No X integration or posting yet; live API verification is pending until a real key is added.
Documents the stage-by-stage workflow, safety rules (DRY_RUN default, no committed credentials, mocked external calls in tests), and the planned build order for ZUMI.
Structure-only setup for the autonomous X agent: TS config, ESLint/Prettier, Vitest, and stubbed src/ modules (agent, twitter, config, types, utils). No agent logic implemented yet.