a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
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.
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_SURFACES → surface_model → claude_client.MODEL_LABELS, passed via client.ask/recap/discourse(model=...) / cogs.memory._memory_model → memory_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/):
ask.py, the @Toots mention handler (the /ask slash command was removed — mentions, plus voice notes via cogs.voice, are the ask surface now; both run the same produce_answer/_answer pipeline). Uses the per-user ask rate-limit counter. Fail-open on DB errors (better to answer than go silent). Natural-language routing: a plain typed mention with words (not a reply, not pointed at an image) that clears a cheap local cue pre-filter (_looks_routable, a substring scan that gates the paid call so ordinary questions/banter never pay for it) is classified by Haiku (claude_client.classify_mention_intent) into ask|recap|discourse|icebreaker|music — so “@toots catch me up on the last hour” runs recap (period extracted), “@toots whats the discourse” / “@toots give us a conversation starter” run discourse/icebreaker, “@toots give me another recommendation” / “@toots recommend me a song” runs the music drop (a track rec with a link, replied in place) — instead of hunting for the matching slash command. Routing the music intent up front (not letting the ask path answer + voice it) is deliberate: a “recommend me a track” mention is a music pick, not a question, and answering it through /ask was producing voiced replies to typed requests (the #over-voicing report). The classifier owns the path choice so a rec request lands on the music surface instead. Routed intents reuse the destination cog’s own pipeline via get_cog (recap’s produce_recap, discourse’s produce_discourse / produce_icebreaker, music’s _compose public entry points) and charge the destination’s rate-limit bucket (recap → per-user recap; discourse/icebreaker → server-wide discourse; music → per-user ask, since music has no bucket of its own and the mention would otherwise have been an ask), mirroring the slash commands, which still exist. Abuse detection (_maybe_handle_abuse) gates routed intents too, so harassment can’t slip past the warn/silence escalation by phrasing itself as a recap. The classifier defaults HARD to ask and fails open to ask, so a real question/banter (or a Haiku outage) always just answers. Emits mention_routed. Injects a fixed long-term-memory slice (the tier mix) AND hands the model a suite of on-demand tools to dig past the injected context: search_memory (guild-bound recall over the guild’s notes, semantic-vector first: _vector_recall embeds the query (text-embedding-3-small) and cosine-ranks the embedded notes in-process, so a conceptual query like “the messiest thing” lands near a drama/beef note natively even though the fenced prose never says “messy”. A strong/numerous vector result stands on its own; a thin one (fewer than _RECALL_MIN_HITS) still gets the durable daily-arc backfill the keyword path uses, so a single stray semantic hit never leaves the model with less material than the old lexical path would. When vectors are unprovisioned/empty it falls back to the keyword/FTS path — full-text over note prose + concept keywords, on a thin literal match Haiku-expands the concept into keyword terms and re-searches, then backfills the durable daily arc. Emits memory_search with a vector field marking which path answered), recall_detail (the hour-grained drill-down companion to search_memory — same vector-first recall but over the kept hourly notes including rolled-up ones that search_memory dedups away, so after a day-level hit the model can zoom into the blow-by-blow; emits memory_search with detail=True), lookup_catalog (Apple Music / iTunes catalog; emits catalog_lookup), lookup_reference (an authoritative, citable fact from the reference library utils.reference — an intent-routed registry of sources, each returning a citation URL: Genius (utils.genius song credits — producer/writer/featured — and identify-a-song-from-a-lyric via the official API, returning the song + a LINK to read the words, never lyric text, ToS/copyright-clean; provisioning-gated on GENIUS_ACCESS_TOKEN, dormant + falls through to Wikipedia prose when unset; first in the registry so credit/lyric intent routes here), MusicBrainz (utils.musicbrainz release-group metadata: release date / type / featured artists, e.g. “when did Scorpion come out”; the model is told to include the artist in the query so a bare title doesn’t match the wrong act; producer/writer credits are deliberately left to Genius since MB’s recording-search ranking + credit coverage are unreliable), Wikidata (utils.wikidata typed date/age factoids: born/died/founded + computed age), and Wikipedia (utils.chart_data) parses per-position CHART peaks for any major chart (Hot 100, Billboard 200 albums, UK, Canadian, detected from the question; the #335/#292 ground truth, e.g. “how many Drake songs peaked at #2”, “how many #1 albums”), routed by chart/discography intent - the one place we keep a bespoke parser, since the column is identified by its wikilink target and web search fumbles per-spot counts - plus the general article summary as the catch-all (award totals and other widely-reported facts ride the summary / web_search)); specific sources match first and fall through to Wikipedia on a miss, fail-closed to a hedge when nothing clean is found; tool-only with no pre-fetched block; emits reference_lookup), song_credits (Genius promoted into its own crisp tool, #847 — utils.genius.lookup direct: producer/writer/featured credits, what a track samples/interpolates, what a line/song means, identify-a-song-from-a-lyric; returns a LINK, never lyric text; token-gated via genius.provisioned() so a dead tool is never taught; Genius STAYS in the reference registry as the wider fallback; emits the shared reference_lookup event with source=Genius|none so its integration-health telemetry survives bypassing the reference dispatcher), read_media (on-demand read/transcribe of a specific video or X/Twitter URL the model hasn’t seen in context, #847 — the ambient ingest is async so a just-posted clip is bare on first read; CACHE-FIRST via video_ingest.cached_video returns a resolved clip’s metadata + transcript instantly, and on a miss it kicks the SAME video_ingest.kick_fetch background fetch the ambient path uses (cached for every later reader) + optionally awaits a short bounded timeout for a fast captions/fxtwitter resolve, NEVER blocking on a long-clip STT; gated on video_ingest.should_fetch() (VIDEO_TRANSCRIPTION kill switch + yt-dlp); emits read_media with the cache_hit|fetched_inline|pending|not_media disposition. Accepts a raw uploaded video file too (video_fetch.is_direct_media_url — a direct .mp4/.mov/.webm URL like a Discord attachment, host-agnostic by extension), routed through fetch_video’s generic yt-dlp path and keyed by canonical_key so it caches — so an OLD uploaded clip found via search is transcribable on demand, not only the recently-buffered ones the ambient path covers), 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). DOWN-vs-no-data signaling (the fabricated-slate fix): sgo and the_odds_api now report an EMPTY result differently depending on their circuit-breaker .degraded state — a DOWN feed returns an explicit OUTAGE string (pointing to the peer feed), distinct from a feed that’s UP-but-empty (a genuine “no line, cross-check the peer”), so the model never reads a downed feed as “no games” and invents a slate (the Opus World Cup fabrication: SGO 100% down + (no SGO lines) read as “no games”). The descriptions bake in the capability matrix (utils.sportsdata.sources.CAPABILITIES): sgo/the_odds_api are PEERS on the same games (SGO reprices IN-PLAY, the_odds_api is PRE-MATCH-only → for a live number prefer sgo or the prediction markets, which DO move in-play); api_sports has NO odds and empty = nothing LIVE, not “no games today”. Cross-provider scoreboard trio (the core fix): live_scoreboard (every LIVE game MERGED across ALL providers via sports_hub.live_games(), the cross-provider “what’s on” the single-source tools miss), game_schedule (today/tonight’s UPCOMING fixtures via upcoming_games, since api_sports only sees live + just-finished so the schedule lived nowhere), and recent_finals (today’s/just-FINISHED games + final scores via recent_finals(), the “what were today’s scores / who won today” answer that was only reachable through api_sports’ named-game-miss fallback) — all three append _FEED_DOWN_NOTE on an empty result IFF a primary feed is degraded (_scores_degraded), emit scoreboard_lookup (phase=live|schedule|finals). API-Sports DEPTH tools (api_sports-gated, each resolving a named game on the live/upcoming/finals slate via _resolve_slate_game): standings (the group/league table → game_standings → format_standings), matchup_preview (formations/form/H2H → game_pregame → format_pregame), box_score (leaders + team shape → game_leaders/game_team_stats); plus list_sports (the sportsbook coverage catalog via the_odds_api.list_sports(), so “do you have odds for X” is answered from the real catalog) and match_highlights (an on-demand post-game video clip for a named game via highlightly.get_highlights, the user-asked counterpart to the commentator’s auto-posted clips; Highlightly-gated, emits highlight_lookup). Prediction-market GAME discovery: kalshi_game_markets / polymarket_game_markets sweep the matchup PLUS each team name (and a resolved game’s real names) so a bare keyword search that whiffs (the Kalshi “world cup” → 0 hits incident) still finds the matchup markets (emit game_markets_lookup). lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO, #390; anchored, never a league-wide sweep: it refuses with no player/game (props are the heaviest SGO call), reads the live scoreboard to scope the prop-covered leagues + a named game’s actual players (sports_hub box-score leaders + goal scorers), then name-filters the league props via match_player_props/format_player_props to just the player(s) asked about; SGO-gated, reuses the commentator’s prop matchers, fail-open to a soft string; instrumented via the underlying market_fetch event), break_down_board (#622: ONE named game’s FULL market board — game lines (moneyline/3-way/spread/total/BTTS/1st+2nd-half totals), game props (corners, first-to-score), and player props, each selection carrying the book’s implied % — so she TRANSLATES the interesting edge in plain words (“ignore the wall, the live one is over 0.5 1H goals at 75%”) instead of dumping a board of ¢ prices. Anchored to a named game like lookup_player_props (resolves the live/upcoming scoreboard → the SGO-covered league → matches the board by team name); SGO-gated, soccer-deep / NBA-thin (SGO barely carries NBA odds depth). The whole odds tree is a flat {statID}-{entity}-{period}-{betType}-{side} dict already in the payload, parsed by the pure utils.sportsdata.board.parse_game_board (standalone — inlines its own implied-prob math to avoid the markets↔sportsdata import cycle) + rendered by format_game_board, fetched via SportsGameOddsClient.get_game_board; emits board_lookup with the per-category market counts as depth telemetry. When SGO is degraded (#725) the board falls back to the Odds API deep board: a FREE /events resolve + ONE metered get_event_board (/event-odds over ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) parsed by the pure parse_odds_api_board into the SAME GameBoard so format_game_board renders unchanged, budget-guarded by the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE) so the credits the /bet SGO-down backstop depends on are never blown), 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, gated on it being configured), and the ops self-service pair so a “you’re broken” report gets diagnosed + fixed instead of only commiserated with: check_deploys (read-only Railway deploy status + build/runtime logs via utils.railway.RailwayClient.diagnose, gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID; emits deploy_check) and file_fix (file a fix-order for her own code off a regular’s bug report, routed through cogs.order.Order.file_autonomous_order — preflight + kitchen/pipeline/in-flight gates + AUTO_ORDER_DAILY_CAP, attributed to the reporting regular; emits auto_order. Prompt-orchestrated dedup (not a code gate): the file_fix tool description + COMMAND_GUIDE instruct her to check_fixes FIRST and only file a NEW order if nothing open already covers it — an existing in-flight match routes to update_fix (add the new detail) or, with nothing new, to a “it’s already tracked” reassurance, so she doesn’t double-file), plus the customer-service-desk pair so she’s her own help desk: check_fixes (read the status of this server’s filed fix-orders via cogs.order.Order.recent_fix_status — reconciled against GitHub, and merges in raw open GitHub issues filed outside the order pipeline so a directly-filed bug isn’t invisible, scoped to user-facing labels (order/bug) with internal/ops issues (auto-eval/feedback) excluded, deduped by issue # against the order rows, user-facing fields only, so she reassures a reporter their bug is tracked/shipped instead of double-filing) and end_game (end a stuck /guess game in the channel on the asker’s behalf via cogs.games.Games.end_game_for; any user can end the game, matching the open-access End button on the game embed); both emit self_serve. Plus update_fix (the counterpart to file_fix, cogs.order.Order.add_fix_update): when a regular adds new detail to a bug already reported (a follow-up reply), it appends their note as a plain attributed comment onto the existing in-flight order’s GitHub issue so the extra context reaches the fix — no @claude, so it records without spawning a duplicate PR run; scoped to this guild’s non-terminal orders (a shipped/closed one routes back to file_fix), runaway-guarded by the shared AUTO_ORDER_DAILY_CAP (one ceiling for both regular-driven order ops, file_fix + update_fix, with separate daily counters); emits fix_update. Plus note_feedback — the lighter lane beside file_fix (cogs.order.Order.file_feedback): a suggestion/wish/sentiment that isn’t a concrete code change gets logged as a comment on ONE rolling feedback-log GitHub issue (label feedback, plain body so claude-code-action ignores it) for mods to triage, gated only by a per-guild FEEDBACK_DAILY_CAP (no preflight/kitchen/pipeline); emits feedback. The /ask surface also always carries a COMMAND_GUIDE (mirrors cogs/help.py) so she points people to the exact real command rather than inventing one. Each is the on-demand counterpart to a block the cog also pre-fetches (except lookup_reference, which is tool-only). The utils.reference _SOURCES/_EXTRACTORS registries are the extension points for adding more authoritative sites/table shapes (utils.reference_types holds the shared ReferenceResult + fetch_json to break the import cycle); the live/breaking path stays on research_web/web_search. Voice-out on a typed mention: the mention answer runs with allow_voice=True, and a voice-nominated answer (the <voice>/<sing> tag) routes through cogs.voice’s shared deliver (user_sent_voice=False) so a typed @Toots can come back spoken or sung when voice is provisioned (bot.tts); a plain answer (the common case) just text-replies, skipping the voice_reply event. Text stays the default and the tag is stripped from any text reply. This is the reliable directed channel for a voiced answer, since a Discord voice note can’t carry an @mention. Image-out on a typed mention: the mention answer also runs with allow_image=True when the image client is provisioned (OPENAI_API_KEY set), so the model can nominate a generated picture by wrapping its description in an <image>...</image> tag (parsed by utils.image_signal); _deliver_image then renders it via utils.image_gen (OpenAI GPT Image) and replies with a native PNG attachment + the caption (the text outside the tag). It’s the rare case (a “draw me X” / visual-gag answer), checked before the voice/text branch and fully fail-open: no key → tag never taught; generation or send fails → it degrades to the caption as a plain text reply, so a nominated image always falls back to its words. Emits image_reply (cog, delivery outcome) + image_generated (the API call). Hardened with a budget (#326): image is a permanent surface gated by provisioning (OPENAI_API_KEY/bot.image) AND a per-guild daily budget (image_daily_cap, default 20 to match the server-wide daily cap, DB-backed via the server-rate counter, restart-proof, checked before the paid generate/edit so a capped image costs nothing). The cap bounds cost in both stages — every room send AND every staging audition counts against it — because image has no per-channel cooldown, so the guild cap is its only budget guardrail (image keeps its budget because a render is dear ~$0.07; gif, being near-free, has no cap at all and is paced by the model’s own nomination + vision gate). A capped/failed nomination always degrades to the caption text. Remix (image edit): when there’s a source image in the immediate context (an image attached to the replied-to message OR to the mention itself, found via _first_image_attachment), the model also gets a <remix>instruction</remix> tag that edits that image instead of generating fresh (<image> = new from scratch, <remix> = change the one that’s here). _deliver_remix reads the source attachment bytes and runs utils.image_gen’s edit (OpenAI images/edits, multipart upload), sharing the same _send_image delivery + fail-open contract as generation (unsupported source / read miss / edit fail → caption text). allow_remix is only passed (and the tag only taught) when both the image client AND a source image are present, so she’s never told to remix nothing. Source priority: the replied-to image first, else the mention’s own attachment. Gif-out on a typed mention: the mention answer also runs with allow_gif=True (only when bot.gifs is provisioned), letting the model nominate a reaction gif with a <gif: query> tag (parsed off by utils.gif_signal, stripped before any text/voice delivery so it never leaks). The gif rides alongside the answer (it doesn’t replace the delivery channel, so it can stack with a voiced reply) as a follow-up message, gated only by Giphy provisioning (GIPHY_API_KEY/bot.gifs): when provisioned she sends the Giphy gif to the room. There is no daily cap or per-channel cooldown — the model’s own nomination + vision pick are the pace (the redundant pacing knobs were removed). A bare <gif:> nomination with no words never sends an empty message (the prompt also tells her to always write a text line); if a gif is the only thing and it misses, a quip covers it so a summon is never left silent. She vision-picks the gif by her own taste rather than sending the top text-relevance hit: the Giphy search returns candidates (each with a small preview), and claude.pick_gif (a cheap Haiku vision call, the outbound twin of the inbound gif vision she uses to narrate gifs posted in chat) looks at the preview frames (plus the actual moment — her reply + what she’s replying to — so a deadpan line gets a sarcastic gif, not an earnest one) and picks the one she’d actually send — it gates on “does it read as the reaction” AND “does it fit the moment” AND “does it fit her voice (sharp/ironic, not corny/basic)”, biases hard toward none fit (skip rather than send a mid gif), and falls back to the top hit only if the vision call errors. Pacing/budget: there is no daily cap or per-channel cooldown — the gif ships only when the MODEL nominated one (a <gif:> tag) AND the vision pick judges a candidate fits, so her own judgment IS the pace (the redundant cap+cooldown knobs were removed; the vision gate biases hard toward none fit, which keeps gifs occasional on its own). Any gif miss (no result, none fit, send failure) is fail-soft and never touches the already-delivered answer. Emits gif_search (the Giphy call) + gif_reply (the disposition, incl. the vision field). Consented memory (remember_about_you, the user_facts store): a regular can explicitly teach Toots a fact about THEMSELVES — a nickname, how to be addressed, a light preference (“call me X”, “don’t call me Y”, “remember this”) — and she stores it per-(guild, user) (db.add_user_fact, case-insensitively deduped via a SQL existence check; storage is uncapped since rows are cheap, while the per-reply read-back is bounded to the newest USER_FACTS_READBACK) and reads the asker’s OWN facts back into her reply context via the privileged <about_them> block (_user_facts_context), so she talks to that person the way they asked. This is user-directed and consented, distinct from the observational, constitution-fenced memory_notes pyramid (which captures observed public behavior and can’t store a volunteered “call me X”): a user’s facts only personalize HER replies to THAT user, are never disclosed to or about anyone else, and the tool steers AWAY from sensitive categories (health, identity, religion, politics, location, contact, anyone else) — those get a warm in-chat ack but no store. The constitution still gates everything she says. /forget wipes a user’s facts alongside their notes (db.delete_user_facts). Emits user_fact (cogs.ask._make_remember).discourse.py, the discourse surface: the mood scheduler background task (proactive posts) + the mention-routed entry points (produce_discourse/produce_icebreaker, reached via @Toots what's the discourse / give us a conversation starter, routed by cogs.ask). The manual /discourse slash command was removed — discourse is proactive + mention-routed now (like /ask), so there’s no standalone command; schedule control lives on /menu. The shared _compose pipeline + the quiet-slot linkless icebreaker fallback (_icebreaker_fallback, material-only invariant kept) are unchanged. Trending-clip variant (#1272): SOME scheduled slots are a “did you see this” REACTION to a fresh trending social clip instead of a news take. Chosen by QUALITY, not a dice roll (owner steer — “the best discourse we have as an option, not a crap shoot every time”, and don’t STARVE trending): _try_trending (called first in _compose_with_retry, gated only on ScrapeCreators provisioned) gathers topic-scoped trending clips (_gather_trending_clips: DISCOVERS the SPECIFIC current viral moments that fit the channel’s vibe via _trending_search_phrases — Perplexity (build_search_query(surface="trending"), theme-scoped, this-week) names what’s actually blowing up RIGHT NOW as short searchable phrases (a celebrity drama, a meme, a release), cleaned by social_search.parse_trending_phrases — then social_search.search_socials searches EACH phrase across all socials + merges. This is the fix for the old raw-topic-string query, which fed the channel’s Discord description straight to ScrapeCreators’ dumb keyword search: the broad “anything goes / pop culture” hub matched literal junk (an 8-year-old “Similes in Pop Culture (For Kids)”, Bing Crosby’s Anything Goes) and never surfaced the current drama; Perplexity discovery live-returns e.g. “BBL Drizzy Drake meme” / “Travis Kelce Taylor Swift honeymoon” → each surfaces fresh on-moment clips. A broad room catches the actual pop-culture drama, a niche room stays on its niche (the topic steers Perplexity); falls back to the general discover_trending feed when discovery yields nothing. The recency gate is 7 days (_TRENDING_MAX_AGE_DAYS, “is it really trending if it’s 2 weeks ago” — with Perplexity surfacing this-week moments the clips come back fresh, so the tight window cleanly drops stragglers). The pool is BALANCED per platform via social_search.balance_by_platform — a round-robin over each platform’s engagement-ranked list, NOT a raw-engagement top-N, so YouTube’s million-view magnitudes don’t crowd TikTok/IG out of the slate she picks from, the “normalize engagement” fix), hands them to claude.react_to_trending — an ISOLATED client method (its own prompt, NO web_search grounding pass so she reacts to the given clip instead of wandering to a news topic — the exact failure a plain-discourse dry run showed) that picks the ONE clip that’d spark THIS room, reacts in voice, and ends with its link. She SEES the clip, not just its caption: each candidate carries a cover still frame (SocialResult.frames — TikTok video.*.uri covers + a clean unsigned YouTube hqdefault.jpg; Instagram’s signed CDN isn’t Anthropic-vision-fetchable so IG clips stay caption-only) attached as VISION on the react (labeled clip N, bounded to the API’s 10-image cap), so a hashtag-soup caption (#fyp #dog #funny) she’d otherwise decline EMPTY becomes a real reaction to what’s on screen — a dry-run A/B confirmed frames flip held visual clips to ship (a dog-launch bit EMPTY→0.80) and catch a caption↔video mismatch, where a transcript A/B did nothing (0/4, TikTok transcripts are absent or garbled). She ends with the clip’s link (allowlisted so the guardrail keeps it; no verify_live_links HEAD check since a datacenter-IP HEAD to tiktok/yt false-strips a real link) — then ships it ONLY when the reaction clears a HIGH bar ON ITS OWN (_TRENDING_SHIP_FLOOR≈0.7, ABOVE the normal 0.6 floor), scored on the surface="trending" REACTION rubric (discourse_score’s carve-out like icebreaker/music: grades whether the REACTION lands, NOT whether it argues a take — the bare take rubric craters a “did you see this” drop as a hollow/soft take for not picking a side, live-confirmed cratering good reacts to 0.25-0.31). A great clip reaction wins the slot on its own merit (never starved by a marginally-higher news take — the head-to-head compare that starves it); a mediocre one returns “” and the normal _compose runs instead (so the news pipeline is skipped whenever trending wins — the cheaper path). The bar is live-calibrated: good reactions cluster ~0.78, weak ones ~0.25-0.31, so 0.7 cleanly separates them with margin for judge noise. Material-only + self-declining: it returns “” (→ the slot falls through to a normal discourse post) when unprovisioned, no on-theme clip fits, the model declined (dry-run-verified: correctly returns EMPTY on the noisy GENERAL trending feed, reacts + links on a topic-scoped one), or the reaction is under the high bar. Movie/TV grounding (#cinema-facts, owner steer “tool she reaches for”): when the cog wires a reference_lookup handler (utils.reference.lookup_reference), react_to_trending is handed the on-demand lookup_reference TOOL + a movies/TV grounding prompt block, so a clip about a specific FILM/SHOW (a trailer, a box-office moment, casting, reviews) lets her pull the REAL facts (director/cast/RT+Metacritic/box office/awards off the #1561 TMDB+OMDb movie source) and work the ONE sharpest detail into her reaction — a dry run flipped a Sinners react from a vibes take to “$370M worldwide, four Oscars including Best Actor for MBJ…”. It’s the ONE tool on this surface (no web_search wander); optional (only when the handler’s wired), never an invented figure, threaded through the leak-repair retry. Emits discourse_scored (category="trending") + claude_api/link_stripped (purpose="discourse_trending"). Market routing reads the recent conversation (not just the channel name) so the Haiku classifier can spot a sport in content (“SGA is cooking” → NBA); when it does, markets.get_context pulls the whole live scoreboard (get_live_scoreboard, every covered league, live games only) so the post can cite a real score.order.py, the /order feature pipeline. One command: /order <feature> files now (pre-flight checked); bare /order opens a management view — a filterable status list, a + new-order button (modal), retry/cancel on a picked order, and a kitchen open/close toggle. This replaced the old /order new|status|retry|cancel subcommand group and absorbed the standalone /close + /open. The interaction-free cores (_file_order / _retry_order / _cancel_order) are shared by the slash arg, the modal, and the buttons. Pre-flight sanity check, in-flight cap, pipeline-red blocking, per-user cooldown. Mod-only via _mod_gate.music.py, /music setup (channel picker) + /music drop (manual post) + scheduled music-lounge posts (track recs with Apple Music links). Sources: feed channels (Twitter/social), Perplexity, channel activity, web_search, plus the two GROUNDED fresh-hit sources the priority-1 “fresh hit” lane picks from so a drop names a REAL current/new song instead of fabricating a “just dropped”: hot_chart (utils.music, the currently-CHARTING top songs, Deezer-led + iTunes RSS) AND new_releases (utils.music/utils.deezer editorial, the JUST-DROPPED-THIS-WEEK releases the chart lags — a hit charts for weeks after release, so this is what makes a New Music Friday drop name a genuine new release rather than a catalog classic; Deezer-only since iTunes’ legacy new-music RSS is dead, and the model curates its long-tail firehose by its own artist knowledge). The music-drop Perplexity pull uses surface="music" (a FRESH-HIT query for the biggest new/hot songs out this week with artist+title, recency=week), distinct from the discourse/news template (which explicitly asks for story pages “not a trending songs this week list”). The priority ORDER is unchanged (most drops are still a classic/deep cut, a fresh hit only when one genuinely fits) — these sources just make the fresh lane land on a real new release when she does reach for it. Links-only channel. Rides on the existing mood schedule. Song-identity dedup (the “Folded keeps re-dropping” fix): the shared music_history dedup keys on the TAKE text + trailing link, which a re-drop of the SAME song under a different take/link form slips past — and the grounded chart sources keep resurfacing a currently-charting song (e.g. Kehlani’s “Folded”), so the model keeps picking it. So music now also keeps a durable SONG reuse block (music_recent_songs, the analog of the guess games’ game_recent_songs): each DELIVERED drop’s canonical song_key(title, artist) — folded from the model’s TRACK: line via drop_song_key, the SAME key the games use, so the two dedup identically — is logged, and a track dropped in the last _SONG_REUSE_DAYS (30) is (1) filtered out of the hot_chart/new_releases material before it reaches the model (filter_blocked_songs, using the FULL block, so a still-charting repeat is never handed back as “fresh” — the direct answer to “is it due to charts?”), (2) surfaced to music_post as a LIGHT prompt nudge — only the freshest ~15 (the block is newest-first), since the full-set filter + hard gate are the real teeth and a long negative list is redundant + can anchor the model toward the songs it lists — and (3) hard-blocked at post time on SCHEDULED drops (same_song gate → one retry → skip the slot; manual /music is a mod deliberately asking so it isn’t hard-blocked, but still benefits from the filtered sources + prompt block). Pure drop_song_key/filter_blocked_songs unit-tested; bounded by prune_music_recent_songs (45d, outlasting the reuse window). Emits music_dedup (decision=song_gate, signal=same_song).cinema_desk.py, the cinema desk (#cinema-desk): the movie/TV sibling of the music-numbers desk (#1461). A ScheduledPoster subclass that each slot sweeps four FREE sources — Box Office Mojo (domestic weekend grosses, utils.box_office), Netflix Top 10 (weekly hours-viewed + views, utils.netflix_top10), TMDB (new theatrical/streaming/TV releases + title metadata, utils.tmdb), and OMDb (a film’s critical scores — RT % + Metacritic + IMDb, utils.omdb) — ranks them by freshness/story (utils.cinema_numbers.fetch_cinema_stories → rank_stories), and posts the top story that hasn’t posted this week into the guild’s cinema channels (its OWN /menu picker + page, db.get_cinema_channels, NOT reusing music channels — owner steer). The lanes each answer a different question (box_office/streaming/release/scores + the two Perplexity-driven NEWS lanes awards/tv_news); the CRITICAL-SCORES lane is the one differentiated fact market_drop can’t do (#cinema-scores, owner steer: market_drop posts the ODDS on a movie market, this posts the FACT — “reviews are in: 94% on RT”). Kalshi’s own RT/score markets are too THIN to read a number off (verified: 0 of the top-200 traded Entertainment events are score markets), so scores come from OMDb directly; the streaming numbers the desk would have wanted from Kalshi it already gets from the Netflix TSV, so the whole Kalshi-numbers route is a dead end (music works on Kalshi only because those markets actually trade). The movie beat is RELEASE-CENTERED (box_office_story leads with the biggest NEW opener via the first-weekend total≈gross heuristic, not an evergreen hold — owner steer). On a weekend with NO big opener it leads with a notable HOLD/DROP trajectory instead of a flat “#1 led with $X” (#cinema-facts owner steer “sharper box-office angles”): parse_chart now captures BOM’s %± (weekend-over-weekend change) + weeks-in-release columns (position-anchored off the Gross + Total cells, live-verified), and _notable_holdover surfaces the sharpest legible trajectory among the holdovers — a strong HOLD (dropped ≤35%) or a CLIFF (≥55%), gated by a _NOTABLE_MIN_GROSS ($2M) floor so a real film’s trajectory carries it, not a tiny arthouse expansion. Every blob line now carries its %±/week so the take reads trajectory even on an opener weekend (“Toy Story 5 only dropping 28% through five weekends”). Worldwide context (#cinema-facts): resolve_lead also surfaces the lead film’s TMDB lifetime WORLDWIDE gross (LeadInfo.worldwide), folded into the box-office blob as context (≥$50M) so a domestic-weekend take can situate it globally (“$40m domestic, already $310m worldwide”) — never a headline number, the card still leads with the domestic weekend. Unlike the music desk there’s NO gatekept scoop (box office is public), so the value is her curated take — she’s the CONSOLIDATED movie/TV NUMBERS + RELEASES source; the prediction-market ODDS layer (reality-TV winners, casting/speculation) is DELIBERATELY left on market_drop (owner steer). Per-STORY weekly dedup (cinema_desk_events, one box-office/streaming/release story per week) so the weekly cadence emerges even on a daily slot pool. Compose reuses the shared compose_market_drop voice (a per-kind context — the NUMBER lanes lead with the figure, the release lane is a “what’s out this week” roundup); self-gated by claude_client.cinema_desk_score (0.6 floor, fail-CLOSED — the sibling of music_desk_score, NOT the betting drop_score), which is kind-aware: the number lanes (box_office/streaming/scores) are graded on the figure + a sharp read (a critical score IS a valid number), while the release lane is graded on a “worth-watching heads-up” rubric (NOT docked for having no number — the #1555 fix for a lane the numbers rubric was silently killing). The release blob carries a one-line SYNOPSIS per title (_synopsis/_release_line off the free TMDB overview, not just bare names + dates), so the take can say what a title IS rather than hedging (“worth a look depending on what it is”) — the telemetry-driven fix for the lane chronically self-gating OUT (prod: 5/5 dropped at avg 0.15; the enriched blob ships at 0.62–0.78). Gated on its OWN cinema_desk experiment (production → the cinema channels, staging → a #bot-logs audition, off → skip) + master switch + mood; cadence on the /menu calendar (cinema_desk is a calendar surface). All three sources are free + unmetered (no quota poll) but fully guarded (rate limiter + circuit breaker + retry + a TTL cache) and instrumented (tmdb_fetch/box_office_fetch/netflix_top10_fetch/omdb_fetch per-call + integration-health, which surfaces a silent BOM IP-block / TMDB key death / Netflix TSV shape change / OMDb key death). Delivery is the UNIFIED BIG-NUMBER card — the SAME schema as the music desk (#cinema-facts, owner steer “big numbers like the music desk, with the art” + “unified design schema”): the film NUMBER lanes (box_office/scores) ship the shared utils.market_chart.render_number_card PNG (the exact music-desk card: a small-caps dot-path eyebrow CINEMA · BOX OFFICE, the film title, the BIG GREEN figure — the weekend gross / the RT % or Metacritic score — a sub-caption, and a status pill REPORTED/CONFIRMED), now with the film’s TMDB POSTER composited top-right via render_number_card’s new optional artwork_png (a backward-compatible param the music desk can also use for album art). It rides in the SAME Components-V2 LayoutView the music desk uses, assembled by the new public utils.market_cards.build_number_card (her take as a TextDisplay + the card image + a native link Button), delivered via send_market_card. The button links a Kalshi market for that film when one genuinely exists (CinemaDesk._kalshi_link → markets.kalshi_search with a film-scoped intent, thin coverage so usually absent) ELSE the film’s TMDB page. The card also folds in a lazily-resolved CREDITS grounding line (director + top cast off cinema_numbers.resolve_lead, one guarded TMDB lookup for the CHOSEN story only) so the take can name the makers (“Ryan Coogler’s Sinners”). The STREAMING lane cards too (#1578): the Netflix #1 is often a TV SHOW search_movie can’t resolve, so resolve_lead(media_kind="tv") pulls the poster + TMDB tv page via tmdb.search_tv (a show carries no director/cast credits line — creators, out of scope). Only a release (multi-title roundup) story, or any figure/render miss, degrades fail-open to a plain-text post. The card renders at 2400×760 (a _OUT_SCALE-2× higher-res emit, #1578) with a w780 composited poster, and carries NO status pill (dropped — the verdict lives in her voice). The card build runs off-thread (asyncio.to_thread) and the poster fetch is best-effort. X crosspost (#1581): a shipped card mirrors to the @tootsiesbar timeline (take + card image + Kalshi/TMDB link) via maybe_crosspost (surface="cinema_desk"), gated by the x_crosspost experiment. Live context beat (#cinema-facts): before composing, the desk pulls utils.context_beat.gather_context_beat (Grok X-crowd + Perplexity web/socials buzz, purpose="cinema_desk") and passes it as compose_market_drop’s x_context so the take carries the sharp CURRENT angle (the fandom read, a critical split, a second-season-slump framing) the bare number can’t — sentiment/context only, hard numbers still from the story blob; provisioning-gated + fail-open. News lanes (#cinema-facts, awards + tv_news): two Perplexity-driven NEWS beats beyond the scraped numbers — awards_story (Oscar/Globe/Emmy/festival nominations + wins + snubs) and tv_news_story (a show renewed/cancelled/greenlit). fetch_cinema_stories takes the perplexity client and runs one fresh-week search per lane; the Perplexity text IS the grounding (like the release lane’s TMDB titles — the compose quotes its specifics, never invents an award/count/renewal). Each self-declines (_clean_news → no story) when the pull returns the NONE marker / a “nothing this week” punt, so awards stays dormant most of the year (seasonal) and TV news only fires on a real renewal week. Scored on the release-style rubric (cinema_desk_score routes awards/tv_news there — no number required, an award COUNT is fine), weekly-deduped (aw:/tv:). Raw ART on the bare-text lanes (#everything-has-art → #thin-ahead-art): the news/release lanes (tv_news/awards/release) no longer ship bare — after the take composes, the ONE unified resolver runs on it (resolve_desk_image(line), the same ranked-subject walk every desk uses: a film/show take → its TMDB poster, a person-centric awards take → their photo), and the resolved bytes ship attached to the take (NOT a custom card — the big-number card format is for a figure, not a block of prose; the raw art IS the image). This replaced the bespoke title-only claude.cinema_news_hero extractor (deleted — it went bare on any take without a single film/TV title). _sched_deliver routes a lane with a card_number (the box_office/scores/streaming NUMBER lanes) to the unified build_number_card, a lane with only resolved art to the raw discord.File + take, and a lane with neither to plain text; the crosspost mirrors the art bytes to X. Resolved from the composed TAKE (not the story) so the image matches what she actually led with; fully fail-open (no art → the bare-text post, never skipped). The NUMBER lanes take that resolved image as the card’s BACKGROUND too (owner steer “even for cards we can use the images as background”): the resolve is no longer gated to the bare-text lanes, so a number lane whose lead has no TMDB poster falls through to resolve_desk_image and the card ships a real full-bleed background instead of the dark panel. The art RUNG travels with the bytes into build_number_card(artwork_source=) → _art_fit, so a logo is fitted whole rather than cover-cropped. Emits desk_art (surface=cinema_desk) so an art-less card is a queryable rate rather than something spotted by eye — carded only ever said a card was BUILT. Pure builders/parsers/ranker in utils/cinema_numbers.py + the shared render_number_card/build_number_card + the three client modules (unit-tested tests/test_cinema_desk.py); the guarded fetch lives in the clients. Emits cinema_desk_posted (with carded) / cinema_desk_scored.pop_desk.py, the pop desk (#pop-desk): the celebrity/pop-culture sibling of the cinema desk. A ScheduledPoster subclass that each slot reads what the pop-culture WIRE accounts (utils.pop_stories.POP_WIRE_ACCOUNTS — CODE-OWNED, the same trusted-account model music_news._TRUSTED uses, NOT a per-guild /menu picker, so a server needs zero config and the list grows by evidence in one place; seeded PopCrave/PopBase/TheePopCore + more) are posting via bot.xprovider (twitterapi.io, the same source cogs.curate fetches), turns each post into a PopStory via the pure utils.pop_stories (rank_pop_stories: build → drop replies/too-short → dedup by a leading-token story_signature → rank by engagement), then composes her own grounded take (on the per-guild discourse model knob, borrowed via claude_client.discourse_model_id — a culture take, not a numbers drop) on the biggest fresh moment. Delivery splits by surface (owner steer “for discord link the tweet if you have it; on twitter still find the best photo possible no quote”): on Discord she posts her take + the source tweet’s permalink to_fixup_link-rewritten to fixupx.com so Discord unfurls the wire’s OWN real media under the take (the earlier find-your-own-photo attach on Discord produced weird/AI-looking / mismatched images); on the X crosspost she still ships her best FOUND photo standalone — the shared utils.subject_image.find_subject_image sources a real photo of the moment from Grok + Perplexity (Perplexity return_images + Grok search → the cited X posts’ photos via x_fetch, interleaved, then VISION-RANKED by claude.pick_subject_image for the clearest / most on-subject photo), used as the fallback after the wire’s OWN source photo — which is now preferred but VISION-GATED (utils.subject_image.resolve_subject_photo, #1682): the source photo ships only when pick_subject_image confirms it’s a clean, on-subject photo (an authoritative source can’t collide on a same-title search the way a found photo can), so a fetchable-but-JUNK source image (a “BREAKING” text card, a logo, the wrong picture) falls through to the found photo instead of shipping just because its url resolved — the same on-subject bar the found path already clears (and a gate-rejected source is dropped to text, never resurrected by the delivery refetch). This whole source-vs-found stage runs only AFTER the music-release catalog cover misses. The resolved photo is also fed to compose_pop_take as vision so the take reacts to it, and is the DISCORD fallback when a moment has no source tweet link. The pop_desk_posted source field records the Discord delivery (tweet_link / catalog / found / source_photo / none), and source_photos (#source-media-reuse, on all three wire desks) counts the fetchable photos the WIRE attached — so source_photos > 0 with a source that is neither tweet_link nor source_photo is the countable form of “the wire had its own picture of the moment and we shipped something else”, the silent failure that put a stock red-carpet portrait on a post about a clip. Image sourcing is the UNIFIED deterministic-first resolver (utils.entity_image.resolve_desk_image): the headline is classified by the ONE image_subject Haiku classifier and routed to its authoritative source — a music-release moment → the ALBUM COVER by ARTIST (resolve_catalog_art, the Pharrell “In My Mind” vs Dynoro same-title homonym fix), a musician → the Deezer artist photo, a person → the TMDB headshot, a team → the API-Sports logo, a film/tv → the TMDB/OMDb poster — falling through to find_subject_image (the source-photo gate → open-web vision) only on a miss, on the CLEAN entity name. This replaced the pop-desk-local _music_release_art/music_release_subject path. The vision picker (claude.pick_subject_image) also carries a BACKSTOP rule rejecting a same-title work whose own text/caption credits a different artist than the moment names (the general non-music homonym case). Fully fail-open. Grounded to break stories: the chosen moment is confirmed via a Perplexity pull (fail-open to the wire headline) so a “breaking” take states real facts — framed by the SHARED utils.wire_lanes.confirmed_brief_block (#wrong-person, all three wire desks): the confirm is a web search on the wire post’s WORDS, and a vague UNNAMED headline can match a DIFFERENT story that shares them — the shipped failure was @DailyLoud’s “Brazilian model is going viral” (a photoshoot BTS clip of one model, Gabi Moura) “confirmed” into the months-old Larissa Nery voter-roll story, so the take named Larissa Nery over the other woman’s video. The framing makes the brief CANDIDATE context the compose checks against the wire post + its photo/clip (a text search can never identify who’s IN an unnamed clip); a mismatched brief is dropped — compose from the wire alone, or EMPTY when that leaves no real news. Measured (scripts/dryrun_wire_brief_mismatch.py, the REAL re-run Perplexity brief, n=5, DRYRUN_MODEL knob): on the posting guild’s actual model (gpt) the old framing leaked the substituted story 5/5 and the fixed framing 0/5; sonnet 2/5→0/5; both same-moment-brief controls still land 5/5; tests/test_wire_lanes.py pins all three desks to the one block. And the UNNAMED moment now resolves its identity from the THREAD (utils.wire_identity, #wrong-person follow-up, owner steer “find her name from the original post”): before the confirm, resolve_thread_identity runs — a Haiku gate (wire_subject_named, unnamed-subject only, so a named moment never spends the fetch) → TWO pooled fetch_tweet_replies pages (the newest slice + the post’s first EARLY_WINDOW_SECS=10 minutes via sinceTime/untilTime; a big thread’s default page is the newest slice and the “who is this” answers land in the first minutes) → a credibility-gated Haiku extract (identify_from_replies: detail or cross-reply agreement, never a lone bare name/joke/lookalike; answer-first NAME:/NONE, parsed strictly) → a Perplexity BIO verify on the name (fail-closed: a crowd ID alone never ships a name). A resolved identity REPLACES the vague-words confirm and lands via thread_identity_block — name + the wire’s own views count (threaded from SourcePost.view_count through PopStory.meta) + a no-body-comment line — with the compose holding the final coheres-with-the-media judgment. Measured live end-to-end 3/3 (scripts/dryrun_thread_identity.py, the real DailyLoud thread): “Gabriela Moura is going viral after a backstage clip of the Brazilian creator and model pulled 2.6M views” (self-gate 0.88) on the guild’s gpt model; SONNET stays editorially conservative on the appearance-driven class (~1/5 even identified) — a model-choice fact. Emits wire_identity; pure parts unit-tested (tests/test_wire_identity.py). Pop desk only for now (sports/cinema wires name their subjects). The compose is the dedicated claude_client.compose_pop_take (modeled on react_to_trending, NOT the betting-framed compose_market_drop — a culture reaction in her voice, optionally SEEING the photo via vision on a fetchable host). In-lane by the MODEL, not a keyword filter (owner steer “election is pop, mamdani is okay”): there is deliberately no political pre-filter — every moment reaches compose_pop_take, which takes the light pop/viral read on a political-crossover moment and returns EMPTY only on charged partisan-advocacy / policy-geopolitics / tragedy substance; the cog tries the top _COMPOSE_CANDIDATES best-first so a declined #1 advances to the next moment instead of blanking the slot. Self-gated by claude_client.pop_desk_score (0.6 floor, the culture sibling of cinema_desk_score); durable per-moment dedup (pop_desk_events, so a moment posts ONCE even across aggregators/redeploys). Two lanes per slot (utils.wire_lanes.order_by_velocity): a slot posts up to 2 moments — the biggest by ENGAGEMENT (lane=biggest) + the fastest-rising by engagement MOMENTUM (lane=breaking, re-sorted on the source post’s created_at), deduped so the two lanes never ship the same moment (the _sched_compose_units override, market_drop’s one-drop-per-category pattern). The breaking lane gets LANE-SPECIFIC treatment (#sports-desk): a Grok-leaned live-chatter beat (context_beat framing="breaking") + a fresher Perplexity confirm + a fresh-break URGENCY compose framing (compose_pop_take(lane=...)), so a just-broke moment is grounded on the freshest read and composed with immediacy; the biggest lane keeps the standard read. Delivery: her take + the source tweet link (fixup-rewritten, unfurls the wire’s media) to the guild’s pop channels (its OWN /menu sub-page picker + accounts editor, db.get_pop_channels); staging auditions in #bot-logs, production posts + crossposts her best found photo standalone (no quote) to @tootsiesbar (crosspost_with_quote(surface="pop_desk", image=photo)). Gated on the pop_desk experiment (default STAGING) + master switch + mood + the X source provisioned + a configured handle; cadence on the /menu calendar (a first-class schedule_calendar surface). Emits pop_desk_posted / pop_desk_scored. Pure rank/dedup in utils/pop_stories.py (unit-tested tests/test_pop_stories.py); live-sourcing dry-run-verified against the real PopCrave/PopBase feed.sports_desk.py, the sports desk (#sports-desk): the sports-NEWS sibling of the pop desk, and the answer to “why didn’t the LeBron news get announced” — nothing watched a sports wire and posted a take. The three existing sports surfaces are live-game or odds only: the commentator calls a game in-game, the betting board/alerts read the odds, and none announce a news moment (a trade, signing, transfer, injury, retirement, record, viral play). This ScheduledPoster subclass each slot reads what the CODE-OWNED sports WIRE accounts (utils.sports_stories.SPORTS_WIRE_ACCOUNTS — the same trusted-account model pop_stories/music_news._TRUSTED use, NOT a per-guild picker; the breaking-news insiders + fast aggregators + official leagues, ordered fastest-wire-first: Shams/Woj/Schefter/Fabrizio Romano/Ornstein + SportsCenter/BleacherReport/ESPN/DunkCentral + the NBA/NFL/Premier League/Champions League/UEFA/La Liga/Serie A/Bundesliga/Ligue 1) are posting via bot.xprovider (twitterapi.io — @tootsiesbar does NOT need to follow them, any public handle is fetchable by fetch_recent), turns each into a SportsStory via the pure utils.sports_stories (rank_sports_stories: build → drop replies/too-short → dedup by leading-token story_signature → rank by engagement), then composes her own grounded take (on the per-guild discourse model knob via discourse_model_id — a take, not a numbers drop) on the biggest fresh moment. It’s an exact clone of the pop-desk pattern, incl. the delivery split (owner steer): on Discord she posts her take + the source tweet LINK (to_fixup_link-rewritten so Discord unfurls the wire’s OWN real media); on the X crosspost she still ships her best FOUND photo standalone, no quote (utils.subject_image.find_subject_image, Grok + Perplexity, vision-ranked — also fed to compose as vision + the Discord fallback when a moment has no link; source field records the Discord delivery tweet_link/found/source_photo/none). It grounds the moment via a Perplexity confirm (fail-open to the wire headline), composes via the dedicated claude_client.compose_sports_take (a NEWS-DESK headline like compose_pop_take but sports-lane — and the betting angle stays OFF this desk, the board/alerts own odds; hard-fails a bet tout in sports_desk_score), and is in-lane by the MODEL (no keyword filter — every moment reaches the compose, which returns EMPTY on off-lane charged substance: an athlete’s private legal trouble, a tragedy, a partisan fight; the cog tries the top _COMPOSE_CANDIDATES best-first so a declined #1 advances). Self-gated by sports_desk_score (0.6 floor); durable per-moment dedup (sports_desk_events). Two lanes per slot (utils.wire_lanes.order_by_velocity): a slot posts up to 2 moments — the biggest by ENGAGEMENT (lane=biggest) + the fastest-rising by engagement MOMENTUM (lane=breaking), deduped so the two never ship the same moment (the _sched_compose_units override). The breaking lane gets LANE-SPECIFIC treatment: a Grok-leaned live-chatter beat (context_beat framing="breaking") + a fresher Perplexity confirm + a fresh-break URGENCY compose framing (compose_sports_take(lane=...)); the biggest lane keeps the standard read. Delivery: her take + the source tweet link (fixup-rewritten, unfurls the wire’s media) to the guild’s sports-news channels (its OWN /menu sub-page picker, db.get_sports_desk_channels — DISTINCT from the live-commentary sports_channels so a guild routes breaking NEWS separately from live play-by-play); staging auditions in #bot-logs, production posts + crossposts her best found photo standalone (no quote) to @tootsiesbar (crosspost_with_quote(surface="sports_desk", image=photo)). Gated on the sports_desk experiment (default STAGING) + master switch + mood + the X source provisioned + a configured channel; cadence on the /menu calendar (a first-class schedule_calendar surface). Emits sports_desk_posted / sports_desk_scored. Pure rank/dedup in utils/sports_stories.py (unit-tested tests/test_sports_stories.py).commentator.py, the live sports commentator loop: Toots calls live games (World Cup soccer + NBA) in a dedicated sports channel (configured on /menu page 1, beside music channels). Pure orchestration over the already-tested sportsdata backbone — bot.sports_hub.live_games() (all providers MERGED, deduped by matchup, #435 breadth: API-Sports covers football+basketball, SGO covers everything else it carries - NFL/MLB/NHL/UFC/tennis - so the loop calls every major sport SGO scores, not just soccer+NBA; the first provider per matchup wins so a game both carry keeps API-Sports’ richer-depth row; PLUS a LAST-resort TheOddsApiScoreProvider (#725, utils/sportsdata/providers.py) that keeps SGO-only-sport scores (NFL/MLB/NHL/UFC) alive off The Odds API /scores when SGO craters — registered LAST so it only fills games the primaries missed, gated on sgo.degraded (the SGO circuit-breaker state) so it spends a metered credit ONLY while SGO is down, deliberately NOT the API-Sports-covered World Cup/NBA (redundant + wasteful), 5-min cached, off-season sports return empty/unbilled) for “what’s live”, utils.sportsdata.cadence.decide_post for the per-game “post now? on what trigger?” decision (goals, quarter/period boundaries, halftimes, finals, a ~10-min heartbeat tighter in clutch time — the heartbeat + clutch intervals are mod-tunable per guild via /menu’s tune page, resolved by tunables.commentary_cadence into the per-guild CadenceConfig), and claude_client.commentate (SONNET on every phase — quality over cost, a cheap-model-on-heartbeat routing was tried and pulled; the LIVE DATA is always injected, never from a tool — see the narrow slow-trigger background tool below) for the voice, with a per-phase format: a pregame rundown / goal reaction / a calm interval “where it stands” read (NOT a goal-style reaction) / a final recap beat, length scaled to the moment. Box-score depth (#432): NBA has no per-play events, so bot.sports_hub.game_leaders(game) pulls the live box-score leaders (API-Sports /games/statistics/players, top scorers parsed to PlayerLine by _basketball_leaders, rendered by format_leaders into a WHO’S COOKING block) and feeds them to commentate (stats_blob) so she calls a standout out by name (“brunson’s got 24, wemby near a double-double”) instead of just the scoreline; grounded (names/numbers given only, never invented) and fail-open (a stats miss → scoreline as before). Soccer gets the same WHO’S COOKING depth (game_leaders is now per-sport, not basketball-only): _football_leaders parses API-Sports /fixtures/players (goals/assists/shots/key-passes/rating, scored by goal-involvement-then-rating to the top few) so she calls a soccer standout by their real line (“wirtz with 2 goals and an 8.4 rating”), beyond just the goal-event scorer name. The commentate prompt leads with WHO’S COOKING (standout player + numbers) AHEAD of any market/odds talk (players + action are the story, the line is secondary color), and per-trigger length is a ceiling not a target (one sharp line is fine; never pad to reach 2-3 sentences). Odds-aware (#476): a matched betting line — sportsbook moneyline (utils.the_odds_api) + Kalshi/Polymarket implied % (bot.markets), matched to the live game by team name (utils.sportsdata.format.match_event_odds/match_prediction_snapshot) and rendered by format_commentary_odds — is injected on every trigger EXCEPT the goal (#1140: a score change lags the market, so the odds/market block is withheld from the goal compose ENTIRELY via _ODDS_SUPPRESSED_TRIGGERS — nothing for her to invent/misread from, and the #507 no-line forbid rule then fires; _odds_blob is still computed so odds_health telemetry is unaffected, only the text handed to the model + self-gate is withheld. The prompt already said the line “never appears on a goal”, but prod showed her inventing a Polymarket flip / handicap line off the still-injected block ~half the time and the self-gate dropping the whole post — ~56% of goal reactions shipped nothing; withholding the block structurally enforces what the rule only asked, validated n=5 on the real card+goal feed), but it is secondary to the game, not the lead (#603 follow-up: the prior “speak on the line at the first opportunity” framing made commentary market-LED, reading as a call on the odds instead of the game — and the market lags a goal, so a goal post citing a not-yet-repriced % read as wrong). The reorder is priority, not suppression (owner steer: “game/player/team focused, but equip our bettors with fresh market info always”): the prompt leads HARD with the GAME on EVERY phase (a top “THE GAME IS THE STORY” rule naming pregame/in-game/final + the rule order puts players/shape/props/standings AHEAD of the line), then hands the bettors the fresh line as a secondary beat so they stay on current numbers — it FOLLOWS the game, NEVER leads, and NEVER appears on a goal (stale-relative-to-the-event). A pure-game post with no line is fine when the game’s the whole story. Still grounded: she quotes the numbers given and never invents/shades a line. Fail-open per source (a miss omits that source; the Bookie surface, #435, goes deeper into line-shopping + user bets + leaderboards). SGO-sourced games (NFL/MLB/NHL/UFC/tennis, #435) get their OWN moneyline surfaced via format_sgo_odds_line (the snapshot already carries it) in the $100 wins $X convention, home/away-paired to avoid the prior misread footgun, used as the sportsbook line when The Odds API has no match - so combat/etc. get “who’s favored” even with no other odds source. Beat-the-line player props (#390): SGO player props (bot.markets.sgo.get_player_props(league), INTERNATIONAL_SOCCER/NBA) are pulled scoped to THIS game’s SGO event (get_player_props(league, event_id=...), #SGO-eff — the caller already knows the game, so it bills one event’s props via the eventID filter instead of the whole league’s limit-event slate, a ~10x entity cut live-verified against SGO; the /ask lookup_player_props path is event-scoped the same way) and matched to the players actually in this game by name (format.match_player_props, a fuzzy person-name match against the box-score leaders + goal scorers — surname-first Brunson Jalen ties to a prop’s Jalen Brunson, a different game’s player is dropped, and a bare initial never false-matches) then rendered by format_player_props into a props_blob fed to commentate — the beat is a player’s ACTUAL box-score number vs the book’s o/u (“brunson’s at 34, smoked his 26.5”), grounded (quote the line exactly, never invent/shade one) and fail-open down the whole chain (no SGO key / unmapped sport / no in-game players / fetch miss / no match → no props, scoreline as before). The SGO prop snapshot carries structured player/market_type on meta so the matcher keys off clean fields, not the title. NOTE: SGO’s playerProps shape is best-effort/version-varying and not yet live-verified end to end (amateur-tier quota + uncertain payload), so watch the commentary_posted has_props rate to confirm props actually land in prod. Market-edge beat (#622 slice 3): the deeper-market siblings of the moneyline — a total / both-teams-to-score / 1st-or-2nd-half total / corners line, the “interesting” market the moneyline doesn’t say — are surfaced via the full SGO board (bot.markets.sgo.get_game_board(league), cached per league per tick like props) matched to this game by team name (_board_for_game, accent-folded _team_match, either orientation), then the single standout is picked by the pure utils.sportsdata.board.pick_standout_market (the book’s most confident READABLE call that isn’t a foregone conclusion — highest top-selection implied % at or under a 0.90 near-lock ceiling; moneyline/spread/3-way + player props excluded since other beats cover them) and rendered as a compact one-liner (“over 0.5 1st-half goals at 75%”) into a market_edge_blob fed to commentate. SLOW triggers only (pregame/interval — never a goal/clutch reaction, where the market lags the event). It is an ALTERNATIVE source for her ONE market beat, not a second one (#603 game-leads discipline preserved): the prompt tells her to use the deeper line instead of the moneyline when it’s the sharper tag, still one clause, still behind the game, still skipped when the game’s the whole story; the no-line grounding guard (#507) now also clears when a market edge is present. Grounded (quote the % exactly, never invent) and fail-open down the chain (no SGO / unmapped sport / no board match / no standout → no edge). When SGO is degraded (#725) the edge falls back to the Odds API deep board (_odds_api_market_edge): a FREE get_events resolve + ONE metered get_event_board (ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) → parse_odds_api_board → the same format_market_edges, gated on sgo.degraded (zero spend when SGO is healthy) AND the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE, so the /bet SGO-down backstop credits are preserved) AND an 8-min per-game cache, so it’s a slow-trigger-only, ~5-credit-per-game-per-window enhancement that never threatens the bookie budget. Dry-run confirmed she works it in as secondary color (“Spain’s been the better side all night… the ‘no both teams to score’ at 61% tracks, Germany’s barely gotten looks”) and ignores a stale/irrelevant edge rather than forcing it. Watch commentary_posted has_market_edge. Bookie tie-in (#623): on the bookend beats she works the room’s play-money bets in via _bets_blob → the Bookie cog (get_cog("Bookie")). PREGAME folds in who’s got open action on this game (Bookie.open_bets_blob → db.open_bookie_bets_for_game, names not pings, biggest stake first, capped at _BETS_READOUT_MAX); FINAL settles the game (Bookie.settle_and_roast_blob → the shared idempotent _settle_game, which ALSO fires the per-channel payout embed, so whoever reaches a finished game first — this or the 5-min settle loop — settles it and the other no-ops) and hands back the W/L for a light roast (format_bets_results). The roast prompt rule is don’t force the joke (only when there’s a real angle, else read it straight), text-only, names never invented. Bookie-experiment-gated + fail-open to “” (a bookie blip never breaks commentary); settlement itself still runs via the loop regardless. Pure formatters (format_bets_action/format_bets_results) are unit-tested. Watch commentary_posted has_bets. (A scheduled follow-up surfaces the same open_bookie_bets_for_game data as a user-facing “everyone’s bets on a game” command, open + closed.) Shape-of-the-game depth (#432): soccer had only score+goals+market% to call on (which is why prod check-ins read as one-liners), so bot.sports_hub.game_team_stats(game) pulls live team match stats (API-Sports /fixtures/statistics: possession / total shots / shots on target / corners / cards, parsed to a MatchStats by _football_team_stats, rendered by format_match_stats into a MATCH STATS block) and feeds them to commentate (team_stats_blob) so she calls the shape (“germany’s on 64% and 14 shots to 3, one-way traffic”) not just the score; grounded (home-away numbers given only, never invented), fail-open (a stats miss → scoreline as before), cached per game per tick. NBA gets the same shape depth (game_team_stats is now per-sport): _basketball_team_stats parses API-Sports /games/statistics/teams (FG% / 3PT% / rebounds / assists / turnovers, mapped to home/away by team id since the stats block carries team.id only, names from the snapshot) so an NBA check-in reads “knicks shooting 39% and getting crushed on the glass 47-38” not just the score; format_match_stats renders both sports’ labels from one _STAT_ORDER. Watch the commentary_posted has_team_stats rate to confirm the depth lands. Pregame depth (#432): on the pregame trigger only (where it belongs, so live reactions never pay for it), bot.sports_hub.game_pregame(game) pulls API-Sports formations + coaches (/fixtures/lineups), each side’s recent form (/fixtures?team=&last=, rendered “WWDLW” newest-first by _form_string), and the head-to-head record (/fixtures/headtohead), assembled into a PregameContext and rendered by format_pregame into a PREGAME CONTEXT block fed to commentate (pregame_blob) so the rundown sets the table with a real hook (a hot/cold run, a formation mismatch, the history); grounded (quote, never invent), fail-open per source, cached per game per tick, team-ids carried on the snapshot meta (home_id/away_id) so no name→id resolve is needed. Watch commentary_posted has_pregame. (Highlightly’s own data endpoints — lineups/H2H/standings/predictions — are a deferred follow-up pending live response-shape verification; the commentator’s pregame depth uses API-Sports, whose shapes are verified.) The commentate prompt was also reshaped for depth-not-length: one sharp sentence is the norm with a HARD two-sentence ceiling (never three), a “DEPTH OVER LENGTH” guard (every sentence carries a real stat/name/run from the feed), a “ONE ANGLE, not a stat dump” rule (the data is there so she PICKS the sharpest beat, never lists ‘three yellows AND outshot AND 55% AND a 7.3 rating’), and a “no wrap-up line” rule (no ‘that’s the story in a sentence’ meta) - the deepen-not-LENGTHEN tightening after the depth pass over-corrected into long stat-sheet posts in prod. The depth still lands, just as DIFFERENT angles across SEPARATE posts (rotated via the ‘story so far’ context - a player in one take, the shape in another, the line in another), never crammed into one and never all at once (the #502 stagger spaces them). Post-game highlights (#432 dim 3): the commentator drops a video clip for a finished game via utils.highlightly (the HighlightlyClient, key-gated on HIGHLIGHTLY_API_KEY — Highlightly covers BOTH soccer + NBA, the one source for our whole coverage, free Basic tier 100 req/day). Because clips land 0-48h after the whistle (the final recap fires the instant a game ends, too soon for a clip) AND a finished game leaves recent_finals() at UTC midnight, the cog persists a pending-clip window: when a game we called finishes, db.mark_game_for_highlight stamps its matchup + highlight_finished_ts onto sports_post_state, and a separate per-tick pass (_process_pending_highlights, run BEFORE the live-games early-return so a next-day clip still posts when nothing’s live) re-queries Highlightly for any game db.due_highlight_games returns — finished within _HIGHLIGHT_WINDOW_SECS (~48h), not yet posted, last checked over _HIGHLIGHT_RECHECK_SECS ago (60min, free-tier-budget-safe; the window/pace/dedup are all enforced in SQL). It posts the first VERIFIED clip (the API’s own verified title + url, which Discord unfurls — real API data, nothing fabricated) and marks the game done. Once per game, durable dedup + pacing + the 48h window via sports_post_state columns (highlight_posted_ts/highlight_checked_ts/highlight_finished_ts + the persisted matchup, durable so frequent redeploys don’t re-post or re-burn budget and a clip landing the day AFTER the game still gets caught). Gated by the same live_scores experiment (production → room, staging → #bot-logs audition) + fail-open throughout (no key / no clip yet / send failure → leaves it pending for the next window). Emits highlight_fetch (the API call) + highlight_posted (the disposition). Phase 2 (deferred, tracked on #432): an X-accounts cog pulling clips from curated accounts (@BleacherReport, @NBACentral) — blocked on a timeline source (fxtwitter does single tweets, not timelines). Odds grounding (#507): when NO odds block is matched/injected, the prompt forbids her mentioning a moneyline/spread/implied-%/Polymarket/Kalshi at all (the inverse of the odds rule) plus an always-on no-bet-sizing rule — prod soccer logs showed her inventing market %s from nothing when the match missed (the self-gate caught most, but it’s a real grounding hole). Right-market-info-at-the-right-time (the capability matrix applied to commentary): The Odds API h2h moneyline is pre-match only (utils.sportsdata.sources.provides_live_odds("the_odds_api") is False — it does NOT reprice in play), so on any in-play trigger (everything but pregame) format_commentary_odds(live_game=...) frames its line as the OPENING line (“where they opened”, not the current price) and points the model to the prediction markets for the LIVE read — otherwise she’d cite a stale pre-match number as live (the Bookie stale-odds bug, in the commentary voice; acute while SGO’s monthly entity cap is exhausted, since the Odds API is then the only sportsbook line). The SGO line (when present) IS live-capable, so it’s never relabeled; the prediction markets are always the live read. live_game flows from the trigger through _odds_blob. Cross-provider matching (#508): so the odds/props blocks actually LAND, format._team_match/_name_match accent-fold via NFKD (utils.sportsdata.format._fold — the Türkiye→trkiye prod miss was the matcher deleting diacritics) and match on accent-folded exact-or-containment ONLY (the fragile English-club suffix list + bare last-token fallback were dropped: containment covers the real cases and biasing toward a miss over a wrong match is safe now that a miss = no line, not an invented one). Game-state awareness (#508): on the SLOW triggers only (pregame/interval, via _context_tools), commentate is handed ONE narrow read-only tool — lookup_background (utils.reference.lookup_background, the Wikipedia-article-reader slice only, deliberately NOT the full lookup_reference so it’s not bundled with Genius/MusicBrainz/chart sources that are noise for calling a game) — for background/stakes/history. Never on goal/clutch (a reaction can’t wait on a round-trip), never for the live score/odds (those are injected, real-time, authoritative — a search there would only risk stale data); live-data tools stay off the commentator. Fail-open (a lookup miss → call it without). Each composed line is self-gated (commentate_score, a Haiku classifier like discourse_score) and dropped under the 0.6 ship floor (fail-open on a scorer error so a Haiku blip can’t silence the surface). The self-gate is handed the live FEED the line was built from (score + events + team/player stats + the line) so it verifies the line’s numbers are real (in the feed) rather than false-failing injected depth - player match ratings, possession %, key passes - as ‘invented’ (#432: judging blind was dropping ~half the good posts in prod) — parity with discourse/music’s quality gate. The commentate prompt inherits the shared _VOICE_REMINDER block AND the cross-surface _LESSON_* grounding constants (substance / not-curator / one-verdict / plain-not-critic — the single source of truth also composed by _POST_GROUNDING, so a prompt-rule edit propagates to every room surface instead of drifting; tests/test_prompt_lessons.py fails if any surface stops referencing them) so commentary can’t drift from the other room surfaces; _ROOM_DIRECTED/_TOOL_DISCIPLINE are deliberately not applied (commentary calls a game, no debate-starting; the only tool is the narrow slow-trigger lookup_background, no live-data/web tools). One @tasks.loop(60s) tick does one global live_games() fan-out PLUS an upcoming_games(~15min window) fan-out PLUS a recent_finals() fan-out (#512 post-game recaps: a completed game is filtered out of live_games() (g.live only), so the final trigger was structurally unreachable — the #441 bug but for final, and zero finals ever posted in prod; recent_finals surfaces just-finished games (reusing the same day-slate fetches, filtered to completed; SGO has no finished feed so it’s API-Sports-sourced) so the once-per-game final recap fires. Gated in the cog so only a game we were ACTUALLY calling recaps: a completed game with no prior sports_post_state (last_post_ts is None) is skipped, never a cold final for a game we ignored. At final the recap material is already in hand — final score + the full event timeline + final box-score leaders + the story-so-far — so no new persistence is needed, #430 stays deferred) (#441 pre-game detection: the loop used to see only live games, so the pregame rundown trigger was unreachable; now a fixture tipping/kicking off within _PREGAME_WINDOW_SECS is merged in, deduped by game_key, so it gets its rundown before the game — the window reads start_ts added to each snapshot’s meta, padded by a few-minute _PREGAME_GRACE_SECS clock-skew tolerance on both ends so a schedule slip doesn’t drop a game) then loops over guilds, so an idle bot (no live/upcoming games / no configured guild) spends nothing — it returns before the loop when _resolve_targets is empty. (Kickoff at 0-0 doesn’t mis-fire the goal trigger: cadence._score_changed normalizes None→0, so an upcoming game’s null scores becoming 0-0 reads as no change.) The dedup memory (cadence.PostState) is persisted per (guild, game_key) in sports_post_state (restart-proof: last_post_ts is wall-clock epoch, not monotonic, so a redeploy mid-match never double-posts a goal or loses the heartbeat clock). Cross-game spacing (#502): when several concurrent games clear a trigger on the same tick they’d post back-to-back in one channel (a wall); so a guild posts at most _MAX_ROUTINE_POSTS_PER_TICK (1) routine reads per tick and the rest DEFER (their PostState is left untouched, so the cadence re-fires them next tick — no wall, nothing lost). MILESTONE triggers (goal/final, a reaction loses value if held) are never deferred but DO count toward the tick budget, so routine reads space themselves around a goal. Emits commentary_deferred. Per-post game header (#502): a sent message is prefixed with a format_game_header line (matchup + live score + clock, e.g. **Spain 2-1 Korea** · 67') so multi-game commentary is self-labeling, but ONLY when the game or score changed since the guild’s last post (deduped via in-memory _last_header), so a single game reads as a clean stream and the header re-appears on a new game or a goal instead of stacking an identical bold line on every consecutive same-game post. Delivery-only: post_preview stays the take so the eval pass grades her words, not the scoreboard. This (identification) is the complement to staggering (volume). Gating, in order: master kill switch (is_bot_enabled) → mood OFF (it’s a proactive surface, a muted room stays muted) → the per-guild live_scores experiment (production posts to the sports channel, staging auditions the line in #bot-logs with the room kept quiet, off skips the guild — no fetch, no spend). Per-guild watched-sports filter: _resolve_targets resolves each guild’s db.get_watched_sports (the /menu page-3 picker over utils.sportsdata.sports.WATCHABLE_SPORTS, empty/unset = ALL) onto the target tuple, and the game loop skips a game whose meta['sport'] the guild isn’t watching (is_watched, fail-open: an untagged game is always covered). This is the lever that lets a World-Cup-only room mute the MLB/NFL breadth that the all-providers merge surfaces — the fix for a sports channel flooded with games nobody asked for. (Every SGO game now carries a meta['sport'] tag, _SGO_SPORT_LABEL extended to all renderable sports — SGO FOOTBALL→americanfootball, never soccer’s football.) Fail-open throughout (provider miss / compose 429 / send failure never crashes the loop; the state is still recorded so a trigger doesn’t re-fire). Emits commentary_posted (with post_preview on room deliveries, graded by the live-log eval pass) + commentary_scored (the self-gate). The behavioral net is scripts/eval_commentate.py (golden + per-trigger + live-log), parity with the other room surfaces.betting_board.py, the “who’s favored” line posts for the sports a guild takes BETS on (#1229/#1234) — the market-read complement to the two existing sports surfaces: the live commentator CALLS a game in-game, the Bookie TAKES the bets, and this reads the LINE around a game (who’s favored + the implied %) so a bettor sees it and is nudged to /bet. DUAL-PATH (owner steer — the ability to “post today’s bets in the morning” run by the calendar, AND a pre-kickoff nudge): (1) a CALENDAR-scheduled morning “today’s bets” SLATE roundup — a ScheduledPoster subclass (mirroring market_drop) posts a compact board of the day’s bettable games + who’s favored, calendar-managed via /menu (bet_board is now a first-class calendar surface in utils.schedule_calendar.SURFACES + cogs.calendar_view), falling back to a morning-leaning CHILL_TIMES/YAPS_TIMES pool when unconfigured; _todays_slate (live-first then today’s-soonest, capped _SLATE_MAX) + _slate_blob feed compose_market_drop; self-gated by drop_score, text-deduped over bet_board_history, slot-paced via bet_board_slots. (2) a per-game PREGAME line, EVENT-DRIVEN off the game’s START time — a @tasks.loop(TICK_MINUTES) (board_tick) posts a game’s line once when it enters the pregame window (_PREGAME_WINDOW_SECS, ~1h, env-overridable), capped at _MAX_POSTS_PER_TICK per guild per tick, per-game deduped via bet_board_matchups (a game’s line posts once, not re-posted while it sits in the window / across a redeploy). Deliberately DISTINCT from market_drop: the drop reads cross-market FUTURES and CUTS single games (Bookie/commentator turf); this is the exact opposite — single-GAME lines ONLY, main line only, no props/exact-score/corners (owner steer). Reuse, not duplication (owner steer: reuse the Bookie odds code): both paths ride the Bookie’s own slate (Bookie._bettable_games → _bettable_for_guild, shared via _slate_games) and line math (_sides/_matchup/_game_when, implied % = round(100/decimal), all from cogs.bookie), and compose_market_drop’s voice (angle="leaderboard", a game-line context_blob). Filtered by the guild’s BETTABLE sports (independent of watched-sports); posted to the betting-drops channel (db.resolve_betting_channels — the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, ELSE the live-commentary sports channel, so commentary and betting drops route independently and an unconfigured guild is unchanged; #betting-links-channel-routing shares this resolver across board + both alerts); gated on its own bet_board experiment (production → the room, staging → a #bot-logs audition, off → skip) + master switch + mood. The bet_board experiment is split out of bookie (which gates bet-taking/settlement/alerts): the two used to be welded together, so silencing the board meant staging all of bookie (which also reroutes bet-taking + the settlement payout pings). Now a guild can take bets WITHOUT the board drops (bet_board off/staging, bookie production), or run the board without opening betting. The morning slate stays calendar-scheduled on top of this gate (the calendar owns cadence, the experiment owns on/off — mirrors market_drop); the pregame line is event-timed so the experiment is its sole on/off. Rich market cards (owner steer: prioritize linking to markets over the bare /bet nudge): each post renders a self-built embed CARD (utils.market_cards.build_market_card — the same card the market drop uses: a clickable Kalshi/Polymarket market link as the embed title + a %s chart image) with the take (which keeps the /bet nudge) as the description, so the MARKET is the headline and /bet rides below it. The pregame post cards the game; the slate roundup cards the marquee game (_marquee_game, the strongest favorite the take leads with). It fetches the game’s prediction-market snapshot via the shared fetch_prediction_snapshots (Kalshi preferred — #prefer-kalshi, US-web-bettable — Polymarket the fallback, and kept for a soccer game whose Kalshi line lacks the draw, usable_prediction_line guard); a game with NO matched market degrades to the prior plain-text post (fail-open). bet_board_posted carries carded + market_source. The pure helpers (_line_blob = the who’s-favored blob incl. the soccer draw leg labeled “Draw”; _todays_slate/_slate_blob; _pregame_games = upcoming-in-window, soonest-first, skipping a db.recent_bet_board_matchups fixture; _norm_matchup) are unit-tested; both self-gated by drop_score (0.6 floor, fail-CLOSED). Live-dry-run-verified rendering on real games (Rangers 68%, Angels 38%). Emits bet_board_posted / bet_board_scored / bet_board_dedup. (The calendar grid is 30-min per #1238.)betting_alert.py, the betting LINE-MOVE alert (#1234) — the event-driven sibling of the betting board (betting_board.py posts on a schedule/pregame-offset; this fires when a bettable game’s moneyline MOVES), the game-line analog of market_alert (the prediction-market movement alert). THREE triggers: a FLIP (the underdog overtakes the favorite — the favored side changed; the rarest, most legible “whoa”, always fires) and a SWING (same favorite, its implied % moved ≥ SWING_THRESHOLD_PP ~15pp from the baseline — pulling away or its edge collapsing toward a flip), both watching UPCOMING (pre-game) games ONLY (#game-lines-live-alerting, owner steer): a LIVE line moves with the play-by-play, so an in-play swing/flip is the game happening, not betting NEWS — it read as noise (a live 27pt Rays swing crossposted to X), so the loop skips game.live (and drops any pending confirmation) and only alerts on the pre-game line, where a move IS news (sharp money / injury / a scratch before first pitch); in-play edges stay owned by the value alert (betting_value.py, book vs live markets) + the commentator. A flip lands only when the new favorite leads by FLIP_MIN_MARGIN_PP (5pp, owner steer) so a pick’em jitter isn’t a “flip”. The third is a WINNER LOCK (#sports-locks-live-games, owner steer) — the ONE live-game exception to the pre-game-only rule: a pre-game line MOVE in-play is noise, but a side crossing into a near-lock (≥ LOCK_THRESHOLD_PCT, 97) DURING a live game is a genuine terminal event (the game is effectively decided), so it fires ONCE as a “winner lock”. It’s attempted only on the actually-live game (not a future same-matchup sibling), and only on a genuine CLIMB into the lock — not a foregone blowout (owner steer “I guess this fires every game”): _is_foregone_blowout reads the pre-game baseline (bet_line_alerts) and SKIPS a side that was already ≥ LOCK_CONTESTED_MAX_PCT (90) pre-game, so it fires only when a contested game got decided (the screenshot’s 56%→97%) or a pre-game underdog came back; fail-open (no baseline → the bot joined mid-game → allow). Gated further by the same PERSIST_PERIODS debounce (a momentary spike into the lock band shouldn’t fire) + a DURABLE fire-once guard (bet_lock_alerts, a row’s existence = already-fired), counts toward the shared bet-alert daily cap, and composes on a dedicated winner_lock angle (name who’s got it locked + the live win %, framed “all but sewn up” — NEVER “won”/a final score, since the game isn’t over). Pure _lock_blob unit-tested; _fire/_fire_winner_lock share the _ship compose→gate→card→deliver→record→emit path. Rare + high-bar: a persistence gate — a flip/swing must hold for PERSIST_PERIODS (2, owner steer “2-3 periods”) consecutive polling ticks before it counts (an in-memory _pending debounce, the noise guard bet_alert needs vs market_alert’s much slower cadence; not durable — a redeploy just re-confirms, never false-fires) — plus a per-game cooldown and a per-guild rolling daily cap (BOTH per-guild TUNABLES on the /menu tune page, tunables.bet_alert_daily_cap/bet_alert_cooldown_minutes, grouped as the “betting alerts” card — parity with market_alert_daily_cap + the chime-in cooldown; SEPARATE from market_alert’s cap so a busy market day can’t starve game alerts), and one alert per guild per tick. The baseline is DURABLE per (guild, game_key) (bet_line_alerts, one upsert row carrying ref_favorite/ref_pct + the cooldown/cap clock last_alert_at + the last shipped line — mirrors market_alert_state): the line as of the last alert (or first sighting, seeded WITHOUT alerting), so cumulative small moves accrue into one alert and a redeploy never loses it or double-fires. Reuse, not duplication: rides the SAME betting_board.bettable_slate + line math (_favorite/_line_blob/_matchup), Bookie’s provider-independent match_key for the baseline (utils.sportsdata.models, the SAME key bets/settlement use — NOT the provider-specific commentator game_key, which would fragment one game’s baseline across providers on the merged slate; order-independent + team-folded, so “consistent with bookie”), and compose_market_drop’s voice (angle="race_moved"). Single-GAME main line only; gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, else the sports channel (#betting-links-channel-routing) — staging → a #bot-logs audition, off → skip) + master switch + mood. Links the market (owner steer: link the market, drop the misleading play-money /bet nudge): like the board + value alert, a resolved game ships a rich market CARD (build_market_card via the shared betting_board.market_snap_for_game — Kalshi preferred, #prefer-kalshi; Polymarket the fallback) whose clickable title is the Kalshi/Polymarket market the reader can go bet on directly, with the move as the description; a game with no matched market degrades to plain text (fail-open). The compose steer no longer tells her to push /bet — the linked card is the CTA. The card charts the side the take is about (#show-the-fav-line, owner steer “show the Giants line”): a 2-way game’s Polymarket moneyline bundles as ONE leg tracking one designated team, so when the take is about the OTHER side the chart showed the loser collapsing to ~0% while the words narrated the winner at ~99% — so build_market_card(favored_label=...) (threaded from _favorite/the lock side) charts the FAVORED side of that leg (market_cards._favor_outcome inverts a loser-tracking leg + relabels), landing the line + legend on the side the take reads. Pure helpers (_favorite w/ margin, _classify_move flip-beats-swing, _move_blob) are unit-tested; self-gated by drop_score (0.6 floor, fail-CLOSED). Emits bet_alert_posted (now carrying carded + market_source) / bet_alert_scored. (An earlier PR also wires db.prune_bet_board_state into the prune loop — it shipped in #1235 defined-but-uncalled, so the bet-board-family tables were growing unbounded — and extends it to prune bet_line_alerts.)betting_value.py, the betting VALUE alert (#1234 follow-on) — the third betting surface beside the board (who’s favored) and the move alert (the line moved): this fires when the SPORTSBOOK line DISAGREES with the PREDICTION MARKETS on a live bettable game, a profit edge (“Kalshi has Spain at 74%, the book’s still at 60% — value on Spain”). The premise is the #968 odds-health insight, now SURFACED instead of just logged: there’s no oracle for the “true” line, but cross-source AGREEMENT is the signal — when the book and the live prediction markets diverge hard on the same game, the book is the outlier (slow to reprice an in-play swing), and the side the markets rate HIGHER than the book prices is the value side (you’re getting book odds implying less than the market’s probability). Cheap by construction: compares the book line ALREADY in the bettable slate (SGO/enriched, free — no metered fetch) against the two FREE prediction markets (Polymarket + Kalshi via the shared fetch_prediction_snapshots, the SAME entry point the Bookie reprice + commentator color use). LIVE games only (an in-play book lagging the live markets is the sharpest edge; pre-game value is a tracked follow-up). Trustworthy, not trigger-happy: fires only when the game carries its OWN book line (_has_book_line — not one already prediction-folded, else book==market), when the divergence clears VALUE_THRESHOLD_PP (15pp, the odds-health “sources disagree” bar), and — when BOTH markets match — when THEY agree with each other within _MARKET_CONSENSUS_TOL_PP (a lone mispriced market isn’t a consensus to chase); both lines must be _coherent (sum ~100±vig, so a garbled parse can’t manufacture an edge). Rare + high-bar: a per-game cooldown + a per-guild rolling daily cap (REUSES the shared “betting alerts” tunables bet_alert_daily_cap/bet_alert_cooldown_minutes, counted against its OWN bet_value_alerts table so value + move alerts pace independently) + one alert per guild per tick, self-gated by drop_score. Reuse: the odds_health extractors (game_sides_pct/prediction_sides_pct), match_prediction_snapshot, match_key, compose_market_drop (angle="race_moved"). Pure helpers (_coherent, _has_book_line, _value_edge biggest-gap-first, _value_blob) are unit-tested; live-dry-run-verified that efficient pre-game lines AGREE (book vs Polymarket within ~2pp on real World Cup games → no false edge). Gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel if set (/menu live-sports page), else the sports channel (#betting-links-channel-routing) — staging → #bot-logs, off → skip) + master switch + mood. Links the SPORTSBOOK where the value bet lives (owner steer: “if the odds are lagging on the odds api, link the sportsbook instead of poly/kalshi; if available use it, else just link the prediction market”): the value is at the LAGGING BOOK (it’s slow to reprice; the prediction market has already caught up to the true number, so clicking through to Poly/Kalshi lands you where there’s NO edge left). So the card’s clickable title links the sportsbook bet-slip for the value side when The Odds API gives a usable one — _sportsbook_link maps the game’s sport → Odds-API key, reads the SAME cached get_odds slate the Bookie uses (no new metered call in the common case), matches the game (match_event_odds) + the value side (sportsbook_link in utils.sportsdata.format, team/draw name-fold), and returns the best-priced book with a CLEAN link (the_odds_api._best_link_per_outcome over the includeLinks=true response — the per-outcome bet-slip link, else the bookmaker event link; _clean_link drops the {state}-templated US-regulated books (BetMGM/BetRivers) we can’t fill). The card links the book (title → the bet-slip, e.g. FanDuel addToBetslip/DraftKings), footer bet it on {book} · odds via {source}, and the CHART still shows the market %s (the evidence for the value). Fallback: no clean book link (or Odds API unprovisioned) → the card links the prediction market as before (build_market_card(link_url=None)). includeLinks is a paid-plan feature that adds NO credit cost (billing is markets×regions), so it rides the slate’s existing call for free. Live-dry-run-verified: MLB + World Cup games resolve real FanDuel/DraftKings/BetOnline/Bovada bet-slip links (a game whose books are all {state}/link-less correctly falls back to the market). Fail-open to plain text if the card can’t build. Emits bet_value_posted / bet_value_scored (posted carries carded + market_source + book = the linked sportsbook, None = fell back to the market link). Kalshi now actually contributes a prediction line (#1234 follow-up): a live-telemetry audit found Kalshi in 0 of 82 production odds_health snapshots (Polymarket 63/82) despite ~64k fetches (99% ok, NOT a cache — 0 cache hits) — the value alert’s cross-market consensus guard was effectively running on Polymarket alone. Root cause was CONSTRUCTION: Kalshi builds one MarketSnapshot per Yes/No contract with an EMPTY outcomes dict (the outcome label lives in meta['yes_label'] = yes_sub_title), so every Kalshi snap failed prediction_line_odds’s empty-outcomes guard, while Polymarket aggregates an event’s legs into a populated outcomes map at construction. Fixed by markets.fold_kalshi_winner_snapshots — applied ONCE at the PUBLIC MarketsManager.kalshi_snapshots entry point, so EVERY surface that reads Kalshi benefits uniformly (the ask kalshi/props tools, the value alert, the Bookie live reprice, and the commentator’s odds_health color) — which groups a Kalshi event’s sibling per-contract snapshots by event_ticker and, for an event with ≥2 DISTINCT yes-labeled legs (a genuine one-winner-per-participant market; a single Yes/No market’s NO side is ‘not-X’, NOT the opponent — Kalshi’s no_sub_title == yes_sub_title — so it’s never folded), emits ONE aggregated snapshot with outcomes = {yes_label: yes_prob} mirroring Polymarket’s shape, PREPENDED to the untouched per-market originals (so ask’s granular per-contract lines + player-prop extraction still work, and its game reads now ALSO get the clean aggregated line). Correctness rides the existing prediction_line_odds guards — a futures/multi-team market (World Cup host, 32-team winner) folds to many outcomes but bails there (>2 valid legs with no draw, or only one side mapping to the game), so it can’t manufacture a bogus game line. Kalshi soccer label normalization (#1234 follow-up): Kalshi’s per-game soccer markets prefix every leg with the period qualifier (Reg Time: France / Reg Time: Spain / Reg Time: Tie), and the raw prefix broke the DRAW detection (the leading token is reg, not tie) — so prediction_line_odds saw 3 legs with no mapped draw and BAILED, rejecting EVERY Kalshi soccer game line (verified on the live World Cup slate: 0/3 games yielded a line before, 3/3 after). _clean_kalshi_label strips the X: qualifier when the fold builds outcomes (Reg Time: Tie→Tie→draw, Reg Time: France→France); a label with no qualifier (MLB Toronto, a futures Greece or Turkey) is unchanged. This is the parsing half of making Kalshi contribute a game line ‘fair and square’ — SEARCHING already works (the live FTS kalshi_event_search index carries 65 game series incl. MLB/NFL/NBA-summer/soccer/UFC), and the source PREFERENCE order is now KALSHI-first (#prefer-kalshi; it was Polymarket-first when this was written); the remaining Kalshi empties are genuine coverage gaps (no NBA regular season, non-WC soccer leagues), not a bug. Cost-neutral (Kalshi was already fetched + discarded; the fix only parses data we already pull). The fix is observable in the SAME odds_health telemetry (watch kalshi appearing in home_pcts/sources); pure fold_kalshi_winner_snapshots is unit-tested.health.py, the self-healing health watch loop (#443): closes the loop from Toots’ OWN telemetry to her self-repair tools. The ops-monitor already flags a silently-degraded integration twice a day for a human; this cog gives her the same eyes in near-real-time and lets her ACT without waiting for a complaint (the day iTunes 403/429’d her ~1,600x and collapsed the /guess clip pools to a few songs, she watched her backbone crater and said nothing). One @tasks.loop(30min) tick reuses the ops-monitor’s own Railway pull (scripts.ops_monitor.fetch_events, run off-thread; gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID, fail-open to a no-op) then runs the pure utils.health.assess over the EVENT window to find a watched backbone integration that has cratered (fail-rate over a floor with enough real attempts, reusing the ops-monitor OkRate counter, hoisted into utils.health so there’s one definition the report + watcher share, the utils/stats.py move). For each crater she (1) autonomously files a fix-order with the real numbers (cogs.order.Order.file_health_order, the bot-attributed sibling of file_autonomous_order: same preflight + kitchen/pipeline/in-flight + AUTO_ORDER_DAILY_CAP gates, attributed to guild.me, filed in the first guild whose gates clear), deduped durably per integration via health_state (24h window, process-wide since a throttle is process-wide, survives the redeploy the filed PR triggers), and (2) alerts the MASTER in #bot-logs with an @master ping (the bot master (utils.permissions.MASTER_USER_IDS)) that a surface has gone dark — #968 owner steer: to #bot-logs ONLY, never a content room, and now for EVERY crater incl. the ops-only backend watches that used to file a fix-order silently (the gap the drained twitterapi.io wallet exposed — detection worked, the fix-order filed, but nothing reached the owner). Deduped per (guild, integration) durably, backstop-gated (a covered outage isn’t dark), and deliberately NOT mood-gated (an ops alert isn’t proactive content, so a muted room can’t hide “your feed is down”; the master kill switch still silences a fully-off bot). Posts with a user-only allowed_mentions so the ping notifies but a stray @everyone/@role can’t. The watched set (WATCHES) is apple_music (keyed on apple_music_lookup’s clean throttle signal, not catalog_lookup’s noisier empties) plus every market_fetch source — api_sports (the commentator’s scores/events/leaders/team-stats/standings backbone), sgo + the_odds_api (the betting lines), and polymarket + kalshi (the prediction markets) — each pinned to its source via the optional Watch.source sub-filter so the sources don’t cross-count, all clean because a successful empty slate is ok=True (only a real fetch failure is ok=False). So a crater of any market/odds/score feed self-files + warns, not just the sports backbone. Broadened to the whole app backbone (#968 “watch everything”): beyond apple_music + the market feeds, the registry now also watches embeddings (OpenAI — semantic recall + Kalshi discovery), elevenlabs_tts + elevenlabs_stt (voice in/out), perplexity (live-web grounding, kinds = all pplx_*), twitterio (#1255 — the content curator’s X source, keyed on curator_fetch; a datacenter-IP crater or a drained credit balance quietly stops the curator finding fresh posts + crawling the follow/repost discovery graph, ops-only notice=""), and odds_divergence (the cross-source line-disagreement signal from #968 — it has no ok field, so its Watch.ok_when predicate treats a ≥15pp odds_health.max_divergence as a “failure”, keeping it inside the same assess machinery). Most of these are ops-only (empty notice → files a fix-order but posts no room warning, since a backend crater isn’t a visible “I’m degraded” moment); only voice (tts) keeps a room notice. Each has a CLEAN ok signal (a real failure, not an empty result). Fail-soft surfaces with noisy signals (gif/image/reference/links/video) are deliberately left to the twice-daily ops-monitor’s integration-health (OBSERVE, no fix-order), not acted on here — the two-tier split: the 30-min loop ACTS on the load-bearing set, the ops-monitor OBSERVES everything. The registry is the extension point for more. Backstop-aware notice suppression (#748): a Watch can name a backstop integration (a bot-client attribute) that carries its user-facing impact when it craters; when that backstop is provisioned + healthy (breaker closed) the in-room notice is held (the fix-order still files for ops) and only fires when the backstop is ALSO down. SGO rides this with backstop="the_odds_api": once the Odds API SGO-down backstop shipped, an SGO crater no longer drops betting lines, so the “lines might be missing” warning would over-promise — she stays quiet unless the_odds_api.degraded too (“everything flaky and down”). The other watches declare no backstop, so they warn on any crater. Fail-open throughout. Emits health (action=filed/dedup/alert/usage_alert, with delivered=bot_logs on a posted owner alert, or delivered=suppressed/reason=backstop_healthy on a held one). The same tick also runs the metered-API usage poll (_emit_usage, #727), and now drives an owner-facing metered-wallet EARLY WARNING off the same reports (_maybe_usage_alert → utils.usage.usage_alerts): a prepaid wallet under its floor (twitterio < 100k / scrapecreators < 500 credits — the credit drain that took the curate feed dark) or a capped source ≥85% of its limit pings the owner in #bot-logs before it drains to a 402/429 wall (deduped per (guild, usage:<source>) on a wider 12h cooldown, since a balance drains slowly): it reads each metered source’s quota via utils.usage.collect_usage (SGO’s quota-exempt /account/usage, API-Sports’ free /status, the Odds API’s free header refresh, Highlightly’s passively-captured headers, ElevenLabs’ free /v1/user/subscription monthly character cap, GitHub’s free /rate_limit REST-core budget, and Giphy’s daily + OpenAI’s per-minute (informational) rate headers captured passively off their search/embed calls, #753; plus twitterapi.io’s prepaid CREDIT BALANCE via the quota-exempt /oapi/my/info, #1255 — a prepaid wallet with no fixed cap, so it flags on a balance FLOOR, not a pct) and emits one quota event per (source, metric), running unconditionally (bot-wide, not per-guild, so it fires even with no configured guild / no Railway creds). This is what turns a quota filling toward its cap into a flagged ops-monitor quota_low finding (high) before the 429 wall — the early-warning that was missing the day SGO’s monthly entity cap (100k, rookie tier) silently exhausted and took /bet dark while API-Sports sat at 90% of its daily cap.voice.py, voice in → voice out: a Discord voice note directed at Toots is transcribed (ElevenLabs Scribe), answered through the exact /ask pipeline (Ask.produce_answer, allow_voice=True so the model can tag a <sing> delivery), and replied to with a native voice note (no file-card attachment). “Directed at her” is a reply to one of her own messages: a Discord voice message has no text body, so it can’t carry an @mention — reply is the only deterministic spoken summon (a voice note that isn’t a reply to her is ambient room audio, heard as transcribed context by the chime-in path, not answered here). Runs whenever the ElevenLabs key is provisioned; whether she answers out loud vs in text is provisioning (the ElevenLabs key / bot.tts): when provisioned she speaks, else she stays in text. A spoken question always gets an answer (text at minimum). Shares the per-user ask rate bucket and the /ask abuse classifier. Failure degrades to a plain text reply (never a file): transcription miss → in-voice deflection, synth/send miss → the composed answer as text. cogs.ask skips voice notes so this cog owns the path. The voice-out delivery decision lives in the public deliver method and is shared with the typed-mention path (cogs.ask routes its mention answers through it with user_sent_voice=False): a voice note forces voice-in→voice-out, a typed mention voices only when the model nominated it, so the delivery format follows the answer, not the input channel.starboard.py, fixes + leaderboards for the server’s MEE6 #wall-of-fame starboard (two independent boards, one keyed on 😭 and one on 😂, each promoting at 10 of that emoji). MEE6’s promotion is rate-limited to one message/minute, so a message that crosses the bar in a busy stretch silently never makes the wall. /star period:[1h|1d|1w] (mod-only, optional threshold defaulting to 10) sweeps the configured discourse channels for messages that cleared a board but aren’t on the wall and lists them as jump links (posted in-channel, NOT ephemeral so anyone can help react; paged; each line tagged with the author mention, pings suppressed) for a HUMAN to react to, because MEE6 ignores reactions added by bots (confirmed in prod #541 via Railway logs: a /star bump added 65 reactions across 64 messages and MEE6 promoted none — starboards gate their reaction handler on if user.bot: return), so only a person’s reaction re-triggers MEE6. (An earlier mode:bump that re-reacted as the bot was removed for exactly this reason — it never promoted anything.) The scan runs in the background (acks ephemerally, posts the list when done) so a big week’s sweep can’t hit the 15-min interaction-token timeout. The report filters out already-promoted messages by harvesting original-message ids from the jump links in the wall’s MEE6 posts (parse_jump_message_ids), resolving the wall channel via the stored channel: pin (set on /star) or a name auto-detect. /star also carries the optional channel: wall-pin (the new home for it — it used to live on /leaderboard bangers, which folded into the /leaderboard stats hub): the pin persists to the per-guild settings KV (wall_channel_id) so /star + the card’s wall-bangers flex resolve the wall by id thereafter, else a name auto-detect (looks_like_wall_channel matches any wall/hall + fame channel ignoring emoji/separators so spelling variants all hit — wall-of-fame, the server’s actual wall-off-fame, etc.; so zero-config works out of the box and a one-time pin makes it rename-proof). Emits starboard_report. /leaderboard stats (everyone) is the single engagement HUB that absorbed the former separate /leaderboard bangers / reactions / card commands (this PR — they were redundant with the in-card board picker, owner steer): one navigable _LeaderboardView driven by FOUR in-card dropdowns so every stat board is one surface: the board toggle (🏆 bangers / 🔥 reactions / 😭 funniest / 🃏 card OVR / 🫶 besties), the when: PERIOD window (all-time / this month / this week, #1017), the show: scope (here-now / everyone), and the how-many: row count (top 10/25/50/full). The command is argument-free — everything is a dropdown (the period + count moved off slash args, owner steer). The hub is never gated so the always-public bangers + reactions + funniest boards stay reachable even with cards off; it opens on the card OVR board when the viewer can see it (the headline ranking), at all-time / top 10, else bangers. The funniest board (#1017) ranks all-time laughs pulled per member (laugh_reactions, 😭/😂) straight from the seed — the comedy-VOLUME twin of bangers (wall-clearing posts) and the leaderboard mirror of the awards’ Funniest (_build_funniest_seed, public like bangers/reactions). WINDOWED stats (#1017): all-time reads the member_engagement seed directly; weekly/monthly subtract a daily SNAPSHOT of the seed from N days ago (db.engagement_snapshots + the pure utils.engagement_window: window_display/window_state diff the cumulative counters + the reply/mention graph) — so the SAME board builders re-rank over the window (_period_display/_period_state swap the data source; the card OVR over a window is a ‘form’ rating via ovr_board(windowed_seed=); besties over a window is the ‘best new duo’). One daily snapshot feeds BOTH weekly + monthly by subtraction — no extra scan jobs, instant reads. Forward-looking (windows fill in as snapshots accrue; a window with no snapshot yet renders EMPTY, never all-time mislabeled). The bangers board ranks all-time BANGERS per member — posts that cleared the wall bar (10+ 😂 OR 😭) — read straight from the member_engagement stats seed, no scan (#957: the bangers board moved off the wall onto the seed; this is the seed’s would-be-wall count, a SUPERSET of MEE6’s actual wall promotions — the card’s separate “wall bangers” flex still reads the wall for that). The reactions board ranks total reactions pulled per member, also straight from the seed (the /stats full-history scan, kept fresh by the daily catch-up writer) — no live scan (#957: the old LIVE windowed 6mo/1w/1d scans were dropped; weekly/monthly are now the period: snapshot-delta windows (#1017), derived by subtraction, not a live scan). Boards show the full board with no row cap (paged past Discord’s 4096-char description limit), delivered as ONE navigable message — the interactive _LeaderboardView (#902): ◀/▶ page flips that are in-memory over the prebuilt pages (instant, no re-fetch), the board picker dropdown (data-driven off self.boards), and a show: scope dropdown (here-now / everyone) that filters whichever board you’re on by guild membership (the universal departed filter, default here-now). Only a board/scope change re-fetches (defer + _build_board dispatcher, which reuses the board builders + resolves the right data source); a public shared board, so anyone in the room can navigate it. The card OVR board (#570) ranks active members by their /card OVR (rows show the OVR number) — built off the PlayerCard cog’s ovr_board (the SAME member_engagement seed + snapshot the card reads, so a member’s board OVR == their card), rendered through the same shared _board_pages as bangers/reactions for a consistent board (_rank_mark + mention_or_name, real-member-only via the PR #985 roster fold). It’s gated on the playercard experiment (PRODUCTION=everyone, STAGING=mods-only, OFF=hidden — exactly as visible as the cards it ranks) and joins the toggle picker only in PRODUCTION (_available_boards), so the toggle never exposes the card/besties surfaces to a non-mod during the staging preview — a mod previewing in staging still gets the card board surfaced by run_stats. (The games + bets boards stay their own /leaderboard subcommands: different data shapes — game points, play-money — not folded into the stats toggle. /leaderboard games is argument-free too (mirroring this hub): its options — when: PERIOD (this season / all-time) + how-many: row count (top 10/25/50/full) — live on in-card Components V2 dropdowns (a _GamesLeaderboardView in cogs/games.py mirroring this hub + /card), so period/count re-slice in place; there’s one game today so it opens straight on Guess the Song (a board picker returns if a second game lands). The board build is the shared Games._build_games_board — paged under the V2 text budget so a “full board” pages rather than truncating.) Boards run anywhere and are all-time and uncapped (accuracy over a ceiling — explicitly NOT capped, per owner steer on #541), tallied by USER ID (fold_entries keys on uid:<id>, the id coming from the embed-author avatar URL for the wall / the message author for reactions) so a person’s posts merge across a display-name change — the prod bug where @zapper showed up as two rows (4 + 2 moments) because he’d renamed. An embed-fixer webhook repost carries the user’s name but the webhook’s id, so it’s keyed by normalized name (clean_author_name strips the (Embed Fixer) marker + #0000 discriminator) and folded onto the real user by resolve, which matches a name-keyed entry against the set of names each id has posted under (so a fixer repost under an old name still lands on the right person). resolve is seeded with the real (non-bot) MEMBER ROSTER (_member_roster, guild members’ display name + username → id) so a webhook/embed-fixer repost folds onto a real member by name EVEN when that member has no id-keyed wall post of their own — and, critically, an entry that matches NO real member is DROPPED (rank_tally now excludes uid=None slots). That’s the fix for the wall board filling with tweet-source reposts (Bleacher Report (@BleacherReport), Fabrizio Romano, …): a fixer/webhook repost of a tweet shows the TWITTER account as the embed author (a Twitter avatar, no Discord id, a name matching no member), so it now stays off the members-only board instead of topping it. The same roster-fold + drop applies to the reactions board (consistency). Every stat board now reads the member_engagement seed — NONE scans the wall. The wall incremental durable cache (db.cache_get/cache_put, namespace walloffame_board_v3, keyed {guild}:{channel}: a cursor + settled tally per channel, re-scanning only new wall posts, immutable → settle on sight) survives for exactly two consumers that genuinely need the ACTUAL MEE6 wall (the seed can’t capture promotions): /star (the un-promoted sweep) and the card’s “wall bangers” flex (lifetime_totals). The bangers + reactions boards read bangers / reactions_received per member from the seed (instant, kept fresh by the daily /stats catch-up writer). The former cold-cache background board-build path (_serve(background=...) + _deferred_board) was removed once every board moved to the seed — boards are always built inline now. The discord-free core (cleared_emojis / message_reaction_total / parse_jump_message_ids / classify_message / parse_avatar_user_id / clean_author_name / fold_entries / resolve / rank_tally / merge_tallies / tally_authors / tally_reaction_totals) lives in utils/starboard.py, unit-tested. The besties board (#570 follow-up) ranks the server’s closest PAIRS — the Ochiai coefficient (provably == cosine similarity; validated against the tie-strength literature, beating PMI/NPMI which inflate rare pairs) of each pair’s two-way reply+mention graph, read from the engagement seed’s state blobs (the full uid-keyed reply_targets/mention_targets Counters — no scan). It’s activity-normalized (the geomean of each member’s share of interaction aimed at the other), so it surfaces genuine duos, not the loudest hubs — “are you each OTHER’s people”, not “are you both loud”; reciprocity is baked in (a one-sided pair’s geomean collapses). The show: filter is UNIVERSAL across the whole /leaderboard family (#570 follow-up — this is also the “departed filter for leaderboards”): a _ScopeSelect dropdown on the _LeaderboardView toggles here-now (members still in the guild) vs everyone (incl. departed — the all-time view) and re-filters WHATEVER board you’re on (bangers / reactions / card / besties), defaulting here-now, persisting as you switch boards. The membership filter lives ONCE in _board_pages (drops uids not in guild.members, applied BEFORE the row cap so ranks re-number contiguously); besties applies the same Scope via closest_pairs. Besties itself is a board IN the shared toggle picker (_BOARD_OPTIONS + _available_boards, joins the cycle in PRODUCTION like card — no separate command, reached by switching boards in the picker), rendered with pair rows (A ↔ B · 20%); playercard-gated exactly like /card (same seed). A fixed interaction floor (40) is the small-sample guard (Bayesian shrinkage is moot at all-time scale — verified — but is the lever a future weekly window would need). The pure closeness core is utils.social_graph.closest_pairs (unit-tested); the cog reads the seed + resolves names. Emits starboard_report + walloffame_leaderboard + reaction_leaderboard + funniest_leaderboard + card_leaderboard + besties_leaderboard./undo command, and its admin.py cog, both removed) now lives in the /order view as a confirm-gated “↩ undo deploy” button, backed by the interaction-free order.py:_undo_deploy core. It’s deploy-scoped (rolls back the live Railway build, not a specific order), so it sits with the kitchen toggle, not the per-order retry/cancel, and only shows when RAILWAY_API_TOKEN + RAILWAY_SERVICE_ID are set. /order’s view is now the single mod-ops console: it absorbed /close, /open (kitchen toggle), and /undo.settings.py, /menu interactive wizard — the single mod-config surface. Config pages (Discord caps a view at 5 action rows / ~5 select menus, so the channel pickers are split across pages to stay ≤4 selects each), paged via nav buttons. The cinema desk (#cinema-desk) cinema-channels picker (_CinemaSelect/db.get_cinema_channels) lives on the betting & markets page (_PAGE_PREDICTION, retitled from “prediction markets” — owner steer: the cinema desk’s movie/TV NUMBERS are the markets-adjacent factual layer, so it sits beside the prediction-market drop config; that page had select headroom, 3→4 selects). The channel pickers are grouped onto contiguous pages so the wiring sits together: page 1 = people + vibe (the non-channel selects) = mod roles, mood, girls roles, ignore (silenced members) + the master kill switch on the nav row; page 2 = channels part 1 = discourse channels, chime-in channels (where Toots jumps in), memory channels (what the long-term memory writer reads), music channels; page 3 = channels part 2 + sports coverage = sports channel (the live-commentary channel) with the watched-sports picker beside it (_WatchedSportsSelect, a string multi-select over utils.sportsdata.sports.WATCHABLE_SPORTS — which sports the live commentator calls; empty = all (the default, stored null), so a guild only narrows when it wants to, e.g. mute the MLB/NFL breadth and keep the World Cup, persisted to the watched_sports settings KV and read by cogs.commentator._resolve_targets to filter each tick’s games per guild) plus the betting-sports picker beside it (_BettableSportsSelect over the settleable subset utils.sportsdata.sports.BETTABLE_SPORT_KEYS = soccer/NBA/MLB/NHL/NFL/UFC-MMA — which sports the play-money Bookie takes bets on; UNLIKE watched, unset = the {soccer, NBA} default, NOT all, so the new sports MLB/NHL/NFL/MMA ship OFF until a mod turns them on (#mlb-betting), persisted to the bettable_sports KV, read by cogs.bookie._bettable_for_guild which serves a game only if its sport is in the guild’s bettable_sports set (unset = the default) — betting is INDEPENDENT of watched-sports (owner steer: the two menus are fully separate, so a guild can take MLB bets without commentating MLB and vice versa); the metered Odds-API odds fetch is likewise scoped to the cross-guild BETTABLE union (_bettable_sports_union), not watched. Page 3 also carries the live-commentary PACE knob (_CommentaryPaceButton → _PaceView): the heartbeat/clutch check-in intervals (the two SETTING_COMMENTARY_*_INTERVAL_MIN tunables, commentary_cadence) were MOVED here off the tune page so they sit beside the sports config they scope (the working-hours precedent — a knob lives with its config, hidden=True on tune). It’s a BUTTON opening a dedicated pace SUB-VIEW (_PaceView, swapped in place with a [◂ back] like the models/calendar/tune tail), NOT inline selects, because page 3 already has 3 select menus and Discord caps a message at 5 (the split that made this page keeps each ≤4) — so the two dropdowns get their own view with full headroom rather than pushing page 3 to 5/5. The sub-view renders the two intervals as preset DROPDOWNS (_PaceSelect, reusing the tune editor’s band-filtered preset_values/_option_label ladder, so a pick is always in range), each saved to the settings KV on pick; on [◂ back] the picked values sync into MenuView.selected so the page-3 button label (⏱️ 20m / 10m ▸) updates. The category-routed prediction-market surfaces (market_drop + market_alert) filter their SPORTS content by WATCHED sports ONLY (markets.resolve_market_sports), NOT the bet menu (owner steer — reverts the #1231 watched ∪ bettable union): the BET menu (bettable_sports) is BOOKIE-only and must not gate market drops — a bettable sport still powers /ask scores/odds, the commentator, and the Bookie’s prediction-market pulls (those fetch snapshots per game, never through this filter), but it only appears in a market DROP when its market is actually selected for the room. So the per-room Kalshi category/subcategory picker (_DropSubcategorySelect) is the SOLE control over a drop’s sports when watched is unset (the common case → resolve_market_sports returns None → no sport filter); a set watched list is a coarse per-guild narrowing on top. The subcategory picker now PAGINATES its tags (_subcat_all_pairs + _subcat_page_count, ◀/▶ _SubcatPageButton) since Discord caps a select at 25 options and a room’s covered categories can carry more (Entertainment’s ~24 live Kalshi tags + Sports’ ~20) — so NOTHING is hidden (the bug where a room covering Entertainment + Sports showed only Sports · Soccer and hid Tennis + every other sport). The paged multi-select MERGES on save (_page_values) so a selection made on one page survives editing another; subcat_page resets on room/category change. DEFAULT_BETTABLE_SPORTS (the {soccer, NBA} default) is hoisted to utils.sportsdata.sports as the shared source of truth (bookie + the market filter both read it). The WHOLE lifecycle works because the Bookie sources authoritative API-Sports STATE for the new sports on demand across all phases — hub.live_games/upcoming_games/recent_finals all take an api_sports_extra set (the enabled-new-sports union), so a MLB/NHL/NFL/MMA game shows correct pregame/live/final transitions even while SGO (the ODDS source) is throttled (the ODDS still fold from SGO/The Odds API/Polymarket+Kalshi; only the game STATE + settlement come from API-Sports). Settlement is a per-sport REGISTRY, interchangeable the way the odds feeds are (#tennis-registry / #1113 enabler): utils.sportsdata.sources.SourceCapability.settles is a per-SPORT set (not a bare bool) declaring which sports each feed can authoritatively finalize — API-Sports its six hosts (soccer/NBA/MLB/NHL/NFL/UFC), The Odds API /scores the TEAM sports only (verified live: MLB 32/32 completed, but tennis AND mma come back 0-completed, so both are HONESTLY excluded — the matrix’s “encode the real competence boundary” rule; MMA’s real settler is API-Sports /fights). The Bookie’s bettable-capability set (_SETTLEABLE_CAPABLE) now DERIVES from settleable_sports() (the union of every feed’s settles) instead of a hardcode, and the settle loop is a _SettleLeg resolver (Bookie._settlement_legs) that walks the feeds in settlement_sources(sport) PRIORITY order (API-Sports the primary dedicated feed; The Odds API the API-Sports-down backstop, #725) — each finalizing only the sports it covers through the shared idempotent _settle_game, so an overlap can’t double-pay. The refactor is behavior-preserving (all prior settle/backstop tests unchanged) and makes settlement extensible the way odds already are: adding a new sport is a matrix/registry entry + a canonical_* name fold (a dedicated tennis provider with settles={"tennis"}, or a new API-Sports host like rugby/handball/volleyball — the hosts exist, verified 2026-07-03), NEVER a settle-loop edit; a sport no feed settles (settlement_sources()==()) is derived-out and never offered. utils.api_sports adds v1.baseball/v1.hockey/v1.american-football + the shape-flexible _team_game_to_snapshot, plus v1.mma’s /fights + _fight_to_snapshot (an INDIVIDUAL-event shape: fighters.first/second with an explicit winner boolean and no scores/league, so the winner is encoded as a 1-0 ‘score’ and the shared settlement path resolves it unchanged — a no-winner draw/no-contest reads 0-0 → a 2-way PUSH; season-less, so it plugs into _EXTRA_SPORT_FETCH via _no_season). utils.sportsdata.names.canonical_us_team (co-located with the soccer FIFA-code fold) folds cross-provider team-name spellings so a bet reconciles across the pricing + settling feeds — including a per-sport NICKNAME fold: SGO prices the US team sports with the bare nickname (Cubs, White Sox, 49ers) while API-Sports settles with the full name (Chicago Cubs, …), so canonical_team(name, sport) maps both onto one canonical (the nickname map is built per-sport + drops ambiguous keys so the Cardinals MLB/NFL collision can’t cross-resolve, and the bare Sox is dropped in favor of Red Sox/White Sox); NBA was added to the US roster (it was missing entirely). Without this, SGO-priced MLB/NHL/NFL/NBA bets reconciled 0% against the API-Sports final and all stranded — invisible during #1101 because SGO was throttled, caught live 2026-07-03 the moment it reset (SGO 0/30 → 30/30 on MLB after the fold). Soccer CLUBS get their own fold, canonical_soccer_club (#1469 Stage 2): a club is spelled differently by each feed (API-Sports settles “Newcastle”/”Bayern München”/”Inter”; SGO + The Odds API price “Newcastle United”/”Bayern Munich”/”Inter Milan”), and norm alone can’t bridge a truncation/compound-drop/transliteration/org-affix — so an unbridged club bet stranded + voided at 96h (Gap B on epic #1469, the national+US analog for clubs). Same machinery as the US roster: a curated _SOCCER_CLUBS table of (canonical, aliases) across the six covered leagues (EPL/La Liga/Serie A/Bundesliga/Ligue 1/MLS), built into a _us_key-normalized key→canonical map with the AMBIGUITY GUARD (a key two different clubs would claim is DROPPED → a safe fall-through to norm → a void, never a mis-pay). Canonical = the API-Sports settlement spelling EXCEPT where it nests as a substring of another club (“Inter” → “Inter Milan”, so the hub’s containment matcher can’t merge it with “Inter Miami”). Verified live 2026-07 against the API-Sports + The Odds API rosters (every measured same-club pair converges, every distinct-club pair — Man Utd/Man City, the three Real’s, Inter Milan/Inter Miami, AC Milan/Inter Milan — stays split, zero ambiguous drops); ships dormant-but-ready (off-season until ~Aug/Sep), an incomplete roster is SAFE. MMA fighters get their OWN fold, canonical_fighter — SGO abbreviates the first name (K. Usman) while API-Sports spells it out (Kamaru Usman), so both reduce to initial+surname (k usman), applied by match_key_parts (game dedup + bet↔final keying) AND bookie_bet_outcome(person_names=True) (winner↔side-label matching) when the sport is mma; without it a SGO-priced UFC bet reconciled 0/16 against the API-Sports final and stranded (live-caught 2026-07-03 the moment SGO’s throttle reset — the fold lifted it to 12/12 of the fights on both slates, the rest just not yet in the API-Sports window). A 2-way tie PUSHES rather than loses (bookie_bet_outcome(three_way=...); MLB/NHL never tie, a rare NFL tie refunds, an MMA draw/NC pushes; MMA is NOT in _THREE_WAY_SPORTS). MMA is UFC-scoped by a fold-only guard (_ODDS_API_FOLD_ONLY_SPORTS = {"mma"}): The Odds API’s mma_mixed_martial_arts key prices EVERY MMA promotion (UFC/Bellator/KSW/regional) but API-Sports /fights settles UFC only, so the Odds-API supplement FOLDS its line onto UFC fights already surfaced (by SGO’s UFC-scoped league or API-Sports) but never ADDS a non-UFC fight — which could never pay out a win (it would strand + void at 96h); live-verified 2026-07-03 (36 Odds-API MMA fights, ~12 UFC on the API-Sports slate). Tennis stays unbettable — API-Sports has NO tennis host (v1.tennis doesn’t resolve, verified 2026-07-03), so no finals feed to pay out a win; deferred), then feed channels + logs channel. chime-in + memory are split out of the discourse set so each can watch a different set of rooms, each independent (empty = that surface goes dark; a one-time DB seed copies the discourse set into both on first deploy so nothing regresses, then they’re edited separately); discourse posts/icebreakers + starboard sweeps keep the shared discourse set. The config pages carry a consistent nav row — ◂ back/⏻ power · more ▸ (steps to the next page) · experiments ▸ (the one-tap jump to the experiments hub, on every config page; on the prediction page, whose next page IS experiments, it replaces more ▸). The final hop experiments→models is its own models ▸ on the experiments page (the friendlier pages come first; the raw-numbers tune editor is now LAST), and the models page’s back is ◂ back (it steps back to the experiments page). Arrow-styled to match the nav row rather than standing out as emoji shortcuts. page 4 = the experiment rollout selects (one tri-state select per registered experiment, paginated via ◂/▸ nav buttons sharing the nav row once the registry grows past _EXPERIMENTS_PER_PAGE (4) selects, so a 5th+ experiment is reachable instead of dropped); the experiments page (page 4) leads via its [models ▸] nav button into the chain tail — models → calendar → tune — each a separate view swapped in place with a [◂ back] to its predecessor. There is no standalone /tune command — the tune editor is only reachable through /menu. Every select auto-saves and re-renders the embed. This cog absorbed the former standalone /logs, /girls, /ignore, and /experiments commands (those cogs were deleted). The experiments page leads to the Models page (cogs.models) via a [models ▸] button — one opus/sonnet select per answer/generation surface (ask/recap/discourse/memory) — which in turn reaches the calendar (cogs.calendar_view) via a [calendar ▸] button: a scheduled-post day-planner (chill/yaps pages over a 30-minute working-hours grid — each hour holds a :00 and a :30 slot — that the discourse/music/market-drop/bet-board/curator schedulers read in place of their fixed CHILL_TIMES/YAPS_TIMES slots when a mood is managed — auto-spread the surfaces evenly + tweak per slot, no overlaps, stored in the post_schedule settings KV; falls back to the full hardcoded slot pools when unset, so an unconfigured guild is unchanged — the calendar is now the ONLY per-guild control over scheduled-post frequency (the per-mood posts-per-day caps were removed). The calendar also carries a 🔥 FIREHOSE picker (_FirehoseButton → the _FirehoseView sub-view → the guild-wide scheduler_firehose_surfaces settings set): a PER-SURFACE opt-in where the picked surfaces post at EVERY working-hours slot (ignoring their grid assignment), resolved in the single chokepoint every surface reads — schedule_calendar.calendar_hours returns all_slot_times(working_hours) for a surface in the set, else the stored calendar. Per-surface (not all-or-nothing) so a guild firehoses the cheap token-only surfaces (curator/discourse) and leaves the SGO-capped metered ones (market_drop/bet_board) on the normal calendar — the affordable shape per the #firehose cost analysis (whole-bot LLM spend is ~$4/day; the wall is SGO’s monthly entity cap, already exhausted at baseline). The grid stays fully editable (it still drives the non-firehosed surfaces), off by default, never overrides a muted mood, and each firehosed surface is still thinned by its own channel config + dedup + the 0.6 self-gate + rate limits; emits scheduler_firehose). Trial-feature rollout stages (off / staging / production) are backed by the registry in the top-level experiments.py module + the per-guild experiment_states table; each registered experiment gets one tri-state select that auto-saves. Registered experiments: live_scores (default staging — gates the live sports commentator loop (cogs.commentator) posting to the dedicated sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the loop entirely; needs API_SPORTS_KEY/SGO provisioned), market_drop (default staging — the scheduled prediction-market drop), market_alert (default staging — the event-driven movement alerts, split out from market_drop so a guild can run the calm scheduled drops without the breaking-news swing/closing-soon alerts, or vice versa; gates cogs.market_alert), bookie (default staging — the play-money betting game; gates /bet + settlement, distinct from the board), bet_board (default staging — the “who’s favored” line posts (cogs.betting_board): the morning slate + the pregame nudge to the sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the guild — split out of bookie so a guild can take bets without the board drops, or vice versa), awards (default staging — gates the weekly/monthly engagement-awards loop (cogs.awards, #1017): production posts the weekly Player of the Week + the monthly MVP / Funniest / Besties gallery to the awards channel, staging auditions each post in #bot-logs, off skips the loop), and playercard (default off — gates /card, the engagement rookie card: off = hidden, staging = mods-only preview, production = everyone; ships dark on purpose so a mod runs /stats + validates the seed before the card is exposed; /stats itself is always available, only /card reads the gate, #570), and x_crosspost (default off — gates crossposting Toots’ drops (music / market_drop / discourse / curator) to the @tootsiesbar X timeline (utils.x_crosspost): production actually tweets each drop that ships to a room, staging auditions the tweet TEXT in #bot-logs (no post to X), off no-ops. Ships dark like playercard because it’s the ONE surface that posts OUTSIDE Discord to a public external timeline — so it’s opt-in per guild, which also scopes it to the account’s home server (only a production guild tweets). Production also needs the 4 X_* OAuth env vars (utils.x_poster.XPoster provisioning)), all reading their stage via db.resolve_experiment_state. icebreaker, voice, video, gif, and image graduated to permanent surfaces and were removed from the registry (and the menu): each is controlled by provisioning + kill switches alone — ElevenLabs for voice, Giphy for gif, OpenAI for image, the VIDEO_TRANSCRIPTION kill switch (+ yt-dlp/ffmpeg) for video; icebreaker always ships a floor-clearing opener — with no per-guild staging/off. The retired global ICEBREAKER_STAGING/VOICE_ENABLED env flags were the earlier form of the same trial→permanent path. Page 1 also carries the master kill switch (the “complete off button”): a power button on the nav row backed by the per-guild servers.bot_enabled flag (default on, db.is_bot_enabled/set_bot_enabled). Flipping it off takes Toots fully dark in the guild — every entry point checks is_bot_enabled: the mention (cogs.ask) and voice-note (cogs.voice) listeners, all three schedulers (discourse/music/memory) and both chime-in paths gate inline, and the whole slash surface is gated globally by bot._GatedTree.interaction_check (a custom app_commands.CommandTree whose interaction_check declines every command with an ephemeral note when off — the one exception is /menu itself, the way back on; fails open on a DB blip so a hiccup can’t brick every command). This is a strictly bigger hammer than mood=off (which only mutes the proactive posts, leaving mentions/voice/commands live); the kill switch silences those too. Emits bot_power.- tune.py, the per-guild editor view for every user-facing cap/cooldown/cadence knob — the rate caps, the chime-in cadence per mood (daily cap / cooldown — the POST cadence; reactions have no knob, they gate on the model score), the /order cooldown + in-flight cap, the market/betting alert caps, and the image reply knob (per-guild daily cap). (Two TUNABLES groups are hidden=True — still resolvable, but edited ELSEWHERE on /menu beside the config they scope, not on this raw-numbers page: the working-hours window lives on the calendar page, and the live-commentary heartbeat/clutch intervals live on the live-sports page — see _CommentaryPaceButton in cogs.settings.) (Reactions + gif replies are paced by the model’s own confidence, and the scheduled surfaces by the /menu calendar, so none of those carry a tune knob anymore.) Not a cog/command — it’s a view library imported by cogs.settings; reached via the tune ▸ skip from /menu’s calendar page (build_tune_view), as the LAST page of the chain (it’s terminal — no onward button; tune was moved to the end since it’s raw numbers). It’s a paged flat list of rows (tapping a row opens a modal to edit that value, int or float). Related knobs collapse into one row whose modal has a field per knob (a knob’s group/mood/field drive this): the daily caps row edits per-user + per-server (2 fields), the chime-in row edits cap + cooldown per mood (a 4-field modal), the /order row edits cooldown + in-flight cap (2 fields, mixed units), and the working-hours start / end pair is a 2-field modal. A merged row’s button shows its values grouped by unit (5 / 20 posts/day · 40 / 20 minutes, or 15 minutes · 3 orders), and each modal field is labeled by field when the bare mood would be ambiguous (cap (chill) vs cooldown (chill), or per-user vs per-server). Submits are parsed all-or-nothing, so a multi-field row never half-writes. A modal tops out at 5 inputs; the biggest row is 4. These used to be hardcoded module constants; now each is a per-guild override in the settings KV table, read live on every check (no restart), with the code-level value as the fallback default. The single source of truth for the set is utils/tunables.py:TUNABLES; this view renders it via ROWS = _build_rows(TUNABLES) (grouping by group), and page_count/page_rows paginate the rows. Paging (ROWS_PER_PAGE rows + a ◀/▶ nav row) lets the list grow past a modal’s 5-input cap; Discord’s 5-action-row ceiling is per message, so each page gets a fresh budget. The chime-in / reaction score thresholds are deliberately not tunable — they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants in utils/tunables.py, read directly), kept out of the menu; the reaction threshold (DEFAULT_REACT_THRESHOLD) is now the SOLE reaction gate (no cap, no cooldown). The model knobs moved to their own Models page (cogs.models, reached from the experiments page): the ask-model select (#832) used to ride tune page 0’s top row, but when it generalized into a per-surface registry (ask/recap/discourse/memory, tunables.MODEL_SURFACES) it moved to a dedicated Models view reached via a [models ▸] nav button on the tune editor. Tune’s paging is now uniform (ROWS_PER_PAGE knob rows per page, no reserved row).memory.py, long-term memory: an hourly writer (200 msgs/1h, same fetch as /recap) + a daily rollup that distills a guild’s memory-channel activity into attributed memory notes (the memory channels are set on /menu page 3, db.get_memory_channels, split out of the shared discourse set so memory can watch a different set of rooms than discourse posts/chime-ins; empty = the writer goes dark). Nothing is deleted by decay: the daily rollup synthesizes each day’s hourly notes into one daily note but the hourlies are KEPT (flagged rolled_up, not deleted) for detail drill-down, and daily is the durable searchable grain, retained across the whole history. There is no weekly tier — at this data scale storage isn’t the constraint, retrieval is, so recall searches the day-grained notes (and can drill into the hour-grained ones) instead of compressing the past into a coarse weekly summary. Every note is concept-tagged AND embedded at write time (_tag_and_store runs tag_memory_note — a Haiku pass storing keywords like drama, beef, callout in memory_notes.keywords — AND _embed_note — an OpenAI text-embedding-3-small vector in memory_notes.embedding — concurrently), so recall has two paths: keyword/FTS (the tags carry the concept vocabulary the fenced prose deliberately lacks) and semantic vector (cosine similarity matches the concept natively, so “messiest thing you remember” lands near a drama note with no tag/expand dance needed). Embedding is fail-open: no OPENAI_API_KEY → NULL embedding → that note just falls back to the keyword path. Stored as a JSON float array in a TEXT column with in-process cosine ranking, not pgvector (at this data scale retrieval, not storage, is the constraint, so cosine over the bounded candidate set is fast and adds no Postgres-extension dependency — a CREATE EXTENSION that isn’t installed would break the every-startup schema init). Read back into /ask/mention context as a tier mix (recent daily arc + freshest hourly) so Toots does callbacks and knows her regulars. In the same hourly pass she also keeps a parallel self_* pyramid of her OWN takes (_maybe_write_self_hourly distills just the messages she authored — subject="self", gated on her own lower SELF_ACTIVITY_THRESHOLD, tiled on the self pyramid’s own clock so a dead-but-she’s-talking room can’t double-count — rolled up self_hourly→self_daily by the same _maybe_rollup with family="self"). It rides the same fenced write/rollup (people are still fenced; only the framing changes to “your own opinions/calls/bits”) and is read back via memory_tier_mix labeled [you've said before] so she stays consistent with herself, distinct from the human-attributed memory. Plus /forget (self-service erasure, no parameter, you can only forget yourself; deletes across all tiers incl. self — and the consented user_facts a user taught Toots about themselves, via db.delete_user_facts) and /remember period:[week|month|2months|6months] (mod-only one-time backfill that seeds the room memory from channel history as per-day daily notes across the whole range; idempotent per day; runs as a background task so a long day-grain backfill isn’t bound by Discord’s 15-min interaction window — it acks immediately and drops the result in-channel when done; the same run also reindexes — _reindex_keywords concept-tags any notes that lack keywords AND _reindex_embeddings embeds any that lack a vector, each emitting memory_reindex. The reindex is scoped to the selected period’s window and walks it newest-first one page at a time until the window drains, so the period actually bounds the reindex — a 6months run reindexes 6 months, a week run just the week; rerun the longest period to cover the whole history. Termination is by forward progress (a per-run seen set of attempted note ids stops the walk the moment a page brings nothing new), not a guessed count ceiling. When an idempotent re-run writes no new daily notes but the reindex DID sharpen the index, the reply says so (_reindex_note) instead of reading as a no-op). The write prompt is fenced (observed public behavior only, never inferred private traits, no transcripts) to keep attributed “who did what” memory inside the constitution.polls.py, native Discord polls Toots creates + manages on a regular’s behalf (#888). The on-demand surface is the @Toots mention: “make a poll for who’s recoupling” routes the model to the create_poll tool (and the list_polls/read_poll/update_poll/end_poll siblings), which delegate to this cog. She supplies the question + options — sourcing them first if asked (e.g. look up the Casa Amor cast, use the names) — and the pure core (utils/polls.py) clamps to Discord’s limits (2-10 options ≤55 chars, question ≤300, duration 1-768h default 24) so a bad request degrades to a friendly note, never a 400. Authorization is the ASK-side gate on top of Discord’s own permissions: create/list/read are open to anyone (poll-create rides the per-user ask bucket), end/update are mod-gated (is_mod). Discord polls are immutable once posted, so update_poll CLOSES the target poll and posts a fresh copy with the edits merged over the old values — votes reset, said plainly. Reads are GUILD-WIDE (#888 owner steer): Discord has no “list every poll” API (a poll is a message), so list_polls/read_poll run a bounded multi-channel scan (_scan_guild_polls: every readable text channel, the request’s channel first, recent history each, channel-/concurrency-capped) and label each poll with its #channel; an id from the list resolves anywhere in the guild (mutations too, via _resolve_poll_message’s guild fallback). The model is also steered to read existing polls before creating (the buffer + list_polls) so it doesn’t post a duplicate, mirroring file_fix→check_fixes. Fail-open throughout; emits poll (action create |
list | read | update | end + ok + reason). Pure validate/normalize/format in utils/polls.py (unit-tested); discord I/O here. |
message_search.py, Discord message-history search Toots runs on demand. The @Toots mention routes “find where someone posted that link”, “what did discord_info.py, read-only Discord server/member lookups Toots runs on demand — the structure complement to message search (search reads message HISTORY; this reads the server’s STRUCTURE, facts she’d otherwise guess at). The @Toots mention routes “when did X join”, “how many people are here”, “what roles are there / who’s a mod”, “what channels/emojis exist”, “who reacted to that”, “what’s pinned in #rules” to the single discord_lookup tool, which dispatches by aspect: member (account age + JOIN date + roles + booster, target=a person), server (member count people-vs-bots, age, boost tier, channel/role/emoji counts, owner), roles (every role + member count, or target=a role → who has it), channels (the list by category + topics), emojis (custom), reactions (WHO reacted per emoji, target=a message link/id), pins (a channel’s pins). One tool by aspect rather than seven, to keep the wiring proportional. Everything is READ-ONLY (no writes, no moderation) and scoped to what the bot can already see — member roles/join dates are the same facts in Discord’s own UI. Resolves names/handles/#channel/role refs against the cache; member/server/roles/channels/emojis are cache reads (no API), reactions/pins make a bounded fetch. member/server/pins also surface IMAGE URLs (the member’s avatar, the server icon+banner, a pinned message’s image) so she can chain to look_at_image and actually SEE them (“what’s their pfp”, “what’s our icon look like”). Fail-open; emits discord_info (aspect + ok). Wired into client.ask, so it reaches every model uniformly. Pure row shapes + formatters + duration math in utils/discord_info.py (unit-tested); discord I/O here.scheduled_events.py, Discord scheduled events Toots creates + manages (#888), the sibling surface to polls. The mention routes “schedule a watch party Friday 8pm” to the create_event tool (+ list_events/read_event/update_event/end_event), delegating here. EXTERNAL events (a free-text location), since a chat request rarely names a voice/stage channel; the model passes an ISO 8601 start_time it computes from the current date (the [ctx] line it’s given is ET, so the pure core reads a naive timestamp as ET, not UTC — what keeps “Friday 8pm” from landing 4-5h off; an explicit offset/Z is respected), and the pure core (utils/scheduled_events.py) validates (name ≤100, future start, end defaulted to +1h, location ≤100 defaulted to TBD) so a bad request is a friendly note. The model is steered to read list_events before creating so it doesn’t duplicate one already on the calendar. Authorization: list/read open to anyone; create/update/end are mod-gated AND require the bot’s Manage Events permission (checked + reported clearly in the cog). Events resolve by id or a case-insensitive name-substring match (_resolve_event, ambiguity lists candidates); end_event is status-aware (a scheduled event is cancelled, an active one ended, an over one reported as such). Unlike a poll, an event edits in place (no reset). Fail-open; emits scheduled_event. Pure core in utils/scheduled_events.py (unit-tested); discord I/O here.playercard.py, the engagement “rookie card” (#570, slash command /card): a public /card [member] builds one member’s Discord-Wrapped-meets-NBA-2K card — a 0-99 OVR, a rarity tier (Bronze/Silver/Gold/Diamond/Legend), three named ratings, a deep “tape” of raw stats, earned badges, and a stat-derived archetype — every rating percentile-ranked against the server’s ACTIVE members (Spotify-Wrapped “you out-rate 80% of the room”), with a “next tier” chase line. Rendered as a PAGINATED Discord Components V2 LayoutView (#570): a hero page (name+OVR emblem + avatar Thumbnail; the LABELED CLOUT/HUMOR/SOCIAL ratings strip; the top headline flexes; then the tier/archetype line BELOW the stats per owner steer; badges; chase) plus ◀ ▶ nav buttons that flip in-memory (mirroring /leaderboard) through FOUR detail pages — 💥 reactions · 🫂 social · 🎧 content · 📊 activity & career — that surface EVERY counted stat, grouped one-per-line so each is self-explaining (the de-jumble fix: 🔥/😭/🤝 are reserved for the ratings and never reused as a detail-line emoji). A 📋 button publishes the explainer — itself paginated (scoring page + one page per stat group) that DEFINES every single stat with a one-line blurb (Discord has no hover tooltips, so the explainer page is the only place an explanation can live; #570 owner steer “fill out the explanation page with every single stat”). The blurbs live in the pure pc.STAT_GLOSSARY (+ a badges page from pc.badge_glossary() explaining how each badge is earned), and tests/test_playercard.py:test_glossary_covers_every_detail_stat asserts the glossary defines EXACTLY the stats the detail pages render (same emoji+label) so a stat can never ship without a definition. Labels are written to be self-clear (e.g. “total emoji used”, “total messages”) so the glossary is a backup, not a requirement. A text fallback covers a V2 send failure. Accuracy + de-fluff pass (owner steer): a banger is now a post that hit the wall bar (10+ 😂 OR 😭 on a single emoji, utils.engagement.BANGER_*), not a combined-laugh heuristic; badges are tightened to a genuine top-10% bar (_top q=0.90) + each carries a how shown in the explainer (no more arbitrary top-quartile tags), and the games/bookie badges (Sniper/High Roller) are dropped; the canned archetype line + the arbitrary chase are cut from page 1 (the memory hype-up replaces that “who is this” role, follow-up); 🎮 /guess wins + the bookie record are dropped from the card in favor of 🏆 server rank (Card.rank_by_reactions, “#N by reactions pulled”) — those game surfaces are too thin right now, lean into the Discord metrics, add them back when real; and a left-the-server member resolves to a real name via db.names_for_user (the leaderboard’s identity map), never a bare “someone”. Server rank rides the HERO (first page, owner steer) — a _render_hero standing line off card.rank_by_reactions, surfaced up front (it also renders on the activity/career detail tab). /guess wins was pulled BACK off the hero (owner steer: too thin for the first page) and now lives on the activity/career detail tab — “where the rank was” — so it’s still on the card, just not the headline (tape["game_wins"] → the career group’s 🎮 /guess wins line, glossary-defined). Nav arrows render as emoji — the ◀️/▶️ page buttons (card + rubric views) carry the FE0F variation selector, since bare ◀/▶ (U+25C0/25B6) render BLANK as Discord button emoji on some clients (the malformed-nav fix). Memory hype-up (#570): the card HEADER (next to the avatar, the “who is this” role the cut archetype used to half-fill) carries a short Toots-voice scouting line generated from her long-term MEMORY of the member. To make it genuinely TRUE it pulls everything she knows about the person across all of memory: cogs.playercard._generate_blurb FTS-searches her DAILY memory of them by every alias they’ve gone by (rename-proof over names_for_user), fetched CHRONOLOGICALLY (db.search_daily_notes, daily-tier only) so it reaches the guild’s FIRST day — then condenses each note to just the sentences that NAME them (the pure, unit-tested pc.about_member_text: word-boundary, NFC, case-insensitive sentence extraction), dedups recurring lines, and SPREADS the kept notes EVENLY across their whole arc (pc.spread_evenly, anchored on the first) to fit the budget. A daily note is about the WHOLE room, so a person is usually a line or two buried in ~13k chars; extracting only the about-them sentences collapses that to pure signal, and the even spread lets a fixed char budget (_BLURB_MAX_CHARS) reflect the member’s ENTIRE history — from their first days to now — instead of only the densest recent stretch (denser for a heavy regular, broader for a light one). Daily-only on purpose (owner steer): the hour-grained tier skews to today’s un-rolled notes, and the daily rollup already synthesizes the hourlies into the daily note. The deduped, evenly-spread block goes to claude.player_blurb, told to build the line from the sharpest, most specific receipts in the notes, to prefer the NAMED + grounded over a vague/loud label (the “Lizz not a streamer” lesson — when the notes are mixed/loaded on a detail, take the grounded version, not the loudest), and to skip a generic role-label opener; fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII, others named are just context); returns “” / EMPTY on too-thin memory. The consented per-user user_facts store is deliberately NOT read here — those are private-to-that-user and must never surface on a public card. A blurb per PAGE (#570 owner steer “one for each page”): beyond the hero memory hype-up, each detail page (reactions/social/content/activity) carries its own short Toots-voice stat ROAST of the actual numbers ON that page (claude.player_stat_roast over pc.page_stat_block(page) — “1312 GIFs and only 22 spotify links, unhinged”; “0.3 hit rate and 7,058 left on read, you’re spraying the whole clip to land 10 bangers”). Each stat carries its glossary DEFINITION inline (pc._STAT_DEFINITION, the same STAT_GLOSSARY the 📋 explainer renders, drift-tested to cover every detail stat) so the model knows EXACTLY what every number means and never misreads one (owner steer); the roast prompt is told the parentheses are context, not fodder. The hero + all page roasts are generated concurrently (_generate_blurbs) as one bundle ({"hero": ..., "<page.key>": ...}), each leg independently fail-open. The card blurbs run on OPUS (_BLURB_MODEL, owner steer: cost no object, a gift to members) — a blinded dry run found Opus pulls noticeably deeper, more specific receipts from the full memory than Sonnet; low-volume + durably cached, so the cost is bounded, and one knob flips it back to Sonnet. Opus EMPTY-parse fix: Opus sometimes THINKS OUT LOUD (“EMPTY\n\nWait, there’s plenty here.\n\nstats.py, the mod-only /stats engagement SEED builder + updater (#570) — the /remember analogue for the player-card stats. A scan over the configured MEMORY channels (the curated “real conversation” set the long-term-memory writer reads, db.get_memory_channels; owner steer) and their threads (active + archived public, rolled up to the parent channel for the per-channel count but scanned as their OWN flow sequence so a thread isn’t interleaved with the main timeline), walked CHRONOLOGICALLY (history(after=, oldest_first=True, limit=None) — no per-channel cap, Discord’s history is already bounded), tallies per-user engagement via the SHARED utils.engagement ScanState and writes the durable member_engagement seed /card reads — so the card’s numbers are ALL-TIME + instant (no per-call scan). Oldest-first is what lets the flow stats (reply latency, closer/reviver/burst) and reply attribution resolve in order (a COMPLETE in-scan msg→author map, no dependence on Discord embedding the replied-to author). TWO MODES so the seed STAYS FRESH without re-walking all of history every time (#570 owner steer — “catch up the latest, don’t rebuild the past; users expect stats to stay updated”): catch-up (the DEFAULT) scans only the messages since a per-guild snowflake WATERMARK (the id of the newest message a prior scan covered, stored in the settings KV) and FOLDS them into the stored totals via the engine’s additive merge (merge_into_state → EngagementAcc.merge), upserting just the active members (db.upsert_engagement) — cheap, the call an hourly/daily writer will make; rebuild (rebuild:True, or implicitly the first run / a legacy seed with no merge-state) re-walks ALL history and rebuilds the seed — the canonical recompute that makes the few boundary-approximate stats exact again + backfills a newly-added stat key across history. The rebuild is RESUMABLE (the shared utils.resumable_scan spine, #570): the owner’s server is ~300-400k messages (one channel, #habibis, is 90k+), so a whole-server scan that holds everything in memory and writes ONCE at the end runs far longer than the gaps between redeploys (the bot auto-deploys on every merge to main) — a run can be killed before it writes a single row, and the prior all-or-nothing rebuild did exactly that (1.5h+ in, seed still empty, then a deploy killed it). So the rebuild folds the memory channels ONE AT A TIME into the seed via the same additive merge (_scan_one → _merge_channel_rows, bounded memory: one channel’s scan in flight, not a whole-server map) and CHECKPOINTS each channel done ATOMICALLY (db.upsert_engagement_and_setting writes the folded rows + the resume checkpoint in ONE transaction, so an interruption between the fold and the checkpoint can’t re-merge a channel and double-count). A fresh rebuild clears the seed first so the per-channel fold rebuilds from empty (guarded: if read access to EVERY memory channel is gone it KEEPS the existing seed rather than wipe it — _any_readable, reason=no_access); a resume keeps the partial seed and skips the done channels. Recovery is automatic: cog_load kicks _resume_on_boot, which picks up any guild whose rebuild checkpoint is unfinished (the deploy that interrupted it is exactly what cancelled it) and continues from the next unfinished channel — so a redeploy/restart/OOM mid-scan never restarts from zero, and the seed accretes channel by channel across however many deploys it takes (a snagged channel is left not-done so a resume retries just it). A catch-up is provably == a rebuild applied incrementally on the additive stats (tests/test_engagement.py); a handful of boundary stats (cross-window reply resolution, conversation flow, reply latency, left-on-read) go slightly approximate between rebuilds, and first_of_day is explicitly guarded against the per-run double-count (the catch-up ScanState(opened_through_ordinal=) suppresses re-crediting the watermark day’s opener — else it inflates every run). Re-running /stats while one is in flight STOPS the prior run and starts over (a per-guild in-flight asyncio.Task map → cancel-restart; never two concurrent scans, which would double the rate-limit pressure). Each member’s row is {"display": blob, "state": merge-state} JSONB (so adding a stat never needs a migration; db.get_engagement unwraps the display, db.get_engagement_states the merge-state; a legacy bare-display row has no state → forces a rebuild), and embed-fixer webhook reposts fold onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s). Runs in the background (acks immediately, drops a server rundown when done), mirroring /remember’s backfill; mod-gated (a rebuild is heavy). Every author with at least one message is seeded (no min-messages floor — just skip genuine bots). Both writes are guarded so a transient blip (a readable channel erroring mid-walk, an empty scan while a seed exists, or a fresh rebuild having lost read access to every memory channel) leaves the existing stats untouched. Observability (a big rebuild outruns Discord’s ~15min interaction-token window, so the ephemeral followup silently dies — the SCAN must be visible in the LOGS): _build logs the START + the OUTCOME (the rundown text, always — so a long/snagged run is in Railway even when the Discord reply is suppressed), _scan logs per-channel msg + thread counts, _walk logs a heartbeat every 10k msgs (a huge channel shows progress mid-walk) and logs a snag LOUDLY (which source errored, at WARNING — not the old silent DEBUG); _deliver falls back from the (expired) ephemeral followup to a plain channel message so the mod still sees the result; and the snag/empty/no-access NON-write paths now emit stats_seed with ok=False + reason (snag/empty/no_access), so a rebuild that wrote nothing is a structured event, never silent. Emits stats_seed (mode=rebuild |
catchup | backfill, trigger=manual |
scheduled, ok, reason). Scheduled daily catch-up writer (#957, the automation half of the living seed): a @tasks.loop(time=09:00 UTC) (daily_catch_up, started in cog_load) folds the settled new activity into every configured guild’s seed once a day, mirroring cogs/memory.py’s writer — so the leaderboards/cards read current numbers with no on-demand scan. It folds with a 24h SETTLE-LAG (before_cutoff = a now-24h snowflake threaded through _catch_up→_scan→_walk’s history(before=)): reactions on a post keep landing for ~a day and a catch-up freezes a message’s reactions when it scans it, so a message is folded only once it’s >24h old (an hourly fold would freeze ~0 reactions; the watermark advances only to the settled boundary, leaving the fresh tail for the next day). It’s catch-up ONLY (never an unattended rebuild; a guild with no seed waits for a mod’s first /stats), shares the _builds in-flight map (a scheduled run and a manual /stats never scan a guild concurrently; a manual run cancel-restarts a scheduled one), is master-kill-switch gated + fail-open per guild. The MANUAL /stats catch-up keeps no lag (scans to now on demand); a rebuild re-reads everything current. Daily engagement SNAPSHOT (#1017, the windowed-stats layer): right after the scheduled fold (_snapshot_after_fold in _run_scheduled, idempotent per UTC day) it freezes the now-fresh seed into engagement_snapshots via db.snapshot_engagement — a ~1-line server-side INSERT ... SELECT FROM member_engagement copy, NOT a re-scan — so the /leaderboard stats period: weekly/monthly boards compute their window by SUBTRACTION (current seed − snapshot from N days ago, via utils.engagement_window). A cog_load bootstrap (_bootstrap_snapshots) takes the FIRST snapshot for any seeded guild with none yet (so windows aren’t empty until the next daily run); the daily loop also prunes snapshots past _SNAPSHOT_RETENTION_DAYS (40, monthly + buffer). One daily snapshot serves BOTH weekly + monthly — no extra scan jobs. Emits engagement_snapshot. /stats period:[week|month] backfill (#1017, the “seed the weekly NOW” path): the forward-looking daily snapshots mean a window is only accurate once N days of them accrue (weekly in ~7d, monthly in ~30d). The period: arg seeds it immediately: it scans the last 7/30 days, then RECONSTRUCTS the snapshot as of the window’s start as (current seed − the window's scanned activity) and stores it stamped at today−N (db.backfill_engagement_snapshot, a past-dated insert into engagement_snapshots), so the board’s current − snapshot reproduces exactly the scanned window. The reconstruction is the WINDOWING APPLIED IN REVERSE — engagement_window.reconstruct_snapshot is literally window_display/window_state over (current, scanned_delta), so the same diff the board applies forward yields a snapshot whose forward diff is the delta (round-trip proven in tests + validated against the live 180-member seed: 3,735 counter pairs + 1,194 graph edges, 0 mismatches). It writes ONLY snapshots — the all-time member_engagement seed is never touched (owner constraint) — and REFUSES while a scan is in flight (rather than cancel-restart like a rebuild/catch-up) since it reads the current seed to subtract from. Run it per window (period:week seeds today−7, period:month today−30); the rundown reminds you to run the other. Emits stats_seed (mode=backfill) + engagement_snapshot (trigger=backfill). |
awards.py, the weekly + monthly engagement AWARDS (#1017): a Player of the Week (Sundays, a single award but the SAME full fanfare as the monthly — owner steer “Player of the week should get the same fanfare as the others, the only difference is it’s just that single award”) and a FULL monthly gallery led by the {Month} MVP (e.g. “June MVP”, the 1st), posted in Toots’ voice to the dedicated awards channel (a single-channel /menu picker on the “where she posts” page, awards_channel_id in the settings KV). Each award is the TOP of a WINDOWED board — the live seed minus the snapshot from 7/30 days ago (window_display/window_state), ranked by the same windowed counters / closest-pairs the leaderboards use (no live scan, no new table; rides #1021’s windowing + #1033’s backfill). The pure winner-selection (registry + dedup) is utils.awards; this cog is the I/O (schedule, windowed reads, name resolution, the claude.awards_post voice call, the champion-card image edits, the send, durable state). Every award is engagement/activity based (owner steer): weekly = Player of the Week (most engagement = reactions_received+replies_received), a single crowning that gets the SAME celebratory host-intro fanfare as the monthly (awards_post(single=True) — owner steer; the only difference is it’s one award, not a stripped-down “light” post); monthly = a gallery led by the {Month} MVP (the SAME engagement metric, titled "{month} MVP" off the recapped month — “June MVP” — via AwardSpec.title’s {month} template) + Funniest (laughs) + Besties (the duo pair). (Weekly + monthly headline are one award/metric, just titled per cadence.) There is NO OVR / “Player” award (owner steers “no player of the month just week, that’s mvp” → then “use same metric for mvp weekly”): OVR is stable (“ovr doesn’t vary too much”), so it makes a dull award — it stays the /card rating + the card leaderboard, NOT an award. The philosophy: awards reward what you DID this period (engagement / comedy / the closest duo, all of which move week to week + are further varied by the per-category rolling dedup), while the card shows your overall standing (OVR). bangers was REMOVED as an award entirely (owner steer “we aren’t doing banger awards anymore”; it overlapped MVP/Funniest) — the AwardSpec, its bangers metric/_FIELD_LABEL/_detail branch, the _CARD_BODIES card prompt, and its tests are all gone; the separate bangers LEADERBOARD board (a different surface) is unaffected. The player/OVR + breakout AwardSpecs were removed entirely (owner steers “no player of the month, it’s player of the week and month mvp”; breakout was never live), and with them the now-dead window_ovr + alltime_ovr inputs — the awards no longer compute or read an OVR board at all (so the cog no longer depends on the PlayerCard cog). One @tasks.loop(time=noon ET) daily tick (_POST_AT, a DST-aware ZoneInfo("America/New_York") time so it stays 12PM Eastern year-round — owner steer; noon ET is 16-17:00 UTC the same calendar day, so the UTC-based _due_period/_token date logic is unaffected) decides the cadence — the 1st of the month posts the monthly gallery and supersedes that day’s weekly, every other Sunday posts the weekly. The post is a delightful gold-chromed multi-embed GALLERY (owner steer “polish + chrome, delightful, no em dashes” + “draw images for them all individually”): one gold embed per award stacked into a gallery — the FIRST carries the TOOTSIES AWARDS banner + Toots’ host intro (claude.awards_post, Sonnet, a short hosting hook, NOT an award list — the gallery shows the awards) + the headline winner + stat; each subsequent embed is a clean labeled trophy card (emoji title + winner + stat). A CHAMPION CARD per board champion (owner steer “remix an image of their pfp winning, each type in the prompt, a besties image for the pair”): every winner’s Discord avatar is remixed into a trophy card themed to that board via image_gen.edit (_CARD_PROMPTS per key = themed body + the shared _CARD_TEXT_RULE — crown / reactions-raining / comedy / duo). Maximalist style (owner steer “the maximalist emojis and gloss and fireworks is what i want”): the cards are deliberately glossy, badge-heavy FUT/2K-style trophy cards — the rich vibe descriptions in _CARD_BODIES are what the image model renders as the baked title + flair (fireworks, medallions, glaze badges). A “de-corny”/minimal-nameplate/textless detour was tried and reverted — minimal was the wrong direction. {award} is baked as the card title, so the on-card title is always the CURRENT award name. _CARD_TEXT_RULE (owner steer chain: “don’t spell out exactly the metrics” → meme-GLAZE badges (GOAT/ICON/LEGEND) → “remove the meme glaze” → “why does it say june mvp CHAMPION not june mvp”): the ONLY text on the card is the award title itself, spelled EXACTLY, rendered big + glossy as the centerpiece — NO word-badges at all (no metric/stat labels, no glaze/hype words, no slogans, no speech bubbles), and no extra word appended to the title. The “champion” leak was the MVP body wording (a {award} champion card put “champion” right after the title, so the model baked “JUNE MVP CHAMPION”); the bodies now say a {award} card and the rule POSITIVELY pins the exact string (a forbidden-word list is deliberately avoided — naming a word can summon it in an image model). The maximalist hit comes from the visuals (fireworks, floating emoji, crown, trophy, laurels, gold gloss), not text — so the bodies are pure visual direction with no vibe phrases that bake as badges. Besties crowns each person. The MVP card featuring the winner’s SIGNATURE reaction emoji (their most-pulled unicode emoji from the seed’s reactions_by_emoji, threaded in via _card_hints; owner steer “mvp is reactions+replies not most-loved, use their most reacted emoji as input”), and the besties card compositing BOTH pfps side by side (_compose_pair) into one duo portrait; generated concurrently (asyncio.gather), cached per (guild, period-token, award, winners) so a PRODUCTION re-post reuses them (a STAGING audition passes use_cache=False — it neither reads nor writes the card cache, so a preview never warms it for a real post nor is served a staged card; part of staging being fully stateless); fail-open per card (no image client / no avatar / edit miss → that embed falls back to a plain card / the hero a thumbnail). The winners are @-tagged once on the content line (the only place a ping lives, since an embed mention never notifies — allowed-mentions users-only; a crowning, distinct from the no-ping leaderboards). Two dedup layers spread the spotlight (owner concern “will uhlant just win every other week” + “win one per month / one every 4 months”): cross-category (a single person is never crowned twice in ONE post — keys order is priority, so the headline (Player weekly / MVP monthly) takes the top and later awards fall to the next distinct one; the pure taken set in utils.awards) + rolling anti-repeat, PER CATEGORY (the cog feeds recent_winners: the last _WEEK_MEMORY/_MONTH_MEMORY=3 winners OF EACH AWARD are skipped, so a person can’t repeat the SAME award for ~4 periods but CAN win a DIFFERENT one — owner steer “for monthly don’t be so strict on any-award dedup, maybe you can’t win THAT award”, relaxing an earlier global “any monthly award locks you out of all” rule; stored as a trimmed history list in the settings KV). Idempotent per ISO-week / per-month so a restart never double-posts. Gated by the awards experiment (default staging, mirroring live_scores/market_drop): production posts the gallery to the awards channel, staging auditions the SAME gallery in #bot-logs (room stays quiet, via bot_logs.staging_header + bot_logs.post(embeds=, files=), no pings) so a mod QAs winners + voice + cards on real data, off skips the loop entirely. Plus the master kill switch + mood != off; production also needs the awards channel set on /menu. A mod /awards period:[week|month] posts on demand (preview/manual; respects the stage — staging→#bot-logs, production→awards channel, off→blocked; bypasses the idempotency skip; only a PRODUCTION post records state — a STAGING audition is a pure preview that records NOTHING (no idempotency token, no anti-repeat history), so a mod can re-audition the same period freely without consuming its token (which would block the real scheduled post) or biasing the dedup, owner steer “i can’t audition if it’ll dedup”). Per-award memory HYPE-UPS (owner steer): each award embed carries a 2-3 sentence Toots-voice appreciation of the winner(s) woven from her THIS-PERIOD memory of them + their receipts — claude.awards_hype (purpose awards_hype), generated CONCURRENTLY per award (_generate_hype → _one_hype over asyncio.gather). _one_hype gathers three grounding sources: _winner_memory (the about-them sentences from the period’s DAILY notes — rename-proof over every alias via match_forms + db.search_daily_notes, scoped to span_end >= cutoff, deduped + spread_evenly across the window, reusing the /card hype-up’s about_member_text/spread_evenly core), _winner_receipts (each winner’s top-reacted message — the actual words behind the numbers, from the seed’s top_post_text/top_post_reactions), and for a DUO _duo_crosstalk (a bounded live scan of the memory channels for messages where the two mention/reply to each OTHER — their back-and-forth, the context memory may not capture). Fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII), fail-open per award (a miss → that embed just shows no hype line). Runs on OPUS by default (the awards are a gift — give them the best voice; a blinded dry run found Opus pulls noticeably sharper, more specific receipts than Sonnet), but the model is a per-guild /menu Models-page knob (awards surface, MODEL_SURFACES, opus/sonnet/gpt/gpt-cheap) via _awards_model so a mod can A/B the GPT-frontier voice. Fail-open throughout (no seed/snapshot → a “run /stats period:” note; a quiet window → no post; a voice/send miss → a soft status). Emits awards_posted (delivered=room |
staged, cards=# themed images rendered, hypes=# hype-ups that generated — a silent-degradation guard: hypes=0 with winners means the memory/model came back empty across the board). Pure core unit-tested in tests/test_awards.py, the schedule/format/embed/compose + hype-receipts helpers in tests/test_awards_cog.py. |
curator.py, the content curator (#curator): Toots keeps a themed room fed with fresh on-theme links, like a tasteful member. A curator channel (its OWN curator_channels set on /menu’s content-curation sub-page — reached by the 🖼️ set up curation button on the behind-the-scenes page, split off so that page stays under the ~5-select cap — separate from feed/discourse) is self-sourcing: she reads the accounts already recurring in that channel’s own history (utils.curator.rank_seed_accounts over the X/TikTok links there, excluding her own posts — the self-feedback guard), pulls their FRESH posts from the source (X via utils.twitterio.XProvider + TikTok + Instagram via utils.scrapecreators.TikTokProvider/InstagramProvider, provider-agnostic behind utils.source_providers.SourceProvider/SourcePost; #1294 the compose loop FANS OUT over every provisioned source — _platform_candidates derives each platform’s own seeds/adjacency, then interleave_candidates merges them into a FAIR judge slate (round-robin per platform, so a big-count TikTok batch can’t crowd X out of the judged top slots); a channel with no seeds for a platform just doesn’t fetch it. NO engagement PRE-FILTER (#curator, owner steer — “give the model more to determine based off everything vs prefilter on engagement, that isn’t working”): an earlier self-calibrating p25 batch floor sorted purely by engagement, so a channel’s single top-pulling account filled the WHOLE vision slate and the judge could only ever re-pick it — which (as a learned seed) re-promoted it into a self-reinforcing pin (prod: 13/14 of a car channel’s posts were one account). Now the only pre-judge gates are STRUCTURAL (dedup + original-with-media), and the per-platform slate is DIVERSIFIED across accounts (utils.curator.diversify_by_account, a round-robin over each author’s engagement-ranked posts, the account-level analog of interleave_candidates’ platform fairness) so the judge weighs VARIED accounts and decides on FIT — a fresh, on-theme, low-engagement post now reaches it instead of being cut for not out-pulling the biggest account (live-verified: a real car-channel pool went from an auto_porn-dominated 4/6 slate to a balanced 2/2/2 across three accounts). TikTok’s/Instagram’s “repost” endorsement analog is the collab (SourcePost.endorsed_handles, the co-creator handle free from collab_info / IG coauthor_producers), folded into the same 3×-weighted adjacency signal as an X repost; IG exposes no follow graph OR repost feed, so IG adjacency leans entirely on the coauthor collab (InstagramProvider.fetch_followings returns []), and its post ids are the shortcode so a channel-URL dedup matches parse_external_id), keeps every original-with-media post (dedup only — no engagement pre-filter, see diversify_by_account below) — INCLUDING a seed’s REPOSTS (#curator, owner steer “reposts are high quality”): is_postable no longer drops reposts, and both X providers (utils.twitterio.tweet_to_post + the ScrapeCreators backup sc_tweet_to_post) now RESOLVE a retweet to the ORIGINAL post it endorses (the original’s media/link/author/id/engagement drive posting + dedup + diversify, is_repost/reposted_handle kept for adjacency; the backup’s nested result carries no url so the permalink is constructed from author+id) — and a QUOTE tweet the same way when the seed added no media of its own (the visual content is the quoted original; a quote WITH the seed’s own media keeps the seed’s post) — an explicit endorsement is high-signal on-theme supply the curator used to throw away, so an aggregator seed that mostly RETWEETS (the #her hot-photo case) now contributes candidates instead of nothing (a media-less text RT is still dropped on an image channel; the fit-judge still gates every pick; curator_posted.via_repost marks a repost-sourced pick) — and hands an account-diversified slate to a fit-judge (a Haiku call comparing each candidate to the channel’s OWN recent posts, so the channel’s precedent sets the bar, no imposed SFW rule — owner steer, #her isn’t SFW), dedups against a durable seen-set (curator_seen, never repost a source id, restart-proof), and posts the winner’s source LINK rewritten to its embed-fixer mirror so Discord unfurls the media (X→fixupx, IG→kkinstagram, TikTok passes through — it unfurls natively; to_fixup_link via the shared _FIXUP_HOSTS; she never uploads raw media — exactly how #her/#art already post). Rides the shared ScheduledPoster spine (master kill switch + mood-OFF + calendar + per-channel slot pacing + text dedup + 429 retry) and the per-guild curator experiment (STAGING auditions each pick in #bot-logs so a mod QAs it’s on-theme; PRODUCTION posts to the room; OFF skips the guild). Calendar-managed via /menu (#curator follow-up): curator is a first-class calendar surface (utils.schedule_calendar.SURFACES + cogs.calendar_view), so its cadence is set on the /menu calendar like discourse/music/market_drop/bet_board; it falls back to its CHILL_TIMES/YAPS_TIMES pool (chill 2pm/8pm, yaps 12/4/7/10pm ET) only when a mood is NOT calendar-managed. (Note: a guild that already had a managed calendar before this must assign curator slots on /menu, else the managed mood posts it zero times — the standard new-surface behavior, same as bet_board.) Provisioning-gated on TWITTERIO_KEY (unset → bot.xprovider is None and the loop no-ops); fully fail-open. Pure seed-ranking + candidate filtering live in utils/curator.py (unit-tested tests/test_curator.py); the twitterapi.io client is guarded like SportsGameOddsClient (rate limiter + circuit breaker + retry). X-source FAILOVER (#curator resilience): when SCRAPECREATORS_API_KEY is ALSO set, the X source is wrapped in a utils.scrapecreators.FailoverProvider — the twitterapi.io XProvider primary + a BackupXProvider over ScrapeCreators (/v1/twitter/user-tweets), routed to the backup only while the primary’s breaker is open (XProvider.degraded), the SGO→Odds-API backstop pattern. So a twitterapi.io crater (datacenter-IP block / credit drain / outage) no longer takes X curation dark. Full SourcePost coverage incl. reposts (retweeted_status_result → reposted_handle), so repost-based adjacency survives an outage; ScrapeCreators has no X follow-graph endpoint so fetch_followings returns [] on the backup (adjacency degrades to the repost path). The façade is a drop-in bot.xprovider (cog unchanged); bot.xprovider_primary keeps the twitterio usage poll pointed at the primary. curator_fetch is stamped provider=twitterio|scrapecreators so integration-health attributes each; ScrapeCreators is a prepaid wallet (usage poll off /v1/account/credit-balance → scrapecreators:credits quota + the scrapecreators:budget balance-floor ops finding + a /debug/integrations probe). ScrapeCreators is the multi-platform seam (X/TikTok/YouTube/Instagram + transcripts, one SCRAPECREATORS_API_KEY) the broader curator + /ask cross-platform work builds on. Emits curator_fetch (the twitterapi.io read) + curator_posted. Phase 1.5 — image vs TEXT mode (#curator): the fit-judge is chosen PER CHANNEL from the channel’s own media density (utils.curator.prefers_media, ≥CURATOR_MEDIA_MODE_RATIO=0.6 of recent posts carry an image → IMAGE mode): an image channel (#her/#art) requires media + the vision judge (claude.pick_curator, candidate images vs the channel’s example images — plus each candidate’s CAPTION (#1294 follow-up): the vision judge weighs the image AND its words together, so an on-vibe-looking image whose caption is an ad / off-theme / engagement-bait is rejected on the text a picture-only judge would miss, live-verified that swapping only the caption flips the pick); a link/text channel (#man: cars/menswear/cool finds) drops the media requirement + uses the text judge (claude.pick_curator_text, candidate captions vs the channel’s recent post text + topic, no vision) so bare text/link posts curate too. Self-calibrating like the p50 engagement floor (no per-channel UI; the ratio is env-tunable, empty channel → text mode, the safe superset that still judges fit). The seed-harvest window is 30 days / up to 400 messages (_HISTORY_DAYS/_HISTORY_LIMIT, widened from 21/200): a low-volume curated channel’s 200 messages span well past 3 weeks, so the day window was the binding limit — a wider window keeps a seed account live longer, so a mod posts less often to keep her fresh (owner steer). Phase 2 — adjacent-account discovery (#1232): the taste self-WIDENS beyond the accounts already in the channel. Each slot fetches a 3:1 CURRENT:NEW mix across reserved tiers (utils.curator.pick_fetch_pool): 2 proven in-channel seeds (recency) + 1 learned seed + 1 fresh discovery. Discovery crawls each top in-channel seed for who it FOLLOWS (XProvider.fetch_followings → /twitter/user/followings, (handle, follower_count)) AND who it REPOSTS (the reposted author rides along on the seed’s recent timeline, tweet_to_post → SourcePost.reposted_handle, so no extra endpoint). rank_adjacent_accounts blends both by cross-seed count, weighting a REPOST far above a FOLLOW (#1251, owner steer: score = REPOST_WEIGHT*repost_cross + FOLLOW_WEIGHT*follow_cross, default 3:1 — a repost is explicit content endorsement, a follow is passive/broad), follower count as the final tie-break. A repost also surfaces an account NO seed follows (widening discovery beyond the follow graph). The ranked adjacency is durably cached per channel (DurableCache namespace curator_adjacency, ~3-day TTL) so the metered crawl runs ~once per few days, not per slot. Growth (the “grow the garden” mechanic, owner steer): because seed derivation ignores Toots’ OWN posts (no self-amplification loop), a discovered account can’t become a seed via her re-reading her posts — so an adjacent account that produces a SHIPPED pick is promoted to a durable learned seed (curator_learned_seeds, _maybe_promote), which fills the “learned” tier on future slots, so discovery COMPOUNDS. The vision fit-judge still gates every post, so an off-theme discovery is just skipped. curator_fetch gains phase=followings; curator_posted gains source = in_channel |
learned |
adjacent (watch the adjacent/learned rates to confirm discovery is working + growing). Full observability (#curator ops): the fail-open surface used to return None at 7 points with no signal, so a curator going quietly DARK (fetching fine but posting nothing because the judge rejects everything / the channels stopped being fed) was invisible. curator_evaluated now emits WHY on every no-post slot (no_seeds/no_history/no_posts/no_candidates/none_fit) + the mode; with curator_posted (which carries source + mode) it forms the ship FUNNEL. The ops-monitor renders a Curator health line (post rate + skip-reason breakdown + source/mode split) every run and flags curator_dark when she’s shipping ~nothing over enough attempts — HIGH when none_fit-dominated (judge/seeds off-theme, users see silence), MEDIUM when supply-dominated (no_seeds → post links). The twitterapi.io integration crater stays self-healed by the health cog (#1257); a content-SUPPLY dark is a human nudge (post links), so it’s an ops-monitor finding, not an autonomous fix-order. The text fit-judge is guarded by scripts/eval_curator_pick.py (registered in run_evals; picks the on-theme candidate over off-theme noise, golden = an off-theme post a healthy judge must SKIP; the vision judge shares the pick_gif path eval_gif_pick already covers). |
curate.py, the curate-feed surface (#curate): the OWNER-DIRECTED, calendar-driven twin of the autonomous curator.py loop. A mod sets one or more X handles on /menu (the content-curation sub-page’s ✎ curate feed button — reached via the 🖼️ set up curation button on the behind-the-scenes page → a modal, stored per-guild in the curate_accounts settings KV; the modal validates on submit by FETCHING each handle — MenuView._validate_curate_handles calls bot.xprovider.fetch_recent, so a typo/empty handle is rejected with a “couldn’t pull @x” note and never saved, and an all-typo edit never wipes the existing list, a deliberately blank field still clears; unprovisioned X → accept as-is); this scheduled surface then rides the curator’s /menu calendar cadence (ScheduledPoster subclass, SURFACE="curate"; _resolve_schedule overrides the calendar-lookup key to curator, so there is NO separate calendar row — a mod configures the calendar ONCE for the curator and both surfaces follow those slots; owner steer). On each due slot, per curator channel, it pulls the configured accounts’ recent posts + reposts (bot.xprovider.fetch_recent, reposts already resolved by utils.twitterio to the endorsed original’s media/link, memoized per tick so the per-channel composes share one pull), filters to a recency window (via the timeline ACTION time stamped onto SourcePost.extra["created_at"] by the X mappers — a repost’s boost time, not the original’s) + drops replies + drops anything already curated, then BEST-HOME arbitration routes each fresh post to its SINGLE best-fit room across ALL the curator channels FIRST (claude.route_curator, a Haiku best-home classifier computed ONCE per tick + shared, memoized in _route_cache), so a room only ever considers the posts that belong to IT — an art photo #art would claim can’t be grabbed by #her (which, judged in isolation, takes the least-bad option in its slate: the #curate misroute fix). Among the posts routed to a channel it DRAINS every eligible one, best-first (owner steer — “curate all eligible recents vs slow drip”), not just the single best: the due slot is only the TRIGGER; Curate._maybe_post_to_channel overrides the ScheduledPoster one-post-per-slot handler to LOOP the curator’s OWN proven, eval’d fit-judge (claude.pick_curator for an image room, pick_curator_text for a link/text room, image-vs-text mode derived from the channel’s own media density like the curator) + deliver — each compose picks the best UNSEEN eligible post + marks it seen, so the loop slides through the pool best-first (O(N), since each compose judges only the freshest _VISION_CANDIDATES) until the judge declines or nothing eligible remains. Bounded by _MAX_DRAIN_PER_SLOT (20, env CURATE_MAX_DRAIN_PER_SLOT) per slot — set to cover a full fetched page (~20/account) so a single account’s page drains in ONE slot (“at least post the first page”, owner steer; below the page size the tail was left for the next slot, where a fast account had already pushed it off page 1 → missed). The best-home route cap (_ROUTE_MAX, 40, env CURATE_ROUTE_MAX) is likewise >= the page so every fetched post gets a home decision (below it, the oldest fetched posts were silently unroutable → never eligible). Only the fit-judge filters now, not these caps. A multi-account / firehose overflow still BATCHES across slots — the durable curator_seen dedup makes carrying the remainder to the next slot safe (nothing re-posts), and discord.py’s own 429 backoff means a batch never drops a post to rate limits (a small _POST_GAP_SECONDS courtesy gap keeps it gentle). The per-channel history read (_channel_context) is memoized per tick (_ctx_cache) so the drain’s repeated composes share ONE read. A post routed to no room (fits none) is dropped from every room; a slot with nothing eligible/fitting skips with curator_evaluated reason=no_home. It posts each drained post’s embed-fixed link (cogs.curator.to_fixup_link) prefixed with via @<account> (the CONFIGURED source account, stamped onto SourcePost.extra in _account_posts — for a repost the reposter’s feed, i.e. “which curate feed fed this”, not the original author; pure _attributed_line, unit-tested), so a curate post is visibly distinct from the autonomous curator’s bare links, and marks it seen. Known bound: the fetch is one page (~20/account, no pagination — the twitterapi.io per-page cap), so a firehose account posting 20+ between sparse slots can have middle tweets scroll off page 1 before a drain catches them; closing that needs pagination (metered by tweet volume) or denser slots. Dedup is SHARED with the curator (curator_seen, platform x), so a tweet lands in exactly one room and never double-posts (across both surfaces). Gated by the CURATOR’s experiment (owner steer — one toggle for both: STAGING auditions each pick in #bot-logs so a mod QAs the art/man routing; PRODUCTION posts to the room; OFF skips), its own de-facto on/off being simply whether an account is configured (no accounts → _sched_extra_gate skips it regardless of the curator stage). It keeps its own curate_slots pacing counters but SHARES the curator’s calendar cadence (no separate calendar row) AND the curator’s experiment (no separate experiment). Master-switch + mood gated like every proactive surface. Provisioning-gated on the X source (bot.xprovider) + a configured account. X-only: TikTok/IG reposts + bookmarks (and X bookmarks) are NOT a supported source — ScrapeCreators has no reposts/favorites endpoint and bookmarks are private. Pure timestamp/window/handle helpers (normalize_handle/parse_handles/parse_tweet_time/within_window) live in utils/curate.py (unit-tested tests/test_curate.py); the cog is the scheduler + provider + Haiku I/O. Fail-open throughout. Fully observable (#curate ops): emits curate_run (a post landed) + curator_evaluated source=curate_feed (a no-post slot, with the reason: no_home/none_fit/pick_error/no_candidates/no_posts) + curate_routing (the per-tick best-home DROP funnel: candidates/routed/dropped) + an error on every failure path so nothing fails silently (source=curate_route a route-classifier API failure, curate_drain a compose blip mid-drain, curate_deliver a room send failure, curate_staging a #bot-logs audition failure); the ops-monitor renders a Curate-feed health line and flags curate_dark when it ships ~nothing (its own funnel, kept separate from the curator’s so neither cross-contaminates).clipboard.py, the cross-server link clipboard (mods only): /copy channel: period: name: scoops every URL people posted in a channel over a window into a named, per-user buffer, and /paste buffer: channel: posts those links into a channel in (possibly) a different server — with an out:curate option that instead sorts the buffer’s links across the guild’s curator channels by best fit (/paste run through curation). The point is that Discord’s slash-command channel picker only lists the current guild’s channels, so a single command can’t read a channel in server A and post into server B; the buffer (keyed by user_id only, so it follows the mod across servers) decouples the two halves. Links only for now (Messages/Files/audio notes are a tracked follow-up — Discord attachments, including voice messages + uploaded audio, sit on a CDN with signed ~24h-expiring URLs, so a durable copy means re-downloading + re-uploading the bytes, not storing a link). The pure URL extraction + first-seen-order dedupe lives in utils/clipboard.py — add_urls(seen, out, text) is the incremental accumulator (the building block /copy streams history through so it never holds every message’s text in memory) and collect_urls(texts) wraps it for the batch case (both unit-tested). /copy walks channel.history(oldest_first=True) so the buffer keeps chronological order (it does NOT skip bot/webhook authors — an embed-fixer reposts a link via a webhook after deleting the original, so filtering bots would drop exactly the embed-fixed links), extracting URLs as it streams (only the link SET is held, not all message text) up to a _MAX_SCAN_MESSAGES safety ceiling — if a scan hits it the reply WARNS that older links may be missing rather than dropping them silently (no link loss). Re-copying into an existing buffer MERGES (the accumulator is seeded with the buffer’s existing links): a union, deduped, order-preserving — every prior link is kept (no loss) and a re-copy of the same window adds nothing (idempotent, a true no-op that skips the write). Stores via db.upsert_link_buffer (which replaces the row with the cog-computed merged list). /copy also accepts a THREAD (a thread copies just that thread’s messages, a channel just the channel’s own — Discord keeps their history separate), and the walk is deploy-resilient: it checkpoints to the buffer every _FLUSH_EVERY messages, so a redeploy that SIGTERMs a long scan doesn’t lose progress and the idempotent merge lets a re-run continue (the ceiling warning is oldest-first-accurate — a truncated walk keeps the OLDEST of the window, narrow + re-copy for newer). /paste posts one link per message (so each unfurls), paced (_PASTE_GAP_SECS), as the bot (channel.send, Toots’ own identity — no webhook, so pasted/curated links read like her own posts, matching the curator). /paste out:curate (_start_distribute → _run_distribute) is the curation destination: instead of dumping every link into one channel it enriches each buffer link (utils.link_enrich; social platforms, bare-URL caption fallback via the pure utils.clipboard.route_caption), routes it to its single best-fit curator channel with the curate feed’s OWN best-home judge (claude.route_curator, reused — a per-link Haiku vision call), and posts it there fixup-rewritten (cogs.curator.to_fixup_link) so X/IG unfurl. It AUTO-DRAINS: one invocation keeps going in the background until the buffer is empty, CONSUMING each link as it’s filed (rewriting the buffer to the unprocessed remainder + the un-routable, per batch of _DISTRIBUTE_BATCH) — so a re-run, OR a redeploy that kills the task mid-drain, never re-posts an already-placed link and always advances (the fix for a first-N slice that re-processed the same head; the buffer’s own consume IS the resume). A link that fits no room STAYS in the buffer; route calls run a few at a time (_ROUTE_CONCURRENCY) and a per-invocation safety ceiling (_DISTRIBUTE_BUDGET) stops a giant buffer with a “run it again” note. Targets are the guild’s db.get_curator_channels (each room’s routing vibe = its recent post texts + topic via _room_vibe). Both out paths share the _deliver_links one-per-message delivery (posted as the bot, no webhook). All run as background tasks (_spawn, with the _deliver_result followup→channel fallback) so a long scan / big paste / big sort isn’t bound by Discord’s 15-min interaction window. Mod-gated (_mod_gate). Emits links_copied / links_pasted / links_distributed.x_game.py, the X (Twitter) guess-the-song game (#1497): Toots posts an urban-90s+ song clip to the @tootsiesbar timeline on a scheduled slot, followers reply with a guess, a twitterapi.io filter-rule webhook delivers the replies (no polling — utils/healthcheck.py:/x/webhook → handle_webhook_tweets), the caption is specific — name the song TITLE (the win is the title; the artist isn’t required, just credited in the reveal). The first correct guess ENDS the round with a single board reply (a MUSIC-DROP-toned reveal of the track + a light nod to whoever called it + the running leaderboard — no fanfare); a NO-WINNER round skips the board/leaderboard entirely and just quote-reveals the answer (no “nobody got it” complaint). The winning board is posted UNDER the clip so the whole round is one thread (the round ends at first-correct, a race, not a fixed window), then a music-drop QUOTE of the clip with the answer + a one-line Toots-voice blurb (claude.song_blurb, Haiku) — GROUNDED trivia: the cog gathers real facts (_song_facts: a live Perplexity fact + the Genius reference — producer/writer/samples, citable) and the model writes the line using ONLY those facts (a real detail dropped casually, never invented; falls back to a pure vibe line when no facts are found, which carries no fabrication risk) — + the revealed album art attached when the model nominates it (song_blurb can end its line with an [art] tag, its call like the /ask <image>/<gif> nomination; parsed + stripped by parse_blurb_art so the tag never leaks; a quoted link doesn’t unfurl its player, so the cover is the visual when attached) + its Apple Music link (resolve_apple_music_url) so people can go listen. Two writes a round (board + drop), ~8 rounds/day under X’s ~17/day cap. A standalone @tasks.loop (NOT a ScheduledPoster — it’s a stateful round on an external timeline, not one Discord post per slot) reusing calendar_hours for WHEN to open. Round lifecycle: OPEN (a due x_game calendar slot + no round open → pick+verify a song, render the clip via utils.audio.make_video_clip, post it, register the to:tootsiesbar reply rule via utils.x_filter_rules.create_active_rule, persist) → COLLECT (webhook → judge each reply against the open round via the SHARED /guess matcher (cogs.games.grade_guess on HARD – exact title modulo case/punct/spacing, no fuzzy typo win; #1497 unification, not a local copy), processed OLDEST-first by X snowflake id (reply_order_key) so the genuinely first correct reply wins regardless of batch order, first correct claims the win atomically via db.x_game_claim_winner so a race yields one winner; the twitterapi.io rule polls ~60s, X_FILTER_RULE_INTERVAL_SECONDS — a game runs for hours so a tight poll just burns read credits) → CLOSE (_finish_round: the single board reply + a music-drop quote of the clip with the answer’s Apple Music link, delete the rule, mark closed — fired on the first correct guess, OR on the no-winner TIMEOUT with a “nobody got it” board. The timeout window is DYNAMIC (_round_window + the pure next_slot_gap_seconds): half the time to the guild’s next calendar slot, clamped to [X_GAME_MIN_ROUND_SECONDS 30m, X_GAME_MAX_ROUND_SECONDS 1h], with X_GAME_ROUND_SECONDS (1h) the flat fallback when there’s no calendar — so a dead slot closes with room to spare before the next round opens, capped at 1h, all env-tunable). ACCOUNT-global: one round open at a time across all guilds (the account is one @tootsiesbar); a guild’s experiment + calendar just decide when to open. Song pool is FULLY UNIFIED with the /guess game (#1497), no divergent copies: the candidate set BLENDS the model pool (claude.generate_song_pool(genre=_genre_for_model("urban"), era="1990s+") — the game’s urban hip-hop/rap/R&B bundle + 90s-onward era, proposing ‘Artist - Title’ lines) with REAL grounding off the SAME source /guess grounds off — the SHARED song_pool.current_genre_hits over the urban bundle’s lanes (hip-hop + R&B), Deezer-led per-genre chart + iTunes RSS hedge — so the set isn’t 100% model memory (which skews to the most OBVIOUS hit) and X + /guess ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before Stage 4). Each candidate is verified against the catalog via music.search_catalog (iTunes-first, Deezer fallback #371) for a real preview + artwork + a guessable, artist-matching track (_is_guessable_title + _artist_matches), gated by a RESOLVED-year era floor (_year_int coercion, >= _MIN_YEAR; a yearless Deezer row passes fail-open), and deduped by the SHARED song_pool.song_key against the SAME cross-round reuse block (game_recent_songs, 7-day window via db.recent_songs_within_days/record_recent_song) — so a song doesn’t repeat across X rounds AND the Discord game, and generate_song_pool gets the block as exclude= too (model-side avoidance). X rejects bare audio, so the clip is a still-cover-art h264/yuv420p/aac mp4 (tweet_video, uploaded via XPoster.upload_video’s chunked flow). Gated: master kill switch + mood != off + the x_game experiment (default OFF — it posts to a public EXTERNAL timeline, like x_crosspost; PRODUCTION runs the game on X, STAGING auditions the clip + board in #bot-logs with NO post to X, OFF skips). Provisioning: the 4 X_* write vars (bot.xposter) + TWITTERIO_KEY (bot.xrules, the reply webhook). Answer JUDGING is the SHARED /guess matcher (cogs.games.grade_guess, HARD), NOT a local copy (#1497 unification — the old utils.x_game.judge_guess was retired). The pure core (utils/x_game.py: reply_targets_round, reply_order_key, format_board_tweet/format_open_tweet/format_answer_drop, parse_blurb_art, apply_win, next_slot_gap_seconds) + the shared utils/song_pool.py (song_key + current_genre_hits) are unit-tested; leaderboard + rounds in x_game_rounds/x_game_scores (account-global). Fail-open throughout. Emits x_game_round + x_game_answer.Utils (in utils/):
rate_limits.py, per-user daily limits (@Toots mentions, /recap) and server-wide daily limits (/discourse, /order) + cooldowns. Defaults are module constants (DEFAULT_PER_USER_DAILY, DEFAULT_PER_SERVER_DAILY, DEFAULT_ORDER_COOLDOWN_MINUTES); per-guild overrides live in the settings KV table and are edited via /menu’s tune page (resolvers _user_cap / _server_cap / order_cooldown_window read them live).tunables.py, the central registry + live resolvers for every mod-tunable behavior knob beyond the rate trio: the working-hours window (start/end ET), the /order in-flight cap, chime-in POST cadence per mood (daily cap / cooldown), and the live sports commentary heartbeat/clutch intervals (commentary_cadence, stored in minutes, resolved to the seconds the cadence engine uses). TUNABLES is the single source of truth (the tune editor renders the non-hidden knobs); each cog reads its value via a resolver (working_hours, chimein_tuning, max_in_flight_orders, commentary_cadence) that falls back to the code-level default. Two groups are hidden=True — kept in the registry (so the resolver + default still work) but edited elsewhere on /menu beside the config they scope, NOT on the tune page: working-hours on the calendar, the commentary heartbeat/clutch intervals on the live-sports page (_CommentaryPaceButton). (The quiet-room icebreaker’s silence window is not a separate knob: it reuses the mood’s chime-in cooldown.) Engineering internals (buffer sizes, tick intervals, retry backoffs, memory gaps) are deliberately excluded — not mod-facing, and so are the chime-in / reaction score thresholds: they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants, read directly, never from a setting).permissions.py, is_mod() checks against mod_roles table. Also home of the two “master” constants: MASTER_USER_IDS (the master USER — a permanent, role-independent mod-access floor + who ops alerts @-ping) and MASTER_GUILD_ID (the master GUILD — the owner’s home “control room”, env-set via MASTER_GUILD_ID, None = unset). The master guild is (1) the default X-crosspost home (utils.x_crosspost._ONLY_GUILD falls back to it when X_CROSSPOST_GUILD_ID isn’t set to override) and (2) the sole destination for owner-facing ops alerts — the health cog’s crater/usage warnings + the new guild_join/guild_left pages route to ITS #bot-logs alone (cogs.health._alert_targets) instead of fanning out to every server; with no master set, both fall back to the pre-master behaviour (alerts to every enabled guild, no crosspost lock)sportsdata/competitions.py, the soccer-competition registry (#club-soccer) — the single source of truth for which soccer competitions the bot covers for state + settlement (SOCCER_COMPETITIONS: World Cup, UCL/Europa/Conference, EPL/La Liga/Serie A/Bundesliga/Ligue 1, MLS — each with its API-Sports league id + a two_legged flag). API-Sports /fixtures?live=all / ?date= return EVERY league worldwide in ONE call, so ApiSportsProvider now fetches ALL football once and FILTERS to COVERED_SOCCER_LEAGUE_IDS (_covered_football, fail-open on an untagged fixture), instead of the old hardcode that fetched the World Cup league only (which is why non-WC soccer couldn’t get state or finals-settlement from the primary feed). Adding a competition is one SoccerCompetition row — no per-league fan-out, no provider edit. (SGO already auto-discovers its own club-league codes; club-NAME cross-provider folding landed in names.py — canonical_soccer_club (#1469 Stage 2) — so a club bet reconciles across providers.)gates.py, require_configured() guard for pre-/menu statefeeds.py, channel history fetching for context. recent_messages(..., stt=) transcribes room voice notes in place (via voice_ingest) so they read as ordinary text everywhere downstream.voice_ingest.py, ambient STT: annotate_transcripts(messages, stt) writes a voice note’s transcript onto msg.content so every context reader (format_for_prompt, the memory writer, chime-in scorer, …) treats it exactly like a typed message, the only difference is the words came from audio. Transcribe-once via the shared two-tier DurableCache (namespace voice_stt, keyed by attachment id, failures cached so duds aren’t retried; durable L2 under a 7-day TTL so a recent voice note re-read after a redeploy isn’t re-transcribed, wired at boot via set_durable_db). No-ops without a provisioned ElevenLabs key. Wired into every recent_messages caller (ask/recap/discourse/music/order + the hourly memory writer & /remember backfill) and the chime-in tick buffer. Gating is provisioning only (the key) — voice notes are public channel content she already would have read if typed, so no separate experiment.video_fetch.py + video_ingest.py, video “listening” (she reads what a shared YouTube clip / video IS and what was said in it, not just that one was posted). The sibling of voice_ingest, but ambient AND async: a video is minutes-to-hours, so transcribing on the read path would stall every recap/tick. feeds.annotate_video(messages, stt) (a binder over video_ingest.annotate_video_transcripts, supplying feeds.video_sources) never awaits a fetch — for each fresh video it spawns a background task and returns at once; the resolved VideoResult lands in a process-wide cache and is surfaced inline by extract_media/format_for_prompt (via feeds._video_label: a metadata head — "title" · channel · 12min · 2.1M views — plus the summary or raw transcript, capped 1500 chars) on the next read (so a clip is bare the pass it’s first seen, rich after). video_fetch.fetch_video returns a VideoResult (metadata + transcript + source) by the cheapest route: captions first (pull the caption track via yt-dlp metadata, no ffmpeg/STT spend — flatten_json3/flatten_vtt; any language, falling back past English so a foreign clip is still transcribed), audio + Scribe STT fallback (yt-dlp + ffmpeg download, gated on an STT key and a DEFAULT_MAX_DURATION_SECS 30-min cap so a long stream can’t blow cost or the Scribe 25 MB ceiling). Metadata (meta_from_info: title/uploader/duration/views/upload_date/chapters) comes free from the same pull, so even an un-transcribable or silent clip gets a rich label. Long or non-English transcripts are distilled by video_ingest’s configured summarizer (a cheap Haiku claude_client.summarize_video, wired at boot via video_ingest.set_summarizer, chapters fed in, translated to English) into a compact summary that’s preferred over the raw transcript in context; short English ones go in raw. Transcribe-once: keyed by canonical_key (YouTube URL forms collapse to yt:<id>), word-less results still cache (they carry metadata + mark the key done so duds aren’t retried), bounded in-flight set + concurrency cap. The cache is two-tier (utils.durable_cache.DurableCache): a process-local L1 (read synchronously on the hot prompt-render path) over a durable, TTL’d kv_cache DB row (L2, namespace video, 30-day success / 6-hour failure TTL) so a once-resolved clip survives the every-merge redeploys instead of being re-fetched (re-paying yt-dlp/STT/Haiku); the durable tier is read-through on the async fetch path (_run_fetch restores from L2 before any live fetch) and wired at boot via set_durable_db (absent in tests → L1-only). Wired alongside annotate_transcripts (every recent_messages caller, the chime-in buffer, the memory writer/backfill). Gating: video is a permanent surface (no per-guild experiment), controlled by provisioning (yt-dlp installed + ffmpeg in the image) + the VIDEO_TRANSCRIPTION kill switch (default on, hard-disables everywhere). When transcription is enabled it both transcribes AND injects — feeds.annotate_video reads should_fetch() and extract_media reads should_inject(), both now collapsed to just the kill switch (video_ingest._enabled()). (It went through a staged per-guild rollout while it was a trial — production injected, staging transcribed-but-held, off skipped — then graduated; the gate machinery was removed.) X/Twitter bypasses yt-dlp onto the fxtwitter (FixTweet) API (utils.x_fetch, #491): yt-dlp’s Twitter extractor 100%-fails on a “Bad guest token” upstream bug (#462), so fetch_video routes is_x_url URLs to _fetch_x, which hits the public FixTweet JSON API (the service behind the fxtwitter/vxtwitter/fixupx links people already paste) — no auth, no guest token. Two fail-open layers: the tweet text is the floor (source=x_text, lands even with no video, where X was previously fully dark) and a direct mp4 (when present, within the duration cap) is downloaded via plain aiohttp + Scribe-STT’d (source=audio), no yt-dlp/ffmpeg. canonical_key collapses every X form (host/screen-name) to x:<tweet_id> so one tweet dedups to one fetch (mirrors yt:<id>). The open item is whether the video.twimg.com CDN mp4 is reachable from Railway’s datacenter IP — watch the video_transcribe source=audio vs x_text split; a CDN block degrades to text, not back to dark. Emits x_fetch (the API call). TikTok + Instagram bypass yt-dlp onto ScrapeCreators (#1272): yt-dlp resolves those two hosts poorly from a datacenter IP, so fetch_video routes is_tiktok_url/is_instagram_url URLs to _fetch_scrapecreators FIRST when set_scrapecreators is wired (boot) — TikTok via /v2/tiktok/video?get_transcript=true (a WEBVTT transcript flattened by the reused flatten_vtt + rich aweme_detail metadata → sc_tiktok_meta), Instagram via /v2/instagram/media/transcript (plain AI text). A transcript-less miss returns None and falls through to the existing yt-dlp path (no regression when SC is down/unprovisioned); emits video_transcribe source=scrapecreators only on a hit. Spaces are out of scope (different live-audio endpoint). Pure helpers (youtube_id/canonical_key/flatten_*/pick_caption_track/meta_from_info/x_fetch.x_tweet_id/is_tiktok_url/is_instagram_url/sc_tiktok_meta/sc_flatten_transcript) unit-tested; yt-dlp/network is integration-only.lru.py + durable_cache.py, the shared caching primitives. LRUCache is a tiny bounded most-recently-used cache (hit/miss-aware get, used by voice_ingest + as the L1 of DurableCache). DurableCache is a two-tier cache: a process-local LRUCache (L1, sync, zero-RTT) over a durable, per-row-TTL’d kv_cache Postgres table (L2, async), partitioned by namespace, generic over the cached type via encode/decode (typed L1, string L2 — JSON for structured values). Read-through (get: L1→L2, populating L1 on an L2 hit) + write-through (put: both, with the L2 write best-effort so a DB blip never breaks the feature); peek/set_l1 are the sync L1-only ops for hot read paths. db=None degrades to L1-only (tests / unprovisioned). Expiry is lazy on read (db.cache_get filters expires_at > NOW()) plus the daily db.prune_kv_cache sweep on the _pruner loop. This is the durable backing the video transcript + voice STT + link-enrichment caches use. In markets the split is by data KIND: live-odds/price caches stay in-memory (freshness-critical, durability would serve stale data; utils/async_cache.py remains for those), and the SGO sports/score fetch (SportsGameOddsClient.get_event_odds/get_player_props) is fresh-first with a bounded stale-on-error fallback: every call always attempts a live fetch (a successful read is never stale), and only when the fetch FAILS (a 429 — the amateur tier’s per-minute + monthly quotas are tight) does it serve the last good result, and only if younger than _SGO_STALE_FALLBACK_SECS (180s — extended from 60s in #725 so a stale-served slate/score survives a throttle that outlasts one per-minute window AND covers the SGO circuit breaker’s open→probe cooldown; failure-path only, so best-case freshness is untouched); a genuine empty result (no live games) is never masked by stale, only an outright failure is. The in-season league set for the live-scoreboard fan-out is also durably cached (namespace active_leagues, ~12h TTL, warm-started — bumped from 6h in #831 since the in-season set only changes on a weekly scale, halving the 14-entity probe with no freshness impact): SGO has no “season active” flag (its /leagues//sports are static catalogs), so MarketsManager.resolve_active_leagues derives it — a cheap has_upcoming_events existence probe per candidate (limit=1 + a startsAfter/startsBefore window = ONE entity each, and the window excludes a future-season schedule like NFL games months out), refreshed lazily under a lock so concurrent scoreboard calls don’t all probe. get_live_scoreboard fans out only over the active subset of the candidate set instead of all of it; a cold cache / failed probe / empty result falls back to the full candidate set, so the filter only ever trims, never blanks. The fan-out is also narrowed to WATCHED sports (#831, the dominant SGO entity saving): get_live_scoreboard(sports=) / live_games(sports=) take an optional set of internal sport labels, and the SGO provider trims the in-season leagues to only those sports, so the commentator (which passes its watched-sports union across guilds — None if any guild watches all) never spends an entity on a league no configured guild displays. Before this it fetched every in-season league every 60s, then discarded the unwatched ones per-guild AFTER paying for them. Threaded through hub → providers (only SGO honors it; API-Sports/Odds aren’t entity-capped); the bookie + ask call unfiltered (sports=None, all in-season) so their coverage + odds-freshness are untouched. Fail-open throughout (unmappable sport / catalog miss / empty intersection keeps the full set) and freshness-neutral (it drops whole unwatched leagues, never serves a stale score/odd). Shipped without a live dry-run (SGO entity cap was exhausted); verify on reset via the per-league market_fetch source=sgo events (#833). The per-tick narrowing itself reads a durably-cached leagueID→sportID map (MarketsManager._resolve_league_sports over namespace sgo_league_sports, ~24h, warm-started — the SAME /v2/leagues catalog the candidate set uses, SportsGameOddsClient.list_league_sports, so list_team_leagues is now its keys), rather than a LIVE /leagues read every tick: that per-tick catalog fetch (~1,440 SGO reads/day, mislabeled “free” in _scoreboard_leagues) was the single biggest STEADY entity drain against the monthly cap (#SGO-eff, the 63k-of-100k-in-5-days incident), running 24/7 regardless of whether anything was live. Fail-open — an unavailable map declines to narrow (returns the full in-season set) rather than blank the slate. The candidate set itself is resolved dynamically from SGO’s /v2/leagues catalog (resolve_candidate_leagues → all enabled leagues whose sport is in _PARSER_SPORTS, the sports _sgo_event_to_snapshot can render as a two-sided matchup — the team sports PLUS head-to-head individual sports (MMA/UFC, tennis, which come back in the same teams.home/teams.away + moneyline shape, just no point score); only FIELD/outright sports (golf, horse racing) are excluded as unrenderable; durably cached namespace sgo_candidate_leagues, ~24h, warm-started), so a league SGO adds is picked up with no code change. The hardcoded COVERED_LEAGUES is the offline fallback (catalog fetch failed / cold / no key / tests) and the base for the SGO_COVERED_LEAGUES env override (which pins the set and skips dynamic discovery). Concurrent scoreboard calls are also single-flighted (an in-flight asyncio.Task + lock): a burst of mentions shares ONE fan-out instead of each launching its own (the #289 stampede — a result cache can’t help since all N start before the first returns), and once done the next caller refetches fresh. Each scoreboard fetch uses SGO’s server-side live=true filter (get_event_odds(live_only=True)) so it bills only the in-progress games per league (often 0-3) instead of fetching up to limit upcoming events and discarding the non-live ones; the named-league odds fetch (_sports_snapshots) stays unfiltered (it wants upcoming odds too) but trimmed to limit=5. The live scores themselves stay fresh/uncached-on-success. But the Kalshi series index — the discovery catalog (which markets exist), not prices — IS durably warm-started (namespace kalshi_series, ~6h TTL): it’s expensive to rebuild (no Kalshi search API) and was rebuilt from scratch on every boot, leaving discovery dead until the first post-deploy refresh; now a fresh process warms instantly from L2 while the hourly live refresh catches up. Its DB ops flow through db._run, so the per-query p99 instrumentation (db_query events) times them like any other query. Note: this deliberately persists derived public content (transcripts/summaries) to the DB, a conscious reversal of the prior “nothing durable” stance, justified by it being public video content under a bounded TTL.stats.py, the shared percentile(values, p) primitive (nearest-rank: integer-clean, tail-biased on small windows). Hoisted out of scripts/ops_monitor.py (which now imports it from here) so there’s one definition of “p99”.numfmt.py, the one compact-number formatter — abbrev(n, *, prefix, suffix, decimals, k_decimals, keep_trailing_zero) renders a count as “140.5K” / “1.2M” / “11.5B” / “$940K”. The “1.2M”-style formatter was hand-copied into ~8 utils (luminate/box_office/netflix/feeds/genius/link_enrich/social_profile/social_search), each slightly different and most carrying the same rounding bug where 999,999,999 rendered the malformed “1000M” instead of “1B”. This is the single source of truth (rounding-correct thresholds that promote a value rounding to 1000 into the next unit; each caller’s exact output preserved via the knobs). Use it — don’t re-roll a K/M/B formatter. Pure, unit-tested.song_pool.py, the shared guess-the-song core (#1497 unification) — the surface-agnostic pieces BOTH the Discord /guess music game (cogs/games.py) and the X/Twitter game (cogs/x_game.py) reuse so the two can’t drift: song_key(title, artist) (the canonical _norm_artist - _normalize_title dedup/reuse identity, so a song keys IDENTICALLY into the durable game_recent_songs block on either surface) and current_genre_hits(genre_key, *, cap, cache=None, seen=None, skip=None) (the current-hits GROUNDING sourcing extracted from the /guess game’s _current_hits_into/_charted_into: the Deezer per-genre chart leading ~2/3 + the iTunes RSS hedge, each guessable + preview-backed + not-seen + not-skipped, window-shuffled + capped, returning raw catalog rows). The genre→chart-id maps (DEEZER_GENRE_CHART/RSS_GENRE_IDS) + the bundle map (GENRE_BUNDLES) live here too, re-exported into cogs/games.py under their historical private names. Games._current_hits_into is a thin wrapper that threads the /guess-specific bits (the durable source cache, the served-set dedup, the reuse block) in and converts the rows to _Clip; XGame._pick_song calls current_genre_hits over the urban bundle’s lanes for its grounded blend — so both games ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before). Answer JUDGING is deliberately NOT here — it’s the /guess matcher (cogs.games.grade_guess, HARD) shared directly. Dependency-light + pure (reaches DOWN into utils only, no cog import), unit-tested in tests/test_song_pool.py.retry.py, the one async retry primitive — retry_async(fn, ...), the single home for every transient-failure retry (no cog/util hand-rolls a for attempt in range(...) loop). It takes a zero-arg coroutine factory and retries on a caller-supplied exception predicate (each site keeps its own notion of “transient”: an aiohttp 403/429 for iTunes, an anthropic 529/timeout for the API, a bare connection drop — a non-retryable error still propagates at once) AND, optionally, a result predicate (for fetchers like markets._fetch_with_retry whose inner call swallows the error and returns a None sentinel instead of raising). Exponential backoff (base_delay·factor**k, capped at max_delay) + optional jitter, with leading_jitter adding a pre-first-attempt jitter to de-sync a concurrent burst (the apple_music pool-prefetch fan-out, #371). Only catches Exception, so CancelledError/KeyboardInterrupt propagate untouched. After the attempts are spent it re-raises the last exception (exception path) or hands back the last sentinel (result path) — the caller’s fail-open handling stays in charge. Pure control flow, emits nothing: it nests INSIDE an @instrument/timed_event so one domain event fires per logical call, not one per attempt. Current callers: claude_client._create_with_retry (anthropic 529/5xx/timeout, exp backoff), utils.apple_music._request_json (iTunes 403/429/5xx, jitter + leading-jitter burst de-sync), utils.markets._fetch_with_retry (the Kalshi paginated open-events walk — a (data, status) result predicate that retries 429/5xx/network but gives up at once on a permanent 4xx), utils.stt.transcribe (ElevenLabs Scribe transient timeout/connection, one retry), every bare-read outbound client via utils.http_retry (below), and the scripts/eval_* harness _ask helpers (a result predicate that retries past the empty-answer fallback).http_retry.py, the aiohttp-aware layer over retry.py — the read clients all share ONE notion of a transient HTTP failure (a 429 or 5xx status, or a raw connection/timeout drop), so the predicate + retryable-status set live here once instead of being copy-pasted into each. is_transient(exc) is the retry_on predicate (a permanent 4xx propagates at once); retry_http(fn) is retry_async pre-wired with it + a short exponential backoff (DEFAULT_ATTEMPTS=3, DEFAULT_BASE_DELAY=0.5s) — the single entry point the outbound read clients use. The convention for a client: the inner round-trip raises on a retryable status (resp.raise_for_status() guarded by status in RETRYABLE_STATUS) so retry_http rides it out, and returns a sentinel ((None, status)) on a permanent failure so it falls straight through to the existing fail-open handling — no behavior change beyond riding out a transient blip. Keeps the generic retry.py primitive aiohttp-free. Wired into the idempotent reads only — perplexity.search, embeddings.embed, gifs.search, link_enrich._fetch_json, github.get_issue, railway._gql(retry=True) (its list_deployments read; the redeploy write deliberately does NOT retry, since a re-fired mutation could double-trigger a deploy). External writes (create_issue/comment/close_issue/redeploy) and paid-asset POSTs (tts/image_gen) are left single-attempt on purpose.circuit_breaker.py, a minimal failure-rate circuit breaker (#615) for an outbound integration that craters from the bot’s datacenter IP. Three states — CLOSED (healthy, calls flow, outcomes tracked in a rolling window) → trips OPEN once the failure rate over the window crosses a floor (default 50%) with enough samples (so a couple of unlucky early failures can’t open it) → short-circuits calls for a cooldown → HALF_OPEN lets ONE probe through after the cooldown → CLOSED on recovery / re-OPEN on failure. Pure + clock-injectable (every method is synchronous so it’s atomic within the asyncio loop; tests drive the cooldown via clock=), fail-open by construction (it never raises — wiring it wrong degrades to “no breaker”, never a crash), and telemetry-agnostic (the caller passes an on_transition hook to wire state changes to its own event stream, so the module stays dependency-free + unit-testable in isolation). The reusable orchestration lives in the util, not the call site: breaker.call(fn, success=..., short_circuit=...) is the one-stop entry point — it owns the allow→run→record→short-circuit dance (short-circuits to the caller’s sentinel when OPEN with no call to fn, records an fn exception as a failure and re-raises, classifies a returned value via the success predicate so a permanent 4xx reads as a healthy “no”), so the next integration that craters wires a predicate instead of re-implementing the loop (the low-level allow()/record() stay public for advanced use). Guards four outbound clients, each behind its own breaker instance (a crater of one never stalls the others, nor Polymarket which has none): every KalshiClient read (#615) + every SportsGameOddsClient read (#725) in utils/markets.py, every ApiSportsClient read (#725, the live-scores + settlement backbone), and every TheOddsApiClient read (#725, now load-bearing as the /bet SGO-down slate source + settlement backstop). A sustained crater trips that source’s breaker and stops the hammering — no network call — for a cooldown, while reads degrade gracefully: SGO/Odds-API to a stale-on-error cache, API-Sports’ degraded routing bookie settlement to the Odds API. The SGO breaker is the answer to the amateur-tier 429 storm (window 30, min-samples 10); API-Sports matches it; the Odds API is slightly lower-volume-tuned (window 20, min-samples 8). The success predicate is per-client, not uniform: Kalshi’s and the Odds API’s treat a permanent 4xx (e.g. an unknown-ticker 404) as a SUCCESS (“a legit not-found”), since those per-ticker/per-event GETs can genuinely answer “no such thing” — only a 429 / 5xx / network failure counts against them. API-Sports and SGO (order #45) instead only count actual data coming back as success: every SGO endpoint (/events, /leagues, /account/usage) is a filtered LIST read that answers a bad/unknown filter with 200 + an empty array, never a legitimate 404, so reusing Kalshi’s “permanent 4xx = healthy” logic there meant a sustained 403 (a datacenter-IP block or a bad/revoked key) recorded as a SUCCESS and never tripped the breaker — silently disabling the Odds-API live-scores backstop and the SGO-down market-edge fallback that gate on sgo.degraded. So for SGO/API-Sports a 429 / 403 / 404 / 5xx / network failure ALL count against the breaker. Emits circuit_breaker (with integration) on each transition.rate_limiter.py, the async token-bucket rate limiter (AsyncRateLimiter, #843 / order #34) — the proactive companion to circuit_breaker.py: the breaker REACTS to a crater after the 429s land, this PREVENTS them by pacing reads so they never blow the upstream’s per-minute cap in the first place. A token bucket (up to burst tokens, refilled at rate_per_min/60 per second); await acquire() consumes one, sleeping until one is free — a burst up to capacity passes instantly, beyond it calls drip at the steady rate. Same conventions as its sibling primitives (retry.py/circuit_breaker.py): clock + sleep injectable (deterministic tests), fail-open by construction (rate_per_min <= 0 → acquire is a no-op, so a misconfig degrades to “no limiter”, never a hang), loop-FREE (sizes ONE sleep then grants, so it can’t spin even if a caller mocks sleep instant), serialized accounting (a lock makes refill-check-consume atomic and spaces concurrent waiters FIFO into the even drip), and telemetry-agnostic (emits nothing — its effect shows as the existing market_fetch rate_limited rate dropping). Wired into every SGO read via SportsGameOddsClient._guarded_json’s _do, inside the breaker gate, so a breaker short-circuit (SGO down/degraded) and the quota-exempt get_usage read spend no token — only an actual breaker-allowed network attempt paces, so the limiter is inert while SGO craters and adds latency only under a genuine live burst. Env-tunable SGO_RATE_LIMIT_PER_MIN (40) / SGO_RATE_LIMIT_BURST (10), <=0 disables; these defaults are calibrated to SGO’s documented rookie-tier cap (50 requests/min; 100k objects/month) — a steady 40/min + 10-burst keeps the worst-case rolling minute (burst + rate) at ~50, i.e. right at the ceiling with steady-state headroom, while the burst still passes a normal single-flighted fan-out unthrottled; confirm/fine-tune against real load via /debug/usage once SGO is live (a Pro-tier upgrade lifts the ceiling to 300/min). Kalshi got its own instance too (order #1057): wired the same way, inside KalshiClient._guarded_fetch’s _do, ahead of every retry/breaker-allowed attempt (a paginated open-events page and a live single-shot read share the one limiter, so the page walk’s own explicit 250ms inter-page sleep and the token bucket both bound the same traffic). KALSHI_RATE_LIMIT_PER_MIN (240) / KALSHI_RATE_LIMIT_BURST (8) default to the SAME 4 req/sec steady rate the open-events walk had already proven safe against the ~6 req/sec public-read limit Kalshi enforces (unlike SGO’s cap, this is empirically observed via live testing, not a number published in Kalshi’s own API docs — Kalshi does not document a numeric public-read rate limit; see the walk’s own pacing comment in utils/markets.py), with an 8-call burst (matching the Kalshi breaker’s min_samples) so a normal bounded fan-out (the category/topic pools’ Semaphore(3-4)) still passes instantly — the proactive complement to the existing Kalshi circuit breaker (#615) and jittered retry (#905), so a concurrent fan-out across surfaces (live commentary + /ask + market-drop firing the same tick) can’t self-inflict a 429 before the breaker ever reacts. Generic + reusable: the next metered integration instantiates its own.kalshi_ladder.py, the ONE definition of what a Kalshi scalar LADDER is (#ladder-forecast) — i.e. “is this leg one RUNG of a ladder, or a standalone proposition?”. A scalar market isn’t one contract, it’s a ladder of “Above N” rungs over a single underlying number (one film’s Tomatometer, one album’s first-week units), and the rungs are a survival curve, not independent propositions. Reading one rung as a standalone market is the trap this module closes: an at-the-money rung is a knife-edge that amplifies a tiny move in the underlying into a huge move in its own digital odds. Live-measured on KXRT-SPI: the figure Kalshi publishes held flat at 90 while the “Above 90” rung swung 66%→48%, because the distribution merely tightened AROUND 90 (rung 89 85→73, rung 90 68→54, rung 91 39→15) — and cogs/market_alert read that rung and posted “a favorite falling to a coin flip” against a market whose published number hadn’t moved in two weeks, three times in three days off the same film. is_ladder_rung(meta) is the tell, hoisted from cogs/market_drop._is_threshold_leg (#1011, which already refuses to narrate this shape) so the alert surface applies the SAME test instead of growing a second copy — two tells, since Kalshi marks ladders inconsistently: a NUMERIC strike (numeric strike_type / floor_strike / cap_strike) OR a threshold-PHRASE label (“Above/Before/More than X” — the only signal on a DATE ladder, which carries no strike metadata). An ENTITY leg (a plain candidate name) is neither, and stays headline-able. What a ladder DOES forecast — the survival curve’s E[X], the figure Kalshi prints — now lives HERE too (ladder_forecast / implied_estimate / is_material_forecast_move), hoisted out of utils/luminate.py where it was proven on the music ladders and re-exported there for its existing callers, since the math is generic over any (strike, price) ladder. That’s what lets cogs/market_alert alert on the thing that actually moved instead of just suppressing rungs. The E[X] right tail is scaled by the ladder’s own SPAN (top − bottom strike), not the top strike (#forecast-card-copy): the old E[X|X>T]=2T tail is only sane on a from-zero ladder (span≈T — the Tyla 1K..10K calibration held under either model), and on a NARROW-BAND board it fabricated — the live Bieber weekly-views ladder (15.25M..17M, top rung ~0.31 survival) read 21.9M against the ~17M Kalshi’s own card printed; span-scaled it reads 17.1M (+0.7%), Tyla 8.46K (−6% of the published 9,028), and a thin-top ladder (Spider-Man, S(top)≈0) is untouched since the term vanishes. Strikes are FLOATS end to end (same PR): an int(s) key collapsed every fractional-strike ladder’s neighbouring rungs onto one integer (live: natgas $2.705/$2.75 → “2”, the board reading “forecast ~1” against its own 83%-to-clear-$2.705 rung) — invisible while the only readers were the integer-strike music boards, caught by the awakened alert path’s population sweep; and the alert’s rendered figure follows the BOARD’S OWN units read off the rung label (market_alert._forecast_figure: a unit-scaled Netflix “At least 18 million” board renders “18.9M” not a bare “19”, a $-commodity board keeps its $). A FAT-TOP ladder posts the OPEN form, never its point E[X]: when the survival curve never crosses 0.5 inside the board (open_high — the market’s own median lies beyond the top strike, e.g. the NCAA season-wins boards topping at 18 with that rung at ~0.98 for a ~25-30-win answer), the board only honestly says “above kalshi_price.py, the ONE definition of “is this Kalshi yes price trustworthy”, shared by every reader of a Kalshi book — the market snapshot (markets._kalshi_market_to_snapshot), the candle trajectory (markets._candle_mid), and the survival ladder (luminate._survival_price). Kalshi hands three price signals per market (the two-sided yes_bid/yes_ask book + the last TRADE) and neither is trustworthy alone, so the rule lives here once: a TIGHT two-sided book (spread ≤ WIDE_SPREAD 0.10) → the MID; a WIDE/one-sided book → the last trade only when it’s a real in-book print (within LAST_TRADE_TOL 0.05 of the book, since on a wide book the mid is a price nobody would trade at — bid 0c / ask 97c mids to 48% on a rung really worth ~95%); otherwise NO price, and the caller drops that sample (a dropped rung/candle beats a fabricated one — the ladder interpolates across the gap, the chart omits the point). traded_price is the companion guard: Kalshi reports last_price_dollars of exactly “0.0000” on a market that has NEVER traded — a no-trades SENTINEL, not P=0 (the 1c tick is the floor, so a live market can’t sit at a true 0). Reading that 0 as a probability is what cratered the petal (Ariana Grande) album-units forecast from ~371K to ~208.8K: four never-traded mid-ladder rungs briefly lost their bid, _survival_price fell through to the 0, and implied_estimate’s monotonic clamp propagated it up the WHOLE ladder — truncating the survival curve just above 200K, which shipped a false “steep cut” line-move alert and a forecast chart that plunged at its anchored right edge. Dry-run-verified against the live ladder: the same bid-pulled state now reads 358K vs the healthy 356.6K (a ~0.4% wobble instead of a 44% crater), and the healthy figure moved 371.4K → 356.6K, i.e. CLOSER to Kalshi’s own published 351K, because the far tail now reads its real ~0.005 book instead of stale 0.02–0.05 last trades. Swept across EVERY live Luminate music market (34 readings, 47 series): 6 MORE were collapsed by the same bug at that moment (KXARTISTSTREAMSY yearly-streams events, each recovering 55–93% — Bad Bunny 32.2B→50B, Bieber 18.6B→31.9B, Billie 11.4B→20.1B), 15 were unchanged, the rest moved <10% (the far-tail cleanup). The second failure shape the sweep exposed is an EMPTY market: a zero-volume event quoted 1c/97c on every rung (PRIMA by ADÉLA) has no market price at all, yet the old read shipped a FABRICATED figure either way — the 1c/97c mid read as 49% on every rung (~10K), and with the bid at 0 the never-traded 0 read as P=0, whose trapezoid first segment is exactly the “~500 pure sales” the desk posted. Now every rung is unusable → NO reading → the desk posts nothing, the fail-safe direction (a dropped rung beats a fabricated one, and a whole dropped market beats an invented number). Pure, unit-tested.alert_triggers.py, the shared alert TRIGGER primitives (#generalize-alerts) — the pure movement classification every alert surface reuses, so FLIP / SWING / lead-change is defined ONCE instead of re-implemented per surface. Each alert cog keeps its OWN fetch, durable baseline, compose voice, card, and gating; this owns only the pure decision “did the leader/number move enough to be news, and how?”. Standing (who leads a 2+-way market: leader + integer % + margin over the next), top_standing(outcomes) (the leader of a multi-outcome {entity: prob} map — the leaderboard analog of betting_alert’s _favorite), and classify_leader_move(ref, cur, *, swing_pp, flip_margin) → "flip" (the LEADER changed AND the new leader clears flip_margin over the runner-up, so a near-tie jitter isn’t a flip) |
"swing" (same leader, its % moved ≥ swing_pp) |
None. Consumers: cogs.betting_alert (a bettable GAME’s moneyline flip/swing — the FIRST implementation, now a thin adapter over classify_leader_move) and cogs.market_alert (a prediction-market LEADERBOARD lead-change — a new artist/name overtaking the prior #1 on a Kalshi ranklist; the SECOND consumer that earned the extraction, rule-of-two). Percentages are integer POINTS so both surfaces compare like with like. Pure, unit-tested. |
tmdb.py, the guarded TMDB client + pure parsers for the cinema desk’s RELEASE calendar + title metadata (#cinema-desk): now_playing (theatrical, popularity-ordered — the clean release signal), discover_streaming (new-on-a-platform via watch-provider), new_tv (discover/tv by first_air_date), movie_detail (budget / lifetime WORLDWIDE revenue / runtime / genres — the enrichment, NOT the daily beat), search_movie, and trailer_url (the OFFICIAL YouTube trailer for a title off /{movie,tv}/{id}/videos — the video twin of poster_url, an AUTHORITATIVE lookup rather than a search, which is what lets utils.watch_link route a film/TV post deterministically; prefers an official Trailer, then any Trailer, then an official Teaser, YouTube-only so the link unfurls into a player). Auth = the v4 bearer TMDB_ACCESS_TOKEN (authorizes v3 too; TMDB_API_KEY the fallback). TMDB is free + unmetered (no quota header, verified), so NO usage poll — just the standard guards (AsyncRateLimiter + breaker integration="tmdb" + retry). Emits tmdb_fetch; fail-open (a miss → None/[]).box_office.py, the guarded Box Office Mojo scrape client + the pure parse_chart HTML parser for the cinema desk’s box-office grosses (#cinema-desk): weekend_chart(year, week) / daily_chart(day) → ranked BoxOfficeRows (period gross + running total). No API/quota; the guards are a GENTLE rate limiter + breaker (integration="box_office", trips on an IP-block/outage — the thing that 403s us on The Numbers) + retry + a short in-process TTL cache. Emits box_office_fetch; fail-open ([]). Parses cleanly from our host (verified).netflix_top10.py, the guarded Netflix Top 10 TSV client + pure parser for the cinema desk’s streaming numbers (#cinema-desk): latest() → this week’s Top-10 rows (weekly hours viewed + views, across Films/TV × English/Non-English). Reads the whole all-weeks TSV (oldest-first, so the latest week is at the END — must read fully) + caches weekly; free, no key, guarded (breaker integration="netflix_top10" + retry). Emits netflix_top10_fetch; a TSV shape change shows as a fail rate. Fail-open ([]).omdb.py, the guarded OMDb client + pure parser for the cinema desk’s critical-scores lane (#cinema-desk): by_title(title, year) → an OMDBRatings (RT % + Metacritic + IMDb, parsed out of OMDb’s Ratings array; a brand-new film may carry Metacritic/IMDb before RT populates). Free tier (OMDB_API_KEY); guarded (rate limiter + breaker integration="omdb" + retry + TTL cache). Emits omdb_fetch; fail-open (None). The one differentiated cinema FACT Kalshi’s thin score markets can’t cleanly give.cinema_numbers.py, the cinema desk’s pure data layer (the movie/TV analog of utils.music_markets): the story BUILDERS (box_office_story — release-centered, leads with the biggest new opener; streaming_story — the #1 film/show by hours; scores_story — a film’s RT/Metacritic critical scores off OMDb, “reviews are in: 94% on RT”; release_story — the notable new theatrical/streaming/TV drop), each turning already-fetched rows into a CinemaStory (headline + a grounding blob the compose model quotes + a dedup_key + a story-priority score), plus rank_stories (order + dedup) and the async fetch_cinema_stories orchestrator (the guarded client reads → the builders; the scores lane looks up the freshest notable theatrical release’s scores). Builders/ranker are pure + unit-tested.instrument.py, the one consolidated timing tool. Instrumenting anything is a single line and the util owns the clock/ok/duration/emit (no call site touches a stopwatch). Four primitives: @instrument("kind") (decorate an outbound-client fn → ONE domain event on exit with ok+duration_ms; set fields inside via the ambient event().set(...)/event().fail(...), the ambient handle resolved through a contextvar so a helper can reach it; used by github/railway/tts/stt/image_gen/gifs/apple_music/video_fetch/link_check/…), timed_event("kind", ...) (the block-scoped form for a dynamic kind or a partial-function span: db _run, pplx_<purpose>, market_fetch), @timed("name")/timed_span("name") (a heavy fn / flow step → span event), and Stopwatch (the escape hatch for the two calls that must read elapsed time at several points in one operation: the claude_api retry loop and @track_command). event() is always safe (a detached no-op outside any scope); everything is best-effort (a metrics failure never breaks the wrapped call) and the call’s own exception propagates (auto-tagged ok=False+error). Replaced the hand-rolled start = time.monotonic() / int((...) * 1000) pattern every client had copy-pasted.context_beat.py, the shared LIVE CONTEXT BEAT (#cinema-facts) — the reusable primitive that pulls the current buzz / reception / discourse around a post’s subject from Grok (the X crowd) + Perplexity (web / critics / Reddit / TikTok / YouTube, swept in ONE Perplexity call so “other socials” ride along cheaply — owner steer) and merges them into ONE block a compose surface weaves into its take as SENTIMENT + the sharp ANGLE the bare numbers can’t carry (a fandom civil war, a second-season-slump framing, a critical split). The generic sibling of grok_search.market_topic_pulse (which is market-framed): a neutral “what’s the buzz” read that fits ANY subject. CONTEXT, NOT GROUNDING — the consuming surface still quotes every HARD NUMBER from its OWN grounding blob, never from the beat (the compose_market_drop x_context slot already enforces “the ONLY numbers you quote come from the grounding block”, so a surface just passes the beat there). gather_context_beat(bot, subject, *, purpose, extra, framing) runs both sources CONCURRENTLY, provisioning-gated per source, fail-open (a missing/erroring source contributes nothing; both missing → None). Emits nothing itself — the underlying pplx_<purpose> + Grok search events carry the telemetry, so purpose attributes cost to the calling surface. framing picks the Grok leg: "general" (default) a neutral “what’s the buzz” read (cinema/film/show/song); "market" reuses the proven market-tuned grok_search.market_topic_pulse (crowd-vs-reality) so a MONEY surface keeps its EXACT existing X read and only GAINS the Perplexity layer; "breaking" (the wire desks’ breaking lane, #sports-desk) leans HARD on Grok’s real-time X for the freshest just-broke chatter (_GROK_BREAKING) and pulls the Perplexity leg at recency="day" for max freshness — Grok beats the lagging web index on a just-broke story. The Perplexity PROMPT is the same across framings; only its recency tightens for breaking. Adopters: the cinema desk (framing="general") and market_drop (framing="market" — it already had the Grok pulse, so the beat’s only net-new call there is Perplexity; no Grok is doubled). Music + discourse deliberately do NOT use the shared primitive — they already pull their OWN Grok + Perplexity (interwoven with surface-specific enrichment), so routing them through the beat would DUPLICATE those calls; they’re left as-is. The live sports commentator is deliberately excluded (owner steer) — it’s latency-critical + already live-fed, so a buzz round-trip per goal/interval isn’t worth it. Pure gather/merge logic unit-tested in tests/test_context_beat.py; the Grok/Perplexity I/O is the clients’.audio_tags.py, the v3 performance-tag sanitizer (voice “directing”): a voiced line can carry inline cues that ElevenLabs v3 reads as delivery direction. ALLOWED_TAGS is the audition-confirmed set, [laughs]/[sighs]/[sarcastic]/[whispers]/[excited]/[curious] (reactions/tone) + [sings]/[singing]/[singsong]/[humming]/[hums] (melodic/wordless). Two invariants keep it safe: filter_to_allowlist(text) drops any non-allowlisted [tag] before synth (v3 reads an unknown tag literally, so a hallucinated [angrily] must never reach the API — applied inside tts.synthesize, filter_tags=False bypasses it for the audition); strip_all_tags(text) removes every [tag] before any text path (the text fallback, dedup, the stored memory/discourse rows, the self-gate scorer) so tags are audio-only and never render as text. The regex only matches lowercase-bracketed tokens, so [2024]/[NBA] pass through. ALLOWED_TAGS is the curated set; a new tag earns a spot only after scripts/audition_voice.py (key-gated, never in CI) confirms it renders on the real voice vs gets spoken literally. Distinct from the <voice>/<sing> control signal in voice_signal.py (those choose the delivery channel; these are performance within the line). The voiced prompts (ask SPOKEN DELIVERY + chime-in SPOKEN OPTION) teach the allowlist as “seasoning, most lines have none.”voice.py, canned quip pools (rate limit, permission denied, pipeline red, etc.) with pick() for random selectionbot_logs.py, structured logging to the guild’s #bot-logs channelimage_gen.py, ImageClient for OpenAI GPT Image (the picture-equivalent of tts.py): an async aiohttp wrapper gated on OPENAI_API_KEY, fail-open (None on any miss → text fallback), model/quality env-overridable (OPENAI_IMAGE_MODEL/OPENAI_IMAGE_QUALITY, defaulting to gpt-image-2/medium). Two calls sharing one POST/decode/emit core (_execute): generate (text→image, JSON) and edit (remix an existing image, multipart upload to images/edits). Emits image_generated with mode=generate|edit. Relies on OpenAI’s own moderation; the prompt is composed under the constitution.openai_chat.py, OpenAIChatClient for OpenAI Chat Completions — the OpenAI TEXT backend (#openapi-parity), the generation sibling of image_gen.py/embeddings.py: an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (a CLASSIFIED OpenAIFailure on any miss, #973 — category routes the failover: provider/quota fail OVER to Claude, auth/request/malformed surface; a 429/insufficient_quota out-of-credits is classified quota and is NOT retried, the OpenAI analog of Anthropic credit exhaustion), model env-overridable (OPENAI_CHAT_MODEL/OPENAI_CHAT_CHEAP_MODEL, the best/cheap pair gpt-5.5/gpt-5.4 — the opus/sonnet analogs; the Haiku-tier gpt-5.4-mini is an env-override only). complete(*, model, system, user, max_tokens, purpose, temperature=None) returns a ChatResult (text + finish_reason + token counts), emitting an openai_api event (model/purpose/tokens/finish/preview, never the prompt; ops-monitor keys it openai:<purpose>). The router (claude_client._call → _call_openai) maps it onto ClaudeResult, so a GPT-routed surface is invisible to every downstream guardrail. Two paths: tool-free → complete (Chat Completions, max_completion_tokens; o-series rejects max_tokens); tools / server-side web_search → create_response (the Responses API) driven by claude_client._call_openai_responses’s function-call loop, with the pure parse_responses_body / responses_function_tool helpers here (split out + unit-tested, alongside _decode_chat_completion). The built-in web_search tool returns url_citation annotations + a sources field that flow straight onto ClaudeResult.web_search_urls for the link guardrail. Image input (vision) is served on the Responses path: responses_input_content maps an assembled Claude vision message onto input_text/input_image parts (_image_part_url recovers a URL or repacks a base64 source as a data: URI), so a GPT-routed surface with images answers on GPT instead of falling back to Claude.embeddings.py, EmbeddingsClient for OpenAI text embeddings (the semantic-recall sibling of image_gen.py): an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (None on any miss → keyword/FTS fallback), model env-overridable (OPENAI_EMBEDDING_MODEL, defaulting to text-embedding-3-small, 1536-dim). embed(text) returns a float vector (emits an embedding event with chars/dims, never the text). Plus the pure, unit-tested helpers the recall path uses: encode_vector/decode_vector (the JSON ⇄ list bridge for the memory_notes.embedding TEXT column), cosine, and rank_by_similarity (cosine-ranks candidate notes against a query vector, drops anything under SIMILARITY_FLOOR, returns the same (tier, summary, span_start, span_end) shape the FTS methods do). Chunked retrieval (#865, the searchability lever): a whole daily note (~13k chars) embedded as ONE vector averages its many topics into a diluted centroid, so a narrow query (“tay keith”) scores weakly against the blur (~0.27) and sometimes the WRONG day’s note edges out. So besides the prose + tag vectors, the write path also stores per-chunk vectors (chunk_for_embedding splits the note into moment-sized pieces → embed_batch → encode_chunk_vectors → memory_notes.chunk_embeddings), and rank_by_similarity does a two-stage rerank: rank all candidates by whole-note vector, then re-score the top-chunk_rerank_top (#50) as max(whole-note, best chunk cosine). A narrow query then matches its specific moment-chunk (~0.59-0.75 vs 0.27), surfacing the RIGHT note — a measured ~+70% top-hit recall on real prod notes, and a correctness fix (the diluted whole-note vectors were mis-ranking days). A chunk hit can only RAISE a note’s score, never demote it, so it’s strictly ≥ the whole-note baseline; bounded to the top-_CHUNK_RERANK_TOP candidates to cap decode+cosine; fail-soft (a NULL/bad chunk column falls back to the whole-note vector). Backfilled onto existing notes by the /remember reindex (notes_missing_embedding now also picks up chunk_embeddings IS NULL). The constants are grounded in the live corpus, not guessed (the dry-run caught a naive first cut): MAX_CHUNKS_PER_NOTE=256 sits above the real max (prod daily notes: median 45 chunks, p99 96, max 194) so a busy day is never truncated; _CHUNK_RERANK_TOP=300 covers ~6× the worst observed whole-note rank of a right note (47 of 190 — the diluted score this stage gates on can run deep, exactly the dilution chunking fixes); and the chunk vectors are stored base64-packed float32 + scored with numpy (not JSON like the single prose/tags vectors), because at recall the JSON parse of dozens-of-vectors-per-note was the bottleneck (~1.8s/recall over the full corpus vs ~143-185ms for base64-f32, ~2× smaller too). JSON-in-TEXT + in-process cosine, not pgvector, deliberately: at this data scale retrieval is the constraint, not storage, and a CREATE EXTENSION vector that isn’t installed on the host would break the every-startup schema init. Embedding fenced prose is purely additive — it doesn’t touch the constitutional memory fence.image_signal.py, parses the model’s <image>…</image> (generate) / <remix>…</remix> (edit) candidate tags off a composed line (sibling to voice_signal.py), splitting into the caption (text outside) and the prompt/instruction (inside), with is_remix distinguishing the two. strip_image_tags scrubs any malformed/stray fragment from text replies. Pure, unit-tested. Only the typed-mention ask path teaches the tags (allow_image/allow_remix).market_cards.py, self-built Discord embed cards for market posts (the link-unfurl fix). kalshi.com sits behind a Vercel anti-bot wall that serves Discord’s link crawler a “security checkpoint” page instead of OpenGraph tags, so kalshi.com links never unfurl in chat (Polymarket’s native unfurl is inconsistent too). Rather than depend on either site’s crawler, cogs/market_drop + cogs/market_alert build their OWN embed (build_market_card → (content, embed, file)): the take is the embed description (her voice lives IN the card, so the post is ONE self-contained card and content comes back EMPTY — nothing is re-listed as message text, since the chart owns the ranked numbers and the take owns the read), the embed carries the clickable market link (on the embed url, NOT a bare link in the text, so Discord doesn’t also try+fail to unfurl it) and an image. A SETTLED market attaches NOTHING, on every surface (owner steer, #1851): when the card’s leading CHARTED outcome sits at or above SETTLED_LEADER_PCT (0.95 — literally market_drop._LOCK_LEADER_PRICE, imported so there’s ONE definition of “effectively decided”), market_settled drops the link — no clickable heading, no link Button, and, since the crosspost takes its link off card.url, no link in the tweet either. The race is over, so pointing the room at a 97% locked market is a dead CTA. The rule lives in this shared builder rather than in a cog, so it holds across every market surface (market_drop, market_alert, betting_board, betting_alert, betting_value, music_alert, music_desk) instead of each one remembering it — the hoist earned by the music desk + music alert having already hand-rolled it (luminate_snapshot(link=...) drops the Kalshi link on a SETTLED reading; those stay, since a RESOLVED reading is settled by its CLOSE, not by a 95% leader, so they catch a case the price test can’t see). It’s judged off the CHARTED outcomes — what the card actually shows — so a locked PROP leg inside a bundled Polymarket game can’t unlink a live moneyline (_chartable_items already dropped it) and a leg inverted to the FAVORED side (_favor_outcome) is judged as displayed; a scalar FORECAST line is skipped (its pct is a normalized trajectory, not a probability) and a market with no charted probability falls back to its binary yes-side (a 3% YES is as decided as a 97% one). A card showing exactly ONE line is judged as a BINARY too (max(p, 1-p), #1876): a lone leg has no field to lead, so its own extremity IS the verdict — taking the raw max there read a leg WIPED to 0% as “still live” and shipped a go-bet-on-it link to a market with nothing left to bet (the BTS #1-song alert). It’s the same rule the no-outcome fallback already applied, reached one step earlier; a real multi-leg board still leads with its top leg. The market_alert settled-OUT report (#1876) is the editorial half on that side of the line: a candidate whose move ENDED the question — it fell to 1 - SETTLED_LEADER_PCT (5%) or below from a real prior — stops being a market read and becomes a sewn-up FACT, the mirror of market_drop._is_report_lock (97% is sewn up FOR, 0% is sewn up AGAINST, and neither has a live line left). It routes to the report_out compose angle, is graded on drop_score(report=True) (generalized to score a settlement in EITHER direction — the winner-shaped rubric was docking a settled NO for “not naming what DID happen”), heroes “NO” on the number card instead of the “0%” the take was just told to drop, and passes attach_market=False. The blob is rebuilt WITHOUT percentages (_settled_out_blob), so there’s structurally nothing left to quote — the shipped post had read “cratered to 0% from 22%, wiped fully off the board”, i.e. a post about the price of a question rather than its answer. Rare by construction (exactly 2 of 30 days of production alerts landed ≤5%) and never a NEW alert — it only reframes one that already qualified. Dry-run-measured on real market families (scripts/dryrun_settled_out.py, n=5): 20/20 lines clean of price/market language, and the gate ships the sound cases 15/15 while holding a report whose underlying EVENT hasn’t happened yet (a “will X be evicted” market at 0% says the room thinks X survives, not that the episode aired) — the fail-closed direction working, not a gap to tune out. Two callers are CARVED OUT via link_when_settled (owner steer, #1856), because “decided” doesn’t make their destination dead: a value read whose link is a resolved SPORTSBOOK slip rather than the prediction market (the book decides whether that bet is still open, not us — with no book override it falls back to linking the market and the rule applies as everywhere else), and a bet_board card for a game that hasn’t STARTED, where a 98% favorite is still an open, tradeable book (resolved off snap.live inside _card, so both board lanes are covered and a game decided IN PLAY keeps the rule). A caller can also veto explicitly with attach_market=False — which still WINS over the carve-out, since the veto is an editorial call the price test must never override: market_drop’s sewn-up report angle (_is_report_lock) does, as the EDITORIAL half of the same rule (this post reports a fact, which is reason enough even if the price test ever stops agreeing). The source still gets its footer/pill credit — attribution, not an attachment; a contested market keeps its link exactly as before. market_image carries market_attached so a surface quietly going link-less (or the rule over-firing on live markets) is a queryable rate. The image is a priority ladder, first hit wins — charts-first, but a chart only earns the slot with real movement, so a no-history market prefers a real native image over a flat line: chart (real history) (the self-rendered card from utils/market_chart.py with actual price-history lines — Kalshi candlesticks AND, now, Polymarket via the CLOB /prices-history per outcome, #polymarket-chart) → native image (the source’s own curated image, Polymarket S3 — used when a Polymarket market has no plottable history) → chart (flat) (a flat-line chart still carrying name + outcomes + %s, better than a generic banner when there’s no native image, e.g. Kalshi with no candles) → banner (a committed branded floor banner, assets/market_cards/banner_default.png, regenerated by scripts/gen_market_banner.py — the never-blank floor). (Wikipedia/iTunes topical images slot in around the native-image rung in a later PR.) build_market_card builds the chart’s Outcome list (_chart_outcomes) from snap.meta['chart_specs'] (a per-outcome {label,pct,series_ticker,ticker} list the Kalshi leaderboard threads → a real Kalshi candle line per outcome via get_candle_summary’s series), else snap.outcomes (a multi-outcome event — a Polymarket game/futures now renders a REAL line per outcome when snap.meta['poly_tokens'] carries the per-outcome YES CLOB token ids: _polymarket_series → PolymarketClient.get_price_series → the CLOB /prices-history points; else flat lines. The token id is the clobTokenIds[0] captured in _poly_event_to_snapshot/_poly_yes_token — verified live that /prices-history?market=<clobTokenId> returns points while the gamma market id returns 0), else probability/last_price (a binary/swing alert → the one yes-side, real Kalshi line). Candle fetches are capped at _CHART_MAX_OUTCOMES (5, the render row cap). Per-surface chart ZOOM (owner report: alert charts weren’t zoomed to see the change): the price-history window is chosen by SURFACE (_chart_hours) instead of the source defaults (Kalshi 72h / Polymarket 24h) that squished a recent move into a wiggle at the right edge — MOVE/EDGE-now surfaces zoom TIGHT so the change fills the chart (bet_alert/bet_value 6h, alert=market_alert’s 24h move window), CONTEXT reads stay wide (drop 72h, bet_board 48h), default 24h; the window threads into both get_candle_summary(lookback_hours=) + get_price_series(hours=), and the y-axis then auto-scales to whatever’s IN that window, so a tighter window zooms BOTH axes onto the change. Primary-leg filter for bundled game events (_chartable_items): a Polymarket US-sport GAME (MLB/NBA/…) bundles its moneyline PLUS every prop (spread / O/U / 1st-5-innings / NRFI …) into one snapshot’s outcomes, so charting them all drew ~18 unrelated lines spanning 1-100% and no zoom could make the game legible; the moneyline leg is named EXACTLY like the event title ("Cleveland Guardians vs. Miami Marlins"), so when a title-matching outcome exists it’s a bundled game → chart ONLY that head-to-head win-prob line. A clean multi-way market (soccer’s home/Draw/away, a futures field) has NO title-matching outcome → charted in full (the fail-safe). Scalar ‘Kalshi forecast’ line (_forecast_outcomes, #charts-vision-caption-kalshi): a numeric-ladder music market (first-week units/streams) collapses its per-rung candle histories into ONE forecast line (the MEAN E[X] Kalshi plots). Two deterministic guarantees keep the LINE matching the caption + figure: (1) zoomed tight to the move — the forecast plots the TIGHTEST window (ladder 48h→7d→14d→30d, tightest first) that still contains a real move (E[X] swing ≥ _FORECAST_MOVE_FRAC of the figure), so the change fills the chart; a fresh move reads tight while an older one still gets found by widening (this fixes the original flat-at-24h bug from both directions — a fixed 24h window either squished or missed a weekly move); and (2) time-grid-aligned, full-ladder E[X] (luminate.forecast_series_grid) — the prior index-right-aligned rebuild (mean_forecast_series) read E[X] off whatever INCOMPLETE rung subset returned candles (values diverging from the headline figure — the Kanye 18.6B-vs-11.2B bug) and collapsed to ONE point when any rung was short (the flat-at-24h pts=1); the grid rebuild reads E[X] at each common-grid time from EVERY rung (forward/back-filled) and anchors the final point to the exact figure (forecast_specs['mean']) so line-end == legend == caption. With NO candle history even at the widest window, _chart_outcomes returns nothing and build_market_card falls back to the big-NUMBER card (a figure with no trajectory can’t contradict a ‘moved’ caption — no fabricated flat line). The candle fetch now returns series_ts (per-point timestamps aligned to series via the shared _downsample_indices) so the rebuild can grid-align on real time. Fail-open throughout (a render/fetch error degrades to the next rung). Emits market_image with the rung that answered + outcomes/with_series (how many lines had REAL history vs flat). (Polymarket’s image/icon is threaded onto the snapshot meta in utils/markets.py; utils/bot_logs.py:post takes an optional embed/file so a staged audition shows the same card.)market_chart.py, the Pillow renderer for the market chart card (render_market_card → PNG bytes). The look is matched to a real Kalshi reference card (owner steer “make ours this crisp”): a near-black card with a small letter-spaced category eyebrow (optional, eyebrow=, threaded from the drop’s category via snap.meta['category']), the market name big + bold, a stacked legend below it (one row per outcome: a colored dot + label + bold %), the co-brand top-right “Tootsies” (big, red) on {source} (small, source color), and a full-width price chart at the bottom — crisp thin STEP lines (_step, the prediction-market close-price look), dotted horizontal gridlines (_dashed_hline), each line ending in a glowing dot, volume bottom-left. Crispness is the point: the whole card is drawn at _SCALE×2 and LANCZOS-downsampled, so the thin lines + text are anti-aliased and sharp instead of jagged single-pass draw.line output; bold weight is a real bundled font (Liberation Sans Bold), not a stroke_width fake. Colors are a consistent semantic schema (_outcome_colors): the favorite (highest %) is green, the longest-shot red, the middle field cycles a DISTINCT palette (one color per line) so each line maps to its legend row by color. The y-axis auto-scales to the data range so movement fills the chart. A no-history market (no price series — Polymarket, or Kalshi without candles) renders horizontal probability bars in the bottom band (length ∝ implied %, color-matched to the legend, on a faint track) instead of the old dead flat lines, so the no-history card’s bottom half is a clean ranked magnitude strip rather than empty horizontals. Pure + dependency-light (Pillow only; the type is bundled Liberation Sans in assets/fonts/ — SIL OFL 1.1, full extended-Latin coverage so accented names like Pérez/Mbappé/Dončić render instead of tofu boxes (Pillow’s built-in Aileron default has no diacritics — the bug this fixed), real Regular + Bold weights, fail-safe to load_default if the file’s missing) and does no fetching itself (the cog/market_cards supplies the data), so it’s unit-testable. The runtime base is python:3.11-slim (no system fonts) — bundling the TTF (vs leaning on a system font that isn’t in the slim image) is what makes the diacritics fix hold in prod. ART FIT — a LOGO is CONTAINED, a photo is COVER-cropped (#wrong-moment): the portrait cover (render_market_cover) full-bleeds its subject art via _cover_crop (object-fit: cover), which is right for a poster/album cover/press photo but MANGLES a logo — a wide 960×260 wordmark scaled to fill the 3:4 frame is blown up ~4.6× and center-cropped, leaving one illegible letter filling the card with the title text stranded on top of it. So render_market_cover(art_fit="contain") instead keeps the branded dark panel and centers the mark WHOLE (_contain_fit, the aspect-preserving sibling of _cover_crop) in the band the art owns — below the wordmark, above the bottom-anchored content block (measured first, so the band’s height is known). market_cards._art_fit maps the resolved image RUNG to the fit (_CONTAIN_SOURCES = the logo rungs — wiki_logo + the raw team crest team_logo, whose square badge loses its top and bottom to a 3:4 crop; every photo rung stays cover), so it follows the resolver instead of guessing from pixels. Default is cover, so every existing caller is byte-identical. The BIG-NUMBER card (render_number_card) takes the SAME art_fit, because it takes the same resolved subject_png the cover does: the fit rule shipped on one renderer only, so an org/team-logo subject was still blown up and cropped on a number card (live-verified — the Netflix wordmark rendered as one illegible letter filling the card) even though the cover card contained it. One rule, both renderers; the contained mark is centered in the band between the wordmark and the figure’s top edge (measured first, same as the cover’s content block) — both call the ONE shared _place_contained_logo, so a logo reads identically on either card. A mark too DARK to read flips the WHOLE CARD LIGHT (#too-dark): containing the mark fixed the shape but not the CONTRAST, so a monochrome near-black brand wordmark (the Universal Music Group mark is pure black, as are UEFA’s and HBO’s) composited bare on the near-black card was INVISIBLE — a market alert shipped with an empty upper half and no sign of whose number it was. The first cut seated such a mark on the desk path’s rounded contrast plate (render_logo_card(plate=True), #1841) and was rejected on sight: a bordered box floating on a dark card reads as tacked-on (owner steer — “bordering is ugly, would rather a light card”). So instead the CARD carries the contrast: _needs_light_card (the mark’s OWN mean opaque luminance, floor _LIGHT_CARD_LUM_FLOOR — the same signal _plate_fill picks a plate color from on the desk path, asked one step earlier) flips the panel to the LIGHT palette (_branded_floor(light=True) + _LIGHT_*), and _place_contained_logo composites the mark BARE with no box anywhere. Both portrait renderers do it, since both take the same resolved subject image: the number card inverts panel/border/title/caption/figure/wordmark-surface/source-pill (and drops the figure glow — a near-black figure blurred over a light field is a smudge), the cover card additionally inverts the chart furniture (gridlines, bar track, legend label + %, volume). The accent TAG, the red wordmark and the red→green hairline are brand colors and carry over unchanged; a projection keeps its amber. Gated on luminance, NOT on the image rung, so it self-corrects for any future logo: live-swept against the real Wikidata marks, UMG/UEFA/HBO/Warner/Sony/Netflix go light while the NBA crest and Spotify keep the dark card (flipping those would be a needless white card, and a bare crest on dark is what #1841 deliberately ships). A light mark renders byte-identically to before on both renderers.entity_image.py, the SINGLE image resolver — image_subject (the ONE Haiku classifier, claude_client.image_subject → {kind, name, artist?, sport?}) + the _deterministic_art router serve BOTH the wire desks (resolve_desk_image, used by pop_desk/sports_desk) AND the market/betting cards (market_subject_image.resolve_market_image), replacing the old per-surface market_image_subject + music_release_subject extractors (both deleted). The classifier tells us WHAT the one visual subject is AND which authoritative source to hit; _deterministic_art(bot, subj) routes by kind: music_release → catalog COVER by ARTIST (resolve_catalog_art), Deezer ARTIST photo as the unreleased-album fallback; musician → the Deezer ARTIST photo (a music person lives in the music catalog, not a movie DB — the “what type of person lookup” distinction; the most-POPULAR name match by nb_fan, since Deezer’s search ranks tiny homonyms first, and the downloaded bytes are compared against Deezer’s no-photo placeholder — the CDN 200s a broken image hash with the same grey-silhouette pixels, the BTS-silhouette ship — with a placeholder match rejected so the walk falls onward); team → the API-Sports LOGO (api_sports.team_logo) composited into the broadcast card; person → the TMDB person PHOTO (tmdb.search_person); film/tv → the TMDB POSTER, with an OMDb poster fallback (omdb.poster, the “does OMDb have images” fallback for a title TMDB missed); org → the WIKIDATA LOGO (P154, wikidata.logo_image → wiki_logo) FIRST, falling back to the Wikipedia lead image (#wrong-moment): the lead image is the wrong picture for this kind twice over — a fair-use CORPORATE logo is excluded from Wikipedia’s pageimages entirely, so a major brand resolves to NOTHING (Netflix → no image, which fell all the way to the open-web search, which matched the Kalshi market page and put the market’s own QUESTION TEXT on the card), and where a lead image does exist it is routinely the headquarters BUILDING (P18) or, for UEFA, a member-associations MAP. Returned as a RASTERIZED Commons URL (?width=) since most logos are SVG, which the card renderer can’t decode. A bare logo NEVER ships plain (#card-logo-center, owner steer “add the logo to the center of a square tootsies card like we do with sports teams”): on the DESK path _card_bare_logo renders the resolved mark into the SAME square branded card a team crest gets (market_chart.render_logo_card — the mark centered on a radial glow in its OWN identity color + the tootsies wordmark; render_team_card is now the brand_suffix=" sports" flavor of it, and a sports desk’s org brands the same way), returning logo_card. An org mark rides a contrast PLATE (plate=True) where a team crest doesn’t, because an org logo is typically MONOCHROME and that breaks the bare card two ways — both caught by rendering the REAL Wikidata marks, neither catchable by a unit test: a BLACK mark (UEFA) disappears into the near-black card, and a single-COLOR mark (Netflix red) melts into its own same-color glow and swallows the red wordmark with it. So the mark sits on a rounded plate whose fill flips on the mark’s own luminance (_plate_fill: near-white for a dark/colored mark, charcoal for a light one — a white mark on white would vanish just as surely), with the glow damped since the plate now carries the contrast. A multi-colored crest reads fine bare, so the team card is byte-identical to before (plate defaults off) — the fix for a transparent-background mark (UEFA) crossposting to X as an EMPTY BLACK FRAME, and for a naked crest not reading as ours. Only the desk path cards: the market/betting cover calls _deterministic_art DIRECTLY and composites the bare mark into its own branded panel (art_fit="contain"), so it keeps receiving raw wiki_logo rather than nesting a card in a card. A render miss returns no art (falling to the vision search) instead of shipping the mark that just failed to decode; place/thing → the WIKIPEDIA article’s LEAD image (reference.wikipedia_lead_image, a bounded pithumbsize thumbnail — the canonical venue/landmark/festival/product/game photo or a governing body’s crest — the kinds with no dedicated catalog; org is a governing body/league/company that ACTED in the story (UEFA/FIFA/a label), the class that classified to nothing and shipped a bare tweet; Wikimedia requires the descriptive UA catalog_art._UA_HEADERS sends); a null/miss falls to vision (resolve_subject_photo) on the CLEAN entity name (not the raw hype tweet). RANKED SUBJECT LIST (#thin-ahead-art, owner steer “extract a list of subjects in relevance order and try the existing resolution on them in order — no new branch”): subject capture is ONE classifier call, claude.image_subjects — the RANKED list of the text’s visual subjects (main subject first, then the other entities with authoritative art), parsed by _parse_image_subjects — and resolve_desk_image is just a LOOP: each subject, best-first, runs the SAME full resolution (_resolve_primary, incl. its own open-web photo search — owner steer “multiple searches are fine, we want the best image”) until one yields art; an empty list runs the loop once subject-less (the open-web search on the raw headline, the old null path). The fix for the “Bilge Ebiri revisiting Scorsese’s The Age of Innocence” bare tweet — the critic (no photo) was the pick while the film’s poster sat right in the headline — and the whole “resolvable entity present but not the primary pick” class, rather than a per-mis-classification prompt rule. (Two intermediate designs were tried inside the same PR and folded away per owner steers: a separate image_alternates second query, then a deterministic-only cheap-tail walk.) Fully fail-open (a Haiku blip → the raw-headline search; no art at all → (None, None, "none"); the take then ships as TEXT on both surfaces — owner steer “if the post is good don’t skip”, so no boundary filter drops a good artless take). image_subject (the single-subject shape the market-card path + market_drop catalog hint use) is now a thin first-of-list accessor over the same one prompt, not a second query. RANKING an INDIVIDUAL-at-an-event (#wrong-moment): a red-carpet/premiere headline (“Zendaya at the Spider-Man: Brand New Day premiere in a Tamara Ralph gown”) ranked the FILM first, so the walk hit the TMDB poster on iteration 1 and shipped the MOVIE POSTER instead of a photo of her — the person was in the list but never reached (the existing #wrong-premiere-photo occasion-gate only fires once person is already the pick, so it couldn’t help). Fixed by GENERALIZING the classifier’s existing statement/quote rule (“a line that is a PERSON’S statement has that SPEAKER as its main subject”) to cover what a named individual said, did, wore, or showed up to — so the work/event they were there for goes SECOND — rather than bolting on a premiere-specific counter-rule (the docs/PROMPT_OPTIMIZATION.md discipline). The rule says “INDIVIDUAL … still kinded by who they ARE (musician/athlete/person)” because an earlier wording (“leads with THAT PERSON”) was read as the KIND person and misrouted a red-carpet MUSICIAN off the deterministic Deezer catalog. The paired examples deliberately show BOTH orderings (person→film for the carpet, film→person for the Ebiri essay) so it can’t over-correct into person-always-first. Dry-run-verified n=3 on the real Haiku classifier: baseline 0/3 on both premiere cases (film first), fixed 9/9 across the premiere cases + every control (essay-about-film, film news, music release, athlete, org). The market’s OWN artist beats asking the model (#thin-ahead-art): a Kalshi music market names the crediting ARTIST in its market SUBTITLE (":: Morgan Wallen" under the “I’m The Problem” leg), so market_subject_image._subtitle_catalog_hint reads {title=yes_label, artist=subtitle} straight off the payload and feeds the verified-cover rung — deterministic, no model. This replaced asking the classifier who made a record, which it answers from MEMORY and gets confidently wrong (“Lizzo” for Morgan Wallen’s album — shipped twice as a wrong-artist photo, since a missed cover falls back to photographing the artist). An upstream luminate hint still wins; a non-music subtitle yields None; resolve_catalog_art still verifies the artist, so a bad read misses to a photo, never a wrong cover. The wire desks resolve ONCE, pre-compose, on the wire headline (the ranked-list capture handles noisy text — the Ebiri essay classifies [film, Scorsese] at the source, so no post-compose take-retry branch exists; a residual miss ships as text, watched via the *_posted source=none rate); cinema_desk’s news lanes and discourse’s crosspost resolve from the composed take (their only text). The SOURCE photo’s gate is framed by PROVENANCE, not identity (#source-media-reuse): the wire’s own photo gets first refusal (#1832), but it was judged by the same test as an unknown open-web hit — “does this genuinely SHOW <entity name>”, with a hard reject bias — so the picture the wire published WITH the moment was thrown away whenever the subject wasn’t face-recognizable in it, and the desk went and found a stock portrait instead. A post about the first Street Fighter clip shipped a red-carpet headshot of Jason Momoa because the clip still of him in full Blanka prosthetics failed “is that Jason Momoa” (measured 0/5); the same framing killed a Rashford-holding-the-trophy photo on his Barcelona-goodbye post (0/5). So pick_subject_image(source_post=True) keeps the identical reject list but flips the default to TAKE IT, and judges against the MOMENT (the wire headline) for every kind — a real photograph counts in costume/character/prosthetics, as a film or clip still, masked, in kit, mid-action, or shot wide. The junk bar is unmoved and re-measured: a text card, a chart, a bare logo, and a rankings/stat table (even laid over a photo) all still reject 0/5, as does a real photo attached to a plainly different moment. Both tail clauses were ABLATED, not assumed (docs/PROMPT_OPTIMIZATION.md discipline, n=5 on the real images): the “rejects still stand / DESIGNED GRAPHIC” paragraph is LOAD-BEARING (without it a rankings table over a photo is taken 5/5), while a drafted clause saying a photo “with a caption/score bug/logo laid over it” counts was CUT as bloat-without-payoff — dropping it changed nothing (7/7 either way, a real photo under a “HERE WE GO” overlay still taken 5/5) and it was the clause pulling toward the rankings table the paragraph then had to push back on (two rules fighting). The ablation also caught a near-miss worth remembering: a first pass “proved” the paragraph was scar tissue, but it had been measured on a tidier moment string than production passes — feeding the real _clean_text headline flipped the verdict, and the leaner wording turned out to sit on a knife-edge (a trailing period alone flipped it 0/5 ↔ 5/5) where the shipped wording is stable in both forms. gated_source_photo also now takes EVERY fetchable photo on the post in the ONE existing call and returns the one it picks, so a multi-photo moment whose first still is the least legible isn’t abandoned on the strength of that frame (image_urls on each wire story; image_url stays the first for the single-photo callers). Measured on a live 20-post wire slate: the wire’s own photo lands 17/20 vs 10/20 (scripts/dryrun_source_photo_gate.py, the labeled 10-case A/B). Pure-ish router; unit-tested tests/test_entity_image.py.subject_image.py, the open-web photo finder (find_subject_image) — Grok + Perplexity candidates, interleaved, downloaded, then gated by a TWO-STAGE vision gate (fail-CLOSED at every step): the Haiku group pick RANKS (all candidates, one cheap call), then a SONNET single-image confirm_subject_image on the winner DECIDES; a confirm-rejected winner is dropped and the pick re-runs on the rest (a real photo may have lost the group rank to a flashy graphic), two rejects → no image. The two stages exist because they fail differently, measured separately on a saved corpus of real X candidates (#find-subject-image): shown N candidates and told to pick the best, Haiku picks the least-bad even when EVERY candidate is junk — that group dynamic is most of how the Grok leg’s revival shipped 42% junk (screenshots, a FanDuel betslip, stat graphics, a meme carrying a homophobic slur) and was REVERTED within the hour (#1894 → #1897). And even single-image, Haiku accepted a stylized fan graphic, a photo of a trading card in a slab, and a real photo the subject wasn’t in — 3/3 votes each — while Sonnet rejects all three (8/9) and keeps every real photo (12/12). The MODEL was the lever, not the prompt: the reject list already named those classes and Haiku accepted them anyway. The two stages share ONE prompt builder (_subject_pick_prompt) so they can’t drift; the reject list includes the merchandise/collectible class (a trading card in a slab, a vinyl being held, a shirt without the person — a photo of an OBJECT depicting the subject is not a photo of the subject), added measured: it took the card class from 1/3-flaky to 3/3 reject with all controls intact, and the source-photo eval stays 9/9 with its golden. The GROK LEG IS ALIVE ONLY BECAUSE OF THE CONFIRM — its query asks for POSTS ("recent posts with pictures of X"; the literal "a photo of X" reads to Grok as an image-generation request, it answers “I can’t display images” and never searches, returning zero 100% of the time — and plain “posts about X” surfaces photo-less posts, so the “with pictures” clause is load-bearing). If the confirm is ever removed, the leg goes back to dead (_grok_candidates’ docstring says so). Acceptance was judged on SHIPPED IMAGES, never a success counter (a counter cannot tell a photo of Ohtani from a slur meme, which is exactly how the #1894 regression shipped): 18/20 builds shipped with ZERO hard junk on the junk-prone slate, and the confirm loop’s cost is one/two Sonnet vision calls per build. The Perplexity leg is separately flaky UPSTREAM: return_images answers a valid code-built payload with HTTP 400 “invalid request” ~52% of the time in prod (115/223 calls, 108 of them 400) while every PROSE purpose on the same key fails ZERO times across 6,000+ calls. Not a malformed request, not the account, not the model (sonar vs sonar-pro measured identical), not the recency filter, and not our own burst (~16 calls/day). It IS partly time-sensitive: back-to-back 5/8 fail, 15s-spaced 2/8. The fix is ONE SLOW retry (_IMAGE_RETRY_DELAY_SECONDS=10) — measured, a retry at ~10s recovers 4/10 real 400s (end-to-end 4/14 → 8/14) while 0.5-3.0s recovers 0/2 and a further +30s attempt recovers 0/10, so there is deliberately no backoff ladder.market_art.py, Polymarket’s own curated leg portraits, as the LAST-RESORT image source for cards — rung 4b of the market_subject_image ladder, under the vision search and above the ungated native icon. What it’s actually for is DETERMINISM, not coverage — and that distinction is the whole finding (#poly-leg-art): the pitch was “it covers niche named people the catalogs miss”, and a first eval measuring the ladder ONCE per subject demolished that (0 of 14 sampled subjects would have hit the branded floor — the existing rungs placed all 14), while as a HIGH rung it displaced a working found result. But re-running the same ladder 3× per subject showed the rung above it is a coin flip: find_subject_image is non-deterministic, and for subjects that do have usable leg art it whiffed on 9 of 18 builds (50%) — the same subject resolving ['none','none','found']. So the card was going out bare half the time, and the leg portrait (deterministic, exact-name-matched, size-gated) fills exactly those. The n=1 eval hid it: this is the docs/PROMPT_OPTIMIZATION.md “n≈5 not n=2, report the spread” lesson applying to a resolver rather than a prompt. Demoting it is what makes it safe — down there it can only ADD art to a card headed for the floor, never take one away, and the Gamma search only spends on that same failure path instead of on every build past the catalogs. Polymarket, NOT Kalshi: Kalshi exposes no art at all (verified against 200 open events pulled with nested markets — zero image URLs, no image field on the event/market/series shape or in its undocumented v1 API; the thumbnails on kalshi.com are frontend-only), so this borrows Polymarket’s art for ANY market card including a Kalshi-sourced one. Three guards, each closing a paid-for failure: (1) distinct from the event icon (distinct_leg_images) — every leg has an image but on most markets it’s just the EVENT icon repeated (one League of Legends logo across all 33 legs, one crude-oil icon per price bucket; measured on the top 100 open events by volume, only 4 carried genuinely distinct per-leg art, all 4 named-entity fields), so without this the “backup” would mostly re-serve the generic native-image rung; (2) exact name match (find_leg_image, accent/punctuation/case folded) — a loose match is how the wrong person’s face reaches a card, the axis this resolver documents as its recurring bug class, so a near-miss falls through instead; (3) minimum resolution (big_enough, MIN_COVER_PX=400 on the SHORT edge) — leg art runs 183×275 to 3500×1741 in practice and the portrait cover is a full-bleed 1800×2400, so a 200px source renders visibly soft, trading a clean branded card for a blurry one; live-verified that a 400×400 source still renders sharp while the 199×254 Ethiopia thumbnails are correctly refused. The size gate is also WHY the rung fires rarely, and the tension is structural: a subject niche enough that TMDB/Deezer miss it tends to have TINY leg art (the Ethiopian field runs 183-200px) which the gate then refuses, while the legs carrying big art are the famous politicians TMDB already has — so the win is the flaky-vision backstop above, not raw coverage. One keyless Gamma search per lookup, wrapped in the shared retry_http — Gamma throttles under a burst and a throttled call is indistinguishable from a genuine no-match (both empty), which silently turned a 429 into “no art” until the retry landed. Pure matching + the size test unit-tested; live-verified against the real Brazil/Ethiopia/2028 fields.market_subject_image.py, the card BACKGROUND-image resolver for every market/betting/desk card (resolve_market_image → (png, source)), and the home of the standing image rule: a DETERMINISTIC source per kind FIRST, an open-web vision photo search LAST (owner steer, applies to every desk + genre from now on — a wrong/misleading image is worse than a plain one). The ladder, best-first: (1) structured catalog_hint {title, kind, artist} → utils.catalog_art.resolve_catalog_art (music→iTunes/Deezer cover by ARTIST, movie/tv→TMDB poster); (2) structured game_teams {home, away, sport} → _resolve_matchup_image → API-Sports team LOGOS composited by utils.market_chart.render_matchup_card into a broadcast half/half card (each half a soft blurred wash of that team’s OWN logo, real colors, no guessed hue; logos via utils.api_sports.team_logo); (3) the unified classifier — with no structured hint, hand image_subject the combined extraction text (question + leading answer + event/ticker context) and route its result through entity_image._deterministic_art (person→TMDB, musician→Deezer, music_release→catalog, film/tv→TMDB+OMDb, team→logo); (4) vision (utils.subject_image.find_subject_image, Perplexity+Grok, vision-gated by pick_subject_image which rejects a map/infographic/boxscore/odds-graphic/wrong-artist) — on the CLEAN name; (4b) a PREDICTION MARKET’s own curated LEG portrait (utils.market_art, see below — the LAST-resort deterministic backstop under the non-deterministic vision search); (5) native image; else the branded floor. The two failure axes to check on any card (the audit lessons): WRONG SOURCE (a sports game hitting vision instead of API-Sports — the Spanish GradaMas58 boxscore fix) and WRONG SUBJECT (a clean source fed a polluted query — the Charli “Music, Fashion, Film” Kalshi-category-as-album, stripped in utils.luminate._subject; the Big Brother contestant-as-show). The betting surfaces (betting_board/betting_alert/betting_value) all stamp game_teams so a game card gets logos first; cinema_desk/music.py use catalog/TMDB with clean titles. A market with NO subject skips rung 4 entirely (#question-card): when the classifier names NOTHING — its documented verdict for “an abstract trend/topic naming no imageable entity”, the honest read of “Will the #1 Movie on Netflix have at least 21 million views?” (no film is named) — rung 4 would search the open web on subj, which with no classified subject is the RAW MARKET QUESTION. The web’s best literal match for a market question is the MARKET: the exchange’s own OG preview card (kalshi.com/api-app/preview/...), which renders that exact question as text over a price line, and it shipped as the background cover-cropped into an unreadable slab of the question. The gate is not the thing to fix (measured n=5 on the real card): judged against a CLEAN entity it rejects that card 5/5 even as the lead candidate beside a real photo, and 5/5 alone — it only takes it when the SUBJECT IS THE QUESTION, where the card is the one literal match. So the QUERY is the bug and a counter-rule in pick_subject_image would be scar tissue for a case that stops arising. No subject → no photo hunt → the branded floor, the same logos-or-the-branded-floor discipline a game card already follows. A classifier BLIP lands here too (the client swallows its own errors, so an empty result is indistinguishable from a real empty verdict) and that’s the safe direction: a miss is never cached, so the next build re-resolves. Verified end-to-end on the live markets — the reported card resolves to the floor, its #1 Show sibling to the Netflix wiki_logo, and the film/person/musician/team controls keep their art (deterministic art wins for person/musician; a team still reaches the search on the clean name). The durable per-market image cache namespace is VERSIONED (market_subject_img_v6) — bump it on any resolution-logic change so cached garbage re-resolves on deploy (v6 flushed the preview cards the raw-question search had already cached, which the Netflix-views alerts kept re-serving). Emits market_subject (source rung + cache_hit).github.py, GitHubClient for filing issues/PRs via the GitHub APIrailway.py, Railway API for /undo rollbackshealthcheck.py, aiohttp server at /health (Railway healthcheck). Also optionally exposes two token-gated debug routes, both disabled unless DEBUG_QUERY_TOKEN is set: POST /debug/query (#652), a read-only single SELECT inside a READ ONLY transaction with a statement timeout — so it physically can’t mutate data — the sanctioned way to inspect prod DB state from a Claude-on-the-web session, whose sandbox proxies all egress over 443 and so can’t open a raw Postgres socket on Railway’s public proxy port; and GET /debug/integrations, the generic outbound-integration smoke test (utils.integration_probes) — probes every (or a ?names= subset of) upstream from the bot’s own datacenter IP and reports provisioned/reachable/ok per source, the only way to tell a real upstream outage from a Claude session’s own egress block; and GET /debug/usage (#727), the metered-API usage/quota read — pulls SGO’s entity cap (/account/usage, quota-exempt), API-Sports’ daily request cap (/status), the Odds API credit budget, and Highlightly’s daily cap (passive headers) off the bot’s own clients and returns spend-vs-cap per source plus a low rollup, so “what’s our usage / are we near a cap” is one authoritative read instead of inferring from 429s.usage.py, the cross-integration usage/quota core (#727, extended across every metered integration in #753): pure per-source parsers (parse_sgo_usage over /account/usage’s requests+entities rate-limit tree, parse_api_sports_status over /status’s daily requests, odds_usage_report over the captured credit headers, highlightly_usage_report over the passively-captured daily headers, parse_elevenlabs_usage over /v1/user/subscription’s character_count/character_limit monthly cap (the voice-budget SGO-entities analog), parse_github_rate_limit over /rate_limit’s REST-core used/limit hourly budget, **giphy_usage_report over the passively-captured X-RateLimit-*-Day daily search cap, openai_usage_report over the passively-captured per-minute x-ratelimit-*-requests RPM) → a normalized UsageReport/UsageMetric (used/limit/remaining/pct, is_low at LOW_PCT=0.85), plus the fail-open async collect_usage that reads every provisioned source by its cheapest authoritative path. Consumed by the health-watch quota poll, the /debug/usage endpoint, and (the pure parsers) unit tests. The single home for “how much of each metered budget is spent” — the answer to the SGO-entity-cap blind spot that took /bet dark. Coverage (#753 epic): the keyed metered sources with a readable budget are polled (SGO, API-Sports, Odds API, ElevenLabs, GitHub, twitterapi.io — its prepaid credit balance via parse_twitterio_usage over /oapi/my/info, #1255; a prepaid wallet, so the metric carries the remaining balance with limit=None and the low signal is a balance floor flagged by the ops-monitor, not a pct) or captured passively (Highlightly daily, Giphy daily, OpenAI per-minute RPM); Giphy’s search endpoint returns NO X-RateLimit-*-Day headers in practice (verified live 2026-07-10, on cache HIT + origin MISS), so collect_usage SKIPS Giphy when the passive capture is empty rather than emitting a recurring ok=false error=no_headers quota event every tick (#1118) — self-healing if Giphy ever starts sending them; OpenAI’s per-minute window is informational only (period="minute" — the ops-monitor renders it but never flags quota_low, since a rolling rate can’t fill ahead of time; its hard signal is the 429 in integration-health). Sources with no machine-readable budget (Perplexity prepaid balance + Genius, both dashboard-/limit-less; OpenAI $ spend, which needs an org Admin key the owner declined) are covered reactively by the existing integration-health rates + auth probes, not polled.integration_probes.py, the /debug/integrations registry: one Probe per outbound integration (Genius, Apple Music, OpenAI, ElevenLabs, Perplexity, Giphy, API-Sports, SGO, twitterapi.io, The Odds API, Polymarket, Kalshi, Highlightly, MusicBrainz, Wikipedia, Wikidata, GitHub, Railway), each the cheapest real read that proves host reachability + (where keyed) auth. Returns ProbeResult(provisioned, reachable, ok, status, duration_ms, detail) — reachable isolates the network axis (DNS/egress/timeout) from ok (status<400, the auth/quota axis). PROBES is the extension point; reach_only=True marks a POST-only host where reachability is the honest ceiling. Fail-open, bounded by a short per-probe timeout, runs all probes concurrently, and emits no events (diagnostic-only, must not skew ops-monitor health rates).polls.py, the pure validate/normalize/format core behind the polls cog (#888): build_poll_spec (clamps a raw request to Discord’s 2-10 options ≤55 chars / question ≤300 / duration 1-768h, recording what it trimmed in notes), normalize_options (strip/dedupe/cap), clamp_duration + parse_duration_hours (“3 days” → 72), to_discord_poll (the one discord touch — building a discord.Poll value object, no network), and format_poll/format_poll_list (render live results + the recent-poll list). Side-effect-free so it’s unit-tested with no live client; the discord I/O lives in cogs/polls.py (mirrors the utils/starboard.py split).discord_info.py, the pure core behind the read-only discord_lookup cog: the row shapes (MemberInfo/ServerInfo/RoleRow/ChannelRow/ReactorRow/PinRow), the format_* renderers (member/server/roles/role-members/channels/emojis/reactors/pins), and humanize_age (compact “3y 2mo” duration math). ASPECTS is the dispatch set; the MAX_* constants the list caps. Side-effect-free + unit-tested; the discord I/O (reading guild/member/channel objects, name resolution, reaction/pin fetches) lives in cogs/discord_info.py.scheduled_events.py, the pure core behind the scheduled-events cog (#888): build_event_spec (validates name/future-start, defaults end to +1h and location to TBD, clamps to Discord’s limits with notes), build_event_edit (a sparse {field: value} of only the provided edits for ScheduledEvent.edit), parse_event_time (ISO 8601 / unix / naive → aware UTC), and format_event/format_event_list. Side-effect-free + unit-tested; the discord I/O (create/edit/cancel/fetch, all needing Manage Events) lives in cogs/scheduled_events.py.message_search.py, the pure core behind the message-search cog: the SearchQuery/MessageFields shapes (the latter carrying the RICH per-hit detail — body, flags, reaction tallies, attachment (name,kind,url) rows, embed titles), the pure message_matches(fields, query) predicate (AND across filter categories incl. from_type + role/everyone mentions, AND within keywords), the model-arg parsers (normalize_keywords, parse_has with friendly aliases, parse_author_type, parse_order, parse_date for ISO/YYYY-MM-DD/epoch, clamp_limit), the is_empty() constraint guard, sort_results (newest/oldest), and format_results (a rich multi-line block per hit + a by-author/by-channel breakdown header). HAS_KINDS/AUTHOR_TYPES are the supported filter vocab; CONTENT_CHARS + the scan bounds (GUILD_SCAN_*, SINGLE_CHANNEL_SCAN) the caps. Side-effect-free + unit-tested; the discord I/O (name→id resolution, channel/thread/forum walks, has: kind + reaction + attachment extraction off a discord.Message) lives in cogs/message_search.py.social_search.py, the unified cross-platform social DISCOVERY core (#1272): one normalized SocialResult shape (platform/title/link/author/stat/published/engagement) + pure per-platform mappers (tiktok_search_result over /v1/tiktok/search/top items[] + the /v1/tiktok/get-trending-feed aweme_list[] — id/desc/statistics/create_time with _tiktok_date normalizing the unix-int-on-trending vs ISO-on-search split, canonical tiktok.com/@handle/video/id link fallback; youtube_search_result over /v1/youtube/search videos[] + /v1/youtube/shorts/trending shorts[] — id/title/channel/viewCountInt/lengthText-or-durationFormatted; instagram_reels_result over /v2/instagram/reels/search reels[] — id/caption/owner.username/like_count/comment_count) + three async orchestrators: search_socials (keyword search, TikTok + YouTube + Instagram) + search_hashtag (the #-tag sibling, sharing a _keyword_fan_out helper) + discover_trending (no-keyword trending, TikTok region-scoped feed + YouTube shorts), each fanning out via the ScrapeCreators client, MERGING + engagement-ranking, rendered by format_social_results (title · @author ✓ · stat · duration + link). Each SocialResult also carries a duration (TikTok video.duration ms / IG video_duration s / YouTube lengthSeconds, rendered once as m:ss) + a verified author badge. _SEARCH_ENDPOINTS/_HASHTAG_ENDPOINTS/_TRENDING_ENDPOINTS are the extension points (add a platform’s path/param/key/mapper). Fail-open per platform (a source miss contributes nothing), emits social_search. Mappers + formatter + orchestrators are pure/unit-tested (tests/test_social_search.py); the ScrapeCreators fetch is guarded in utils/scrapecreators.py. Wired as the /ask search_socials + discover_trending tools (handlers cogs/ask.py:_search_socials/_discover_trending) so the model finds fresh social content uniformly — the “search all social platforms uniformly” ask.playercard.py, the PURE scoring core behind the /card cog (#570), no I/O: takes a guild SNAPSHOT (every active member’s raw PlayerStats) and renders one member’s Card (OVR + tier + three percentile-ranked RATINGS + badges + archetype + chase line). _STATS is the single source of truth for the named face-stats (key/label/emoji/blurb/weight/terms) — the OVR weights, the rubric (rubric_lines), and the card labels (stat_labels) all derive from it. Only the 3 RATING_KEYS (clout/humor/social) are percentile-ranked + feed the OVR (face_stats/overall_rating/rubric_lines default to them); the other dims (presence/taste/degen) are NOT ratings — archetype_for reads the wider ALL_STAT_KEYS so a one-lane regular still gets their label (a degen cards “The Whale”), and their raw numbers surface in the tape/badges. pct_rank (ties-split percentile) makes every number relative to the room. CLOUT = the three per-post RATES (reactions_per_msg + replies_pulled_per_msg + mentions_per_msg) blended with the three received TOTALS (reactions_received + replies_received + mentions_received) — six inbound signals balancing rate + volume; HUMOR = laugh_share + bangers (the #570 rework — magnitude vs flavor; the three ratings are weighted EQUALLY, 33/33/33). Also exposes the PURE detail_pages(tape) (→ CardPage/StatLine): the four scrollable detail pages that cover EVERY counted stat, formatting a RESOLVED tape dict (skip-zero, name/dict/date/duration helpers) — the cog supplies the Discord render + name resolution. Plus the PURE about_member_text(summary, names) for the memory hype-up — keeps only the sentences of a memory note that NAME the member (word-boundary, NFC, case-insensitive over every alias), so a whole-room note collapses to its about-them signal — and spread_evenly(pieces, budget, max_items=), which picks a budget-fitting subset of the time-ordered notes spread EVENLY across the range (anchored on the first), so the hype-up reflects the member’s whole arc from their first days to now, not just the recent stretch. Fully unit-tested in tests/test_playercard.py; the data-gathering + Discord render live in cogs/playercard.py.engagement.py, the shared engagement-scan engine (#570) — the per-message tally /stats (cogs.stats, the full-history scan) runs to build the seed /card reads, so the counting logic lives in ONE place. The cog walks each channel oldest-first and calls ScanState.tally_message(msg, channel_id=, start=, end=); because the walk is ordered + intra-channel, a reply’s target is already seen, so replies + reply-latency + the conversation-flow stats (closer/reviver/burst) resolve INLINE. ScanState.finalize then folds embed-fixer webhook reposts onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s — the proven utils.starboard name-fold; a genuine bot like MEE6/Toots is skipped) and emits the per-user stat BLOB the seed stores (a JSONB dict, so a new stat is just a new key — no migration). The blob is comprehensive: volume + cadence (messages/chars/longest-message/days/longest+current streak/longest-absence “ghost”/first+last/hour+weekday+monthly histograms/busiest-month/busiest-day/late), reactions (split laughs→HUMOR vs hype→CLOUT, by-emoji, bangers, top post; bot reactions are excluded — Toots’ own via r.me (she reacts via chime-in) AND MEE6’s music-link auto-reaction via fingerprint (mee6_autoreaction_counts mirrors MEE6’s owner-confirmed, live-verified automation: it reacts 🔥 + 🆒 + ✌️ — all unicode, the automation UI’s “:cool:” is the unicode 🆒 (U+1F192), NOT a custom emoji — on any message carrying a music domain, so a trigger-domain message carrying all three has one of each subtracted) — a bot’s reaction must not inflate a member’s reactions_received/laughs/bangers; both are free (no per-reactor users() fetch, prohibitive over a ~300k-msg rebuild) and SAFE (requiring all three emoji means a music post lacking the full fingerprint is never touched, so it under-corrects rather than ever stripping a real human reaction; a dry run over 200 real music posts confirmed 80/93 fire)), social (replies sent/received, ride-or-die outbound + top-fan inbound + best-duo, mentions made/received + distinct-mentioned reach + #1-mention, most-replied post, quickdraw + avg latency), flow (closes/revives/bursts), content persona (links bucketed apple_music/spotify/youtube/x/tiktok/instagram/gif/image/video/other — gif = animated shares (tenor/giphy/direct .gif), image = a pasted still picture (imgur/direct .png/.jpg), video = a raw clip URL (streamable/direct .mp4), each distinct from an attachment which is counted separately as images_sent/videos_sent — attachments split images/videos/files + stickers sent, voice notes sent, forwards + cross-server forwards (the “mole”, a native Discord forward whose source guild differs), got-pinned, first-of-day opener, signature emoji via own-text emoji scan, total emoji-messages + emoji count, questions, caps, edited), roasts (#570: own-laugh rate per message via is_own_laugh, left-on-read = posts with no reaction AND no reply), and per-channel (channel_messages + home channel). The per-message trackers behind most-replied / left-on-read are bounded (only posts that GOT a reply or reaction are held, not every message). IDs of other people (ride_or_die/top_fan/best_duo/#1-mention) are stored so the reader resolves the current name. Pure, unit-tested in tests/test_engagement.py. Incremental catch-up (#570): every acc field is additively mergeable (it’s how the fixer fold works), so the seed stores a round-trippable durable merge state beside the display blob and a CATCH-UP scan folds a delta into it via the same EngagementAcc.merge. The public surface: finalize_accs() (the folded {uid: acc} + token→uid resolver, the raw material), acc_to_state/state_to_acc (the JSON-safe round-trip — reply_targets/repliers resolved token→uid, names dropped), merge_into_state(stored, delta_acc, resolve) (the fold: stored state + delta → one merged uid-keyed acc), and seed_row(uid, acc, resolve?) (→ {"display", "state"}; resolve defaults to identity for an already-merged catch-up acc). ScanState(opened_through_ordinal=) is the catch-up’s first_of_day guard. The member_engagement seed is the canonical per-user engagement store going forward — new per-user engagement features read it, and the message-engagement counting lives HERE in one engine (don’t re-implement a tally elsewhere). The starboard reactions + bangers boards (in the /leaderboard stats hub) now read this seed directly (reactions_received / bangers, no scan; #957, #949 closed) — the only #wall-of-fame SCAN left is /star + the /card’s real-MEE6 “wall bangers” flex (lifetime_totals), which need the actual wall the seed can’t capture.engagement_window.py, the pure windowing core for weekly/monthly engagement stats (#1017). The seed is all-time cumulative, so a windowed view = the seed MINUS a daily SNAPSHOT from N days ago (db.engagement_snapshots, frozen by the /stats daily catch-up). Period (all_time/weekly/monthly, .days = None/7/30, .label); window_display (per-member numeric counter deltas, clamped at 0 — bangers/reactions/the OVR rating inputs); window_state (the reply_targets/mention_targets graph deltas for windowed Besties); extract_display/extract_state (unwrap the {display, state} snapshot blobs); and reconstruct_snapshot (the INVERSE, for the /stats period: backfill — window_display/window_state over (current, scanned_delta) to build a past-dated snapshot whose forward diff reproduces the window). No I/O — the cog reads the live seed + get_engagement_snapshot_near and hands the blobs here; the SAME board builders rank over the windowed blob exactly as over the all-time seed. One daily snapshot serves BOTH windows by subtraction (no extra scan jobs); forward-looking (no snapshot yet → empty window, never all-time mislabeled). Unit-tested in tests/test_engagement_window.py.awards.py, the pure award-selection core behind the weekly/monthly awards cog (#1017). Side-effect-free: the cog builds the windowed boards (utils.engagement_window) + the windowed OVR ranking (utils.playercard) + closest-pairs (utils.social_graph) and hands the already-ranked inputs here; compute_awards(keys, period, …) picks each award’s winner under two dedup layers: cross-category (_pick_single’s taken set — a person is never crowned two single-winner awards in ONE post, so nobody sweeps; keys order is priority, so the headline takes the top contender and later awards fall to the next distinct one; an award with no untaken candidate is omitted) + rolling anti-repeat PER CATEGORY (recent_winners[key], the set the COG feeds — the last few winners OF THAT AWARD, so a person can’t repeat the SAME award for a few periods but CAN win a different one — skipped for a fresh runner-up, falling back past it only if every untaken candidate is recent). AWARDS is the registry (key/emoji/title/blurb/metric; AwardSpec.title(period, month_name) fills a {month}-templated title): WEEKLY_KEYS = (mvp,) (the mvp award titled “Player of the Week” weekly) vs MONTHLY_KEYS = (mvp, funniest, besties) (the mvp titled “{month} MVP” monthly, e.g. “June MVP”) — both the SAME engagement metric, NO OVR/”Player” award. A metric award ranks a windowed display counter, or a +-joined SUM of them (reactions_received+replies_received → MVP = most engagement, the detail breaking the sum down “4,931 reactions · 812 replies”; laugh_reactions → Funniest); the one rating-style special is besties (windowed closest pair — a relationship axis, so it doesn’t claim taken and softly prefers a pair bringing a fresh face). bangers (wall-clearing posts) stays defined + tested but is off the live key sets; the player/OVR + breakout AwardSpecs were removed (no OVR/Player award — OVR is the /card rating; breakout never live). Unit-tested in tests/test_awards.py.resumable_scan.py, the shared resumable-long-scan spine (#570) — the foundation for a backfill that walks a lot of history UNIT BY UNIT and must survive an interruption (a redeploy SIGTERM, a Railway restart, an OOM, a transient snag). A scan that holds everything in memory and writes once at the end can run far longer than the gap between redeploys, so it may NEVER finish — each attempt is a gamble against the next deploy (exactly what stranded the /stats rebuild over the ~300k-message server). It wraps a scan into UNITS (a channel for /stats; a day for /remember, the planned PR-B adoption) processed one at a time and CHECKPOINTED as done, so an interruption loses only the in-progress unit, a resume skips the done ones, and memory stays bounded. ScanProgress (a done-unit set[str] + a small meta carry, e.g. the high-water snowflake) is the pure shape; parse_progress/encode_progress are the jsonb-safe round-trip; ResumableScan(db, guild_id, kind) is the per-(guild, kind) checkpoint over the settings KV (load/mark_done/update_meta/clear, plus mark_done_local → the encoded value for the atomic path where the caller persists the checkpoint inside its OWN transaction, alongside the unit’s data, so a fold + its checkpoint commit together and an interruption can’t double-count). One tiny per-(guild, kind) settings row, no new table. Pure + unit-tested (tests/test_resumable_scan.py); the feature supplies the per-unit scan+persist and calls mark_done after each unit lands durably. (#570’s /stats is the first adopter; memory’s /remember backfill is the planned second, hence “shared”.)/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:
Tunable to utils/tunables.py:TUNABLES (with group/mood/field if it should merge into a multi-field row) + a live resolver beside the others. The tune editor (cogs/tune.py) renders TUNABLES automatically as modal-rows — no UI code, no settings.py change. This is the default and lightest path.discord.ui.ChannelSelect/RoleSelect on a MenuView page (the _DiscourseSelect/_WatchedSportsSelect pattern).discord.ui.Select. It can’t be a tune modal-row (Discord modals are text-input-only, no dropdown), so it’s a select either on a MenuView page OR on the tune view’s reserved top row (the _AskModelTuneSelect precedent — keeps it grouped with the other tunables).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):
discord.ui.Select whose callback sets self.parent_view.selected["<key>"] = ... and await self.parent_view.autosave(interaction, "<key>").add_item(...) it on a page’s _render_page branch (mind the row budget; the nav row is always the last)._load_initial_state into state["<key>"].elif key == "<key>" branch in autosave’s persist step (most write db.set_setting)._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).
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).