tootsies

a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.


Project maintained by mejasonmejason Hosted on GitHub Pages — Theme by mattgraham

Tootsies architecture (the per-surface encyclopedia)

This file inherits CLAUDE.md’s former Architecture section and its discipline, unchanged: before touching a surface, read its section here; when a PR changes a surface’s behavior, update its section here IN THE SAME PR. These paragraphs are load-bearing – they carry every owner steer, incident lesson, and PR cross-reference for each surface, and the numbered (#NNNN) references only work because this record is kept current. CLAUDE.md keeps the always-loaded architecture MAP; this is the full detail, read on demand.

Architecture

Entrypoint: bot.py, boots Discord client, opens DB pool, exposes /health, loads cogs, syncs slash commands per guild on every startup.

Claude API layer: claude_client.py wraps the Anthropic SDK. Optional OpenAI text backend (#openapi-parity): _call routes a model id _is_openai_model() recognizes (a gpt-* / o-series id, label gpt in MODEL_LABELS) OUT to utils.openai_chat (Chat Completions) instead of the Anthropic SDK, so a generation surface can run on GPT to spread token spend onto the under-used OpenAI API bill or fail over when Claude is out of credits — the text twin of the existing OpenAI embeddings + image clients. It reuses the exact assembled system/user prompt (skip_persona, time-context, every per-surface block) and maps the result back onto ClaudeResult, so every downstream guardrail is unchanged. Dispatch by feature: _call_openai sends a tool-free call to Chat Completions and a call with tools / server-side web_search / a client-side tool loop to the Responses API (_call_openai_responses — the only OpenAI path to the built-in web_search + the function_call→handler→function_call_output loop, mapping our Anthropic tool defs onto Responses tools and collecting the web_search source/citation URLs for the link guardrail). IMAGE input (vision) is served on the Responses path — an assembled Claude vision message maps onto input_image parts via responses_input_content, so a GPT-routed surface with images answers on GPT (no longer a deferred gap / Claude fallback); a missing key / API failure raises OpenAIChatUnavailable, so the surface’s own try/except falls back. A best/cheap PAIR mirroring opus/sonnet: gpt (best = gpt-5.5, the Opus analog) + gpt-cheap (cheap = gpt-5.4, the TRUE Sonnet analog — Sonnet 4.6 is $3/$15, gpt-5.4 $2.50/$15; the Haiku-tier gpt-5.4-mini at $0.75/$4.50 is a rung too cheap for a generation surface, available only as an env override), env-overridable. Both are per-surface menu choices (tunables.MODEL_CHOICES = opus/sonnet/gpt/gpt-cheap), offered on every Models-page surface (ask/recap/discourse/music — a new ModelSurface) EXCEPT memory, whose constitutional fence is only verified on Sonnet (ModelSurface.choices = ANTHROPIC_MODEL_CHOICES there, gated behind a GPT fence eval). GPT gets its OWN copy of the OPUS prompts: rules_for() hands any OpenAI model _GPT — a replace(_OPUS) COPY of the Opus lean ModelRules (lean ask/discourse cores, lean persona), not the Sonnet-era _LEGACY walls — GPT-5 over-commits + follows literally like Opus, so the Opus rebuild (#849) is the right starting point. It’s forked into its own instance (byte-identical to _OPUS today) so the GPT-specific polish a dry run flagged (scripts/dryrun_openai_surfaces.py found markdown/inline-citation drift Opus doesn’t have) lands on _GPT alone, never on the prompts Opus itself uses. Every surface still defaults to Claude, so the choice is opt-in and changes nothing until a mod picks it. Safe by a BIDIRECTIONAL fallback (#973): _call is now a thin, symmetric router over two sibling provider methods — _call_openai and _call_anthropic (the Anthropic kwargs-build + client-side tool loop + result assembly, split out of _call so the router is a clean two-provider gateway) — each serving the SAME assembled system/user_content (skip_persona, time prefix, every per-surface block, vision included — byte-identical across providers, only the transport differs). The fallback runs BOTH ways: FORWARD, a GPT-routed call that raises OpenAIChatUnavailable (no key / API error / overload) falls back to Claude (#973: the GPT’s cross-provider sibling — gpt-5.5→Opus, gpt-5.4→Haiku [cheap stays cheap]; an unmapped id → the frontier Opus) — the opt-in safety that makes gpt a per-surface menu choice; REVERSE, a Claude-routed call that fails on credit exhaustion (_is_credit_exhausted — a 400 naming the credit balance, PERMANENT until topped up) or a persistent overload/outage (_is_claude_unavailable — a 529/5xx/429/timeout that already survived _create_with_retry’s retries) falls OVER to OpenAI when it’s provisioned (TIER-PRESERVING: the Claude’s cross-provider sibling — Opus→gpt-5.5 (frontier↔frontier), Sonnet/Haiku→gpt-5.4 (cheap↔cheap), with any UNMAPPED model (an env override, an unknown id) defaulting to the FRONTIER gpt-5.5 rather than erroring/degrading (owner steer #973); the frontier pair derives from _TIER_ANALOGS, the cheap mappings are explicit overrides; credit is account-wide so the failover always succeeds-or-fails identically across tiers) — credit-exhaustion failover, so the bot keeps talking instead of taking every surface dark at once when the Anthropic balance runs dry. A generic 400/401 is NOT failed over (OpenAI can’t fix a malformed request or a bad key — it re-raises as before, so the surface’s own canned fallback + the claude_api ok=False telemetry still fire). Each direction calls the OTHER provider’s method DIRECTLY (never back through the router), so the two CAN’T ping-pong (a forward GPT→SONNET that then fails just raises; a reverse Claude→GPT_CHEAP that then fails re-raises the ORIGINAL Claude error). Both emit provider_fallback (forward reason=OpenAIChatUnavailable; reverse reason=credit_exhausted/claude_unavailable). generate_song_pool(model=...) + music_post(model=...) were dry-run-validated (scripts/dryrun_openai_song_pool.py, scripts/dryrun_openai_music.py) before the music knob shipped. Model routing: Haiku for the cheap, high-volume classifiers/scorers — mention-intent routing (classify_mention_intent), deflections, the chime-in scorer, and the quiet-room cold-open judge (quiet_room_score, fast/cheap); Haiku also tags each memory note with concept keywords at write time (tag_memory_note), expands a thin conceptual recall query into keyword terms (expand_memory_query), and distills a long/foreign shared-video transcript into a compact English summary (summarize_video, purpose=video_summary) — all cheap, high-volume, no fence exposure (they only categorize/condense already-public text). Sonnet for the answer-generating surfaces — /recap, /discourse, chime-in posts (chimein_post — DEFAULT Sonnet, now a per-guild Models-page COMPOSE-model knob distinct from the Haiku chimein_score classifier), /order pre-flight — and the entire long-term-memory write pipeline (hourly writer + /remember backfill + daily rollup) — plus the /guess song-pool generation (generate_song_pool), moved off Opus to Sonnet after a head-to-head dry run found Sonnet’s recognizable-song recall equal (it’s a breadth-of-canon task, not reasoning, and every line is iTunes-verified) at a fraction of the cost, which matters since the pool regenerates on every refill of an endless game. Per-surface model knobs (the /menu Models page, reached from the experiments page): ask, recap, discourse, music, memory, market drop, and chime-in (the COMPOSE model chimein_post, NOT the Haiku chimein_score classifier — defaults Sonnet, Opus opt-in for sharper room-buffer reading / attribution) each have a per-guild model TUNABLE (utils.tunables.MODEL_SURFACESsurface_modelclaude_client.MODEL_LABELS, passed via client.ask/recap/discourse(model=...) / cogs.memory._memory_modelmemory_note/memory_rollup(model=...); model=None falls back to Sonnet so eval/dry-run paths are unchanged). The Models view (cogs.models) renders one opus/sonnet select per surface (fully data-driven off MODEL_SURFACES, so a new surface needs no UI change); defaults preserve today’s behavior — ask defaults Opus (#832), recap + discourse + memory default Sonnet (opt-in to Opus per guild, so adding the page changed nothing). The original ask-model knob generalized into this registry. (the Tier-2 room/live surfaces are added as each is evaluated.) The numbers-desk FAMILY BORROWS the market drop knob rather than each getting its own row (#1611): every surface that composes through compose_market_drop but has no menu row — cinema_desk, music_desk, music_news, music_alert, market_alert, and the betting board/alert/value trio — resolves the per-guild market drop model via the shared claude_client.market_drop_model_id(db, guild_id) (label → id, fail-open Sonnet) and threads it as compose_market_drop(model=...), so a guild’s model choice for its flagship market surface carries to the whole desk family (owner steer: none is a discourse-style culture take, they’re all market/numbers reads that align with market drop, not discourse). Before this they silently rode compose_market_drop’s model or SONNET default regardless of the guild’s Opus/GPT choice. Opus is the ask default because the blinded head-to-head (scripts/dryrun_opus_vs_sonnet.py) found it FUNNIER and LESS preachy than Sonnet on real ask traffic, with NO constitution/restraint regression in the safety pass (scripts/dryrun_opus_safety.py). (OPUS is live again via this knob, not just defined.) Memory DEFAULTS Sonnet (Opus opt-in via the knob), and its per-model PROSE follows the ask pattern (ModelRules.memory_note_room/memory_rollup_keep_daily/_task, the exact analog of ask_core, with named _LEGACY_MEMORY_* fallbacks): Opus over-compacts a note on the reductive verbs it follows literally (“Compact”/”Synthesize”/”Drop”), so its memory prose strips them and reframes the note as a RECALL artifact (it’s searched by vector + keyword, so write it name-dense + specific, breadth + depth); Sonnet/Haiku ignore those words, so they keep the proven legacy prose inline. The fence (_MEMORY_FENCE) + token/char caps are SHARED across models (policy/storage, not voice), so the narrowed fence + raised caps reach production (Sonnet) while the rebuilt prose stays Opus-only. Memory defaults Sonnet because the fence is verified on it (scripts/eval_memory_fence.py); a real-recall cross-model dry run (max(prose, tags) cosine over prod daily #1444) found Opus and Sonnet notes recall about equally (means 0.33 vs 0.35, a 5/4 per-query split), and an Opus fence probe held every PII/address/minor hard line at parity with Sonnet (both pick up one soft “verbatim-quote” judge nitpick, no PII leak), so Opus is a safe opt-in rather than a forced default. The writer is low-volume (one call/hour/active-guild) so the cost is modest. Before editing ANY text Opus sees (constitution → persona → per-surface prompts → tool/context descriptions → eval judges), read docs/PROMPT_OPTIMIZATION.md: the standing lesson from Epic #849 is to OPTIMIZE by cutting/rewording the existing text that causes a drift, NOT by instinctively adding a counter-rule (scar tissue) — Opus follows literally and over-commits, so accretion compounds — plus how to evaluate a change (real-pipeline metrics, n≈5 not n=2, validate the metric, report spread, a wash is a result). System prompt is cached via cache_control: ephemeral. Every API call gets the full constitution + persona prepended (~120 tokens). _call also drives a bounded client-side tool loop (tool_handlers): the model emits tool_use, _call runs the handler, feeds back a tool_result, and continues (capped at _MAX_TOOL_ITERS). With no handlers it’s a single call, unchanged. (web_search remains a server-side tool, no round-trip.) Integrations as tools, in addition to context: the ask flow pre-fetches and injects markets/Perplexity/memory blocks for the common case AND exposes each as an on-demand tool the model can call when the question lands outside the injected block (the Perplexity pre-fetch is model-gated (cogs.ask._TOOLSIDE_WEB_MODELS): it’s the long pole of the pre-fetch (~5.6s median, up to ~10s) and fully redundant with the research_web tool for a model that reliably reaches for it, so it’s skipped for Opus — which also has server-side web_search and is force-grounded on factual asks, so it pulls web research only when a question needs it instead of paying the latency on every ask — and kept for Sonnet, which doesn’t self-route to the tool dependably and so needs the block as a grounding safety net; the tool is offered to BOTH regardless) — search_memory (semantic-vector-first DB recall, keyword/FTS fallback), lookup_catalog (Apple Music / iTunes durations + tracklists, the fix for “longest song” questions a bare web search collides on, e.g. “Drake” the software), lookup_reference (an authoritative, citable fact from the reference library in utils.reference — an extensible registry of sources, routed by intent: Genius for song credits (producer/writer/featured) + identify-a-song-from-a-lyric (returns the song + a LINK to read the words, never lyric text; token-gated, ToS/copyright-clean), MusicBrainz for release metadata (release date / type / featured artists), Wikidata for date/age factoids (“how old is X”, “when did Y die”, “what year was Z founded”), and Wikipedia for CHART peaks on any major chart (Hot 100, Billboard 200 albums, UK, Canadian; the #335/#292 fix for “how many Drake songs peaked at #2”, “how many #1 albums”) - parsed from the discography table because the column must be picked by its wikilink target (“US” Hot 100 vs “US R&B”) and web search fumbles thin per-spot counts - plus general article summaries as the catch-all (other widely-reported facts, like award totals, ride the summary / web_search rather than a bespoke parser); each returns a citation URL; tool-only, no pre-fetched block; the live/breaking path stays on research_web/web_search), the live market/score feeds broken out one tool per source (so the model routes to the exact feed that answers and can cross-check two): api_sports (live scores + in-match events, World Cup soccer + NBA, the fast primary), sgo (Sports Game Odds live scores + sportsbook betting lines), the_odds_api (traditional sportsbook moneyline payouts), lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO; anchored, never a league-wide sweep: it fires only with a player or a game (props are the heaviest SGO call), resolving the live scoreboard to scope leagues + a game’s actual players, then name-filtering the league props to the player(s) asked about; SGO-gated, #390), polymarket + kalshi (the two prediction markets, individually, so a real split reads as “Kalshi 42% vs Polymarket 38%”) — each handler hits ONLY its named source and is provisioning-gated (an unkeyed source isn’t offered; Polymarket/Kalshi are public so always on); this replaced the bundled search_markets, which hid which feed answered, research_web (Perplexity). GitHub/Railway are exposed only through two narrow, gated ops tools so Toots can self-serve when a regular reports she’s broken (instead of only commenting): check_deploys (read-only Railway deploy status + build/runtime logs, so she diagnoses against the real runtime; gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID) and file_fix (file a tracked fix-order for her OWN code — the autonomous counterpart to a mod’s /order, routed through order.py:file_autonomous_order’s preflight + kitchen/pipeline/in-flight gates + a dedicated AUTO_ORDER_DAILY_CAP daily budget; the resulting GitHub issue still becomes a reviewed PR, so the human merge stays the backstop). Arbitrary GitHub/Railway writes (committing code, redeploys, closing issues) stay off the chat surface — the order pipeline is the one safe write path. On a factual question the grounding classifier forces tool_choice: {"type": "any"} (use some lookup tool, model routes to the right one) rather than forcing web_search specifically; _call relaxes the force after the first tool round so the model can still land a final answer. Tool budgets are deliberately generous, not stingy: the client-side loop ceiling (_MAX_TOOL_ITERS) and the /ask server-side web_search cap (_ASK_WEB_SEARCH_MAX_USES) are both 100, runaway guards rather than “use fewer tools” knobs (a low cap was cutting fact-verification off mid-chase and pushing answers from stale memory, #292); room-post surfaces sit at 25 (_POST_WEB_SEARCH_MAX_USES), ample to verify a stat but bounded for their semi-realtime latency.

Persona: persona.py composes the system prompt from constitution.py (hard rules, house rules, calibration) + persona core + voice examples. constitution.py is non-negotiable and cannot be loosened by /order.

Database: db.py, raw asyncpg with inline SQL, no ORM. Schema is idempotent CREATE TABLE IF NOT EXISTS statements that run on every startup. Add new tables here; never drop columns without a migration plan. Post-dedup history is generalized: the “have I said this lately” text-dedup memory for every scheduled/wire surface lives in ONE table, post_dedup_history (guild_id, surface, summary, created_at), keyed by the cog’s ScheduledPoster.SURFACE; write via add_post_history(guild_id, surface, summary) and read via recent_post_history(guild_id, surface, *, limit, window_hours=None) (fed to utils.dedup.duplicate_reason). The per-surface add_*_history/recent_*_history names are thin delegators kept for the cogs. One wired prune, prune_post_history, bounds every surface by its POST_HISTORY_RETENTION_HOURS window (with a default-window catch-all so an unmapped surface can’t grow unbounded). A NEW scheduled/wire surface must NOT add its own *_history table — add a POST_HISTORY_RETENTION_HOURS entry and use the generic methods. (discourse_history carries a category and chimein_history doubles as cadence state, so those keep their own tables.)

Models: models.py, plain dataclasses for DB rows and StrEnums for OrderStatus and MoodMode. No ORM behavior.

Cogs (in cogs/):

Utils (in utils/):

Adding a knob to /menu

/menu (cogs/settings.py, a MenuView of pages) is the one mod-config surface. Discord caps a view at 5 action rows, so each page budgets 4 selects + a nav row. Pick the home by the knob’s TYPE:

Wiring a new MenuView select is a 4-touchpoint checklist (miss one and the UI silently lies — saves but reverts on re-render, or doesn’t persist):

  1. class — a discord.ui.Select whose callback sets self.parent_view.selected["<key>"] = ... and await self.parent_view.autosave(interaction, "<key>").
  2. renderadd_item(...) it on a page’s _render_page branch (mind the row budget; the nav row is always the last).
  3. state-load — read the saved value in _load_initial_state into state["<key>"].
  4. save-dispatch — an elif key == "<key>" branch in autosave’s persist step (most write db.set_setting).
  5. refresh — a branch in _refresh_select_defaults re-applying the picked value’s default/default_values on re-render (selects revert to construction-time defaults otherwise). (A select on the tune view instead persists in its own callback + refresh(), not these MenuView hooks — see _AskModelTuneSelect.)

Nav: the menu chain is vibe → channels1 → channels2 → prediction → experiments → models → calendar → tune, walked by more ▸/◂ back. Tune sits LAST (it’s the raw caps/cooldowns/cadence NUMBERS editor, the most advanced page, so it’s placed past the friendlier ones — owner steer). Every config page’s forward nav reaches the experiments hub via experiments ▸ (on the prediction page, whose next page IS experiments, that single button replaces more ▸). The hop experiments→models is its own models ▸ on the experiments page (a _ModelsNavButton — it swaps in the separate Models View via open_models, not a flip_to page change), and the models page’s back is ◂ back (it steps back to experiments — open_models’s return_page = 4). The hop models→calendar is a [calendar ▸] button on the Models page that swaps in the separate calendar view (cogs.calendar_view.build_calendar_view — the scheduled-post day-planner, chill/yaps pages over a 30-minute working-hours grid), whose ◂ back rebuilds the Models page. The final hop calendar→tune is a [tune ▸] button on the calendar that swaps in the separate tune editor (cogs.tune.build_tune_view); tune is terminal (no onward button), and its ◂ back rebuilds the calendar. The page layout + nav contract is documented in the cogs/settings.py module docstring (keep it in sync).

Order status reconciliation

Opening the /order view lazy-reconciles in-flight orders against GitHub on every invocation (the check_fixes /ask tool reconciles too), and a background reconcile_sweep loop (@tasks.loop, ~5 min, started in cog_load) sweeps every configured guild’s in-flight orders so a shipped fix flips to SERVED + pings the reporter promptly after the deploy instead of waiting for someone to poke the order system. The sweep is per-guild gated on the master kill switch (an OFF guild is skipped, leaving its orders in-flight + served-ping pending) and fully fail-open per guild. For any non-terminal row with an issue_number, reconcile fetches the issue and flips the row to SERVED if the issue is closed. The close-on-deploy.yml workflow closes the issue as soon as the fix merges to main (deploy assumed to follow), so a closed issue is the served signal. It deliberately does not poll Railway for deploy success: Railway is configured to “wait for CI”, so a CI job that blocks on the deploy reaching SUCCESS deadlocks (Railway waits for the check-suite, the check-suite waits for the deploy — this is exactly what stranded #125/#127/#126). Deploy failures are surfaced via deploy_event / Railway logs, not by holding the order open. This replaces the previous out-of-band scripts/update_order_status.py flow, which broke whenever the GitHub Actions runner couldn’t reach Railway’s Postgres host.

Completing the circle (reporter ping on SERVED): every order stashes the channel it was filed/reported in (orders.reporter_channel_id, captured in _file_order, carried through a retry). When the reconcile flips a row to SERVED, _ping_reporter posts back in that channel tagging the original reporter (<@requester_id>) so the person who flagged the bug hears the fix shipped — the human-facing close to the file_fix → issue → PR → deploy loop. Fires once per order (guarded by orders.served_pinged_at, marked after a successful send OR a terminal can’t-reach so a courtesy ping never retries; an OFF bot leaves it pending). Fully fail-open and honors the master kill switch; emits order_served_ping. Applies to mod /orders and autonomous file_fix orders alike (both share _file_order).