a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
CLAUDE.md carries the binding rules (the operability bar, the field-budget core, the
ops-monitor summary); this file is the full narrative those rules compress – the
complete field-budget history + tooling, and the ops monitor’s findings catalog.
The DETAILED event ledger (the full table) lives in this file; utils/events.py’s
module docstring carries the compressed quick index – keep both updated.
Every metric-worthy thing emits a JSON event line via utils.events.emit(kind, **fields).
Each line is prefixed with the literal EVENT so Railway log queries can isolate
dashboard data from operational logs.
Existing event kinds – the DETAILED ledger (the compressed quick index lives in utils/events.py’s module docstring; keep BOTH updated when adding an event):
| event | source | fields | |
|---|---|---|---|
command |
utils/metrics.py (@track_command) |
cmd, user_id, guild_id, duration_ms, ok, error | |
claude_api |
claude_client.py (_call) |
model, purpose, input_tokens, output_tokens, duration_ms, stop_reason, ok | |
openai_api |
utils/openai_chat.py (complete/create_response) |
model, purpose, duration_ms, ok; on success: input_tokens, output_tokens, stop_reason, response_chars, response_preview (Chat Completions); Responses round-trips carry responses=True; on failure: error (HTTP <status>/no_text_in_response/<exc>), detail, category (#973 — the failover class: provider/quota/auth/request/malformed; provider+quota fail over to Claude, the rest surface; category=quota = OpenAI out of credits, a 429/insufficient_quota NOT retried), detail — one OpenAI call, the optional OpenAI TEXT backend (#openapi-parity), the provider twin of claude_api for a surface routed to a GPT model (cost-spread / Claude-credit failover). Two paths: Chat Completions (tool-free) + the Responses API (tools/web_search, one event per round-trip). Ops-monitor keys these openai:<purpose> (parity with claude_api’s per-purpose split) so each GPT-routed surface’s cost/latency stays separately visible |
|
provider_fallback |
claude_client.py (_call) |
purpose, requested (the model that failed), fell_back_to, reason — one provider failed and _call failed over to the OTHER (#openapi-parity, now BIDIRECTIONAL). FORWARD (a GPT surface → Claude): fell_back_to = the GPT’s Claude sibling (gpt-5.5→Opus, gpt-5.4→Haiku; an unmapped id→the frontier Opus), reason = the failure CATEGORY (#973: provider/quota/unprovisioned — the FALLOVER classes; an auth/request/malformed OpenAI failure is NOT failed over, it re-raises so the surface’s own canned fallback handles it, the mirror of the reverse direction re-raising a Claude 400/401) — the graceful degradation that makes gpt safe as a per-surface menu choice. REVERSE (a Claude surface → OpenAI, #973): fell_back_to = the Claude’s OpenAI sibling (Opus→gpt-5.5, Sonnet/Haiku→gpt-5.4; an unmapped id → the frontier gpt-5.5), reason=credit_exhausted (the Anthropic balance ran dry — a 400 naming the credit balance, permanent until topped up) or claude_unavailable (a persistent 529/5xx/429/timeout that survived the retry loop) — credit-exhaustion failover, so the bot keeps talking instead of going dark on every surface at once. Each direction calls the OTHER provider’s method DIRECTLY (never back through the router), so the two can’t ping-pong. A rising FORWARD rate = OpenAI flaking; a rising REVERSE rate = Anthropic out of credits / overloaded. (Vision is served on the Responses path now, so it no longer triggers a fallback by itself.) |
|
order_state |
cogs/order.py | order_id, issue_number, guild_id, user_id, from, to | |
order_served_ping |
cogs/order.py | guild_id, user_id (the reporter), order_id, issue_number, delivered, reason (None on delivered; no_guild/no_channel/no_perms/bot_off/send_failed) — when a reconcile flips an order to SERVED, Toots pinged the original reporter back in the channel they filed it from (“completing the circle”). Fires once per order (guarded by orders.served_pinged_at); fail-open, honors the master kill switch (bot_off leaves it pending so it lands once she’s back on) |
|
rate_limit_hit |
utils/rate_limits.py | scope, command, user_id, guild_id, count, cap | |
deploy_event |
bot.py | kind (boot/shutdown), guilds | |
guild_join |
bot.py (on_guild_join) |
guild_id, ok, + folded detail (name, members, owner_id, inviter_id) — Toots was ADDED to a server. Captures the guild NAME + who added her (the facts that were unrecoverable once she’d left an unknown server), AND pages the bot MASTER in the MASTER guild’s #bot-logs (utils.permissions.MASTER_GUILD_ID) so an unrecognized add is surfaced the moment it happens, not discovered weeks later from a stale DB row. Fully fail-open |
|
guild_left |
bot.py (on_guild_remove) |
guild_id, ok, + folded detail (name, members, owner_id) — the mirror of guild_join: Toots was REMOVED from a server. A durable record of WHEN she left + a master #bot-logs page |
|
error |
cogs/* + bot.py error handler | source (e.g. ask, order_preflight, undo), error (exception class), guild_id, user_id, optional context |
|
recap_deflected |
cogs/recap.py | guild_id, user_id, period, channel_id, channel_name, reason (no_permission/no_messages), can_read_history, total_messages |
|
discourse_fallback |
cogs/discourse.py | guild_id, user_id, category, source_count, recent_topic_count, reason | |
discourse_scored |
cogs/discourse.py | guild_id, channel_id, score, reason, must_post, category, user_id, post_preview; on category="trending" also (#1272 silent-degradation tripwires): shipped (bool — the trending-clip reaction cleared its high solo bar and won the slot vs fell through to a news take), platform (the clip she reacted to), framed (# pool clips that carried a vision cover frame — a framed=0 while TikTok is in the pool means the frame plumbing broke, e.g. TikTok changed its cover-URL shape or Anthropic stopped fetching tiktokcdn, so she’s back to reacting caption-blind), pool (the balanced platform mix, e.g. instagram:2,tiktok:2,youtube:2, confirming the per-platform balance holds). #trending-visibility: it ALSO fires on the PRE-score DECLINES (shipped=False, score=0.0, reason=no_clips|model_declined|error) so EVERY trending attempt is countable — the ops-monitor renders a “Surface decisions” line (news vs trending shipped + the decline breakdown) and flags trending_dark when trending is attempted repeatedly but never ships on a fixable reason (error = the GPT-vision Responses 400 that took it dark; no_clips = the gather finds nothing on-theme; model_declined is by-design restraint, never flagged). The GPT-vision failure now degrades caption-only in react_to_trending (drop the frames + retry once) rather than falling through to a news take every slot. |
|
discourse_dedup |
cogs/discourse.py | guild_id, channel_id, decision (similarity_gate), signal (same_link/text_similarity/shared_run/content_overlap), post_preview |
|
topic_dedup |
cogs/scheduled_poster.py | surface, guild_id, channel_id, reason, post_preview — the semantic TOPIC-dedup gate (a cheap Haiku judge, claude_client.topic_duplicate) SUPPRESSED a post as a RESTATEMENT of something already shipped. Opt-in via ScheduledPoster.TOPIC_DEDUP on the proactive news/market surfaces (sports_desk/pop_desk/cinema_desk/cinema_news/music_desk/music_news/market_drop); runs AFTER the mechanical duplicate_reason and catches the same STORY re-angled that order-sensitive token-overlap misses (a signing posted 3 ways scored 0.33-0.46 overlap; “72 Hours” across sibling Netflix boards 0.47 — both real prod cases). Policy (owner steer “allow real updates only”): a restatement carrying no materially-new info is suppressed; a genuine DEVELOPMENT (a new number/party/resolution) or a different subject is ALLOWED (the judgment tokens can’t make). Emitted ONLY on suppression (its existence IS the suppression); the per-check count + latency ride claude_api purpose=topic_dedup, so suppress-rate = topic_dedup events / topic_dedup calls. FAIL-OPEN (a Haiku miss/error lets the post through, never swallows a real update). The mechanical content_overlap (#1681) stays the free deterministic first pass; this is the semantic backstop for the low-overlap re-angled cases it misses. |
|
discourse_skipped |
cogs/discourse.py | guild_id, channel_id, reason (rate_limited/compose_error/empty) |
|
discourse_icebreaker |
cogs/discourse.py | guild_id, channel_id, category (ranking|hot_take|this_or_that|confession), score, reason, shipped, manual, trigger (fallback|quiet_room|manual), has_memory, has_topical, has_market, post_preview (a linkless opener; material-only, so a slot with no source material skips with reason=no_material; reason=room_unanswered = the human-activity floor held it back, fewer than MIN_HUMAN_MSGS_BETWEEN_ICEBREAKERS (3) human messages have landed since Toots’ own last post in the channel, so a second unanswered opener can’t stack on the first (enforced once in _icebreaker_fallback via a live channel-history read, shared by the scheduled + quiet-room paths, restart-proof; manual bypasses it); market line reused from compose or sports-room-gated. trigger names the source: fallback = an otherwise-empty scheduled discourse slot fell back to one; quiet_room = cogs.chimein broke a silent room (the inverse of a chime-in: fired off the chime-in tick when a listen channel went QUIET_AFTER with no human message, riding the chime-in mood/hours/cooldown/cap gates, one opener per lull); manual = on-demand /discourse category:icebreaker (also manual=True). icebreaker is a permanent surface (no per-guild experiment): a ship-worthy opener (clears the quality floor) posts straight to the room — shipped=True; a gated/no-material slot is recorded by this event with shipped=False.) |
|
chimein_evaluated |
cogs/chimein.py | guild_id, channel_id, decision (mood_off_gate/hours_gate/vibe_gate/cooldown_gate/daily_cap_gate/threshold_gate/reacted/empty_generation/dedup_gate/quality_gate), vibe, score, mood; situational: signal (same_link/text_similarity/shared_run/content_overlap, on dedup_gate), local_hour_et, quality_score, quality_reason, post_preview, voice_present (a voice note in the buffer lowered the worth-posting threshold — likelihood only, the quality gate is unchanged) |
|
chimein_posted |
cogs/chimein.py | guild_id, channel_id, score, vibe, hook, mood, quality_score, quality_reason, voiced (shipped as a native voice note), sing (sung delivery), post_preview (the shipped take, POST_PREVIEW_CHARS — graded by the live-log eval pass), voice_present (a voice note in the buffer lowered the post threshold) |
|
quiet_room_decided |
cogs/chimein.py | guild_id, channel_id, decision (break|hold), score, reason (the verdict on cold-opening a quiet room — the inverse of a chime-in. After the cheap silence/mood/hours/cooldown gates, an early deterministic human-activity pre-gate runs first — reason=room_unanswered (score=0.0, no model call) holds when she posted recently and fewer than MIN_HUMAN_MSGS_BETWEEN_ICEBREAKERS humans have spoken since, so the path skips the paid quiet_room_score/produce_icebreaker work; otherwise a Haiku quiet_room_score call (gated on the same mood-tuned threshold as chime-in, 0.7 chill / 0.5 yaps) decides whether the room is genuinely dead and worth reviving, weighing whether Toots was just mentioned/engaged. break = a quiet-room icebreaker is being produced (→ discourse_icebreaker with trigger=quiet_room, called with skip_activity_floor=True so the shared floor isn’t re-read), hold = left alone. Bias is toward hold; emitted once per lull) |
|
link_enrich |
utils/link_enrich.py | platform, url_host, ok, duration_ms, cache_hit | |
link_check |
utils/link_enrich.py | platform, ok, alive (False only on confirmed 404/410), status, duration_ms — the dead-link HEAD guardrail’s per-URL liveness probe (distinct from link_enrich’s metadata fetch) |
|
github_api |
utils/github.py | op (create_issue|get_issue|list_issues|close_issue|comment), ok, status, duration_ms — one GitHub REST call (the /order path + the feedback log) |
|
feedback |
cogs/order.py | guild_id, user_id (the regular), logged; on logged=True: kind (request|suggestion|complaint|praise), issue_number; on logged=False: reason (daily_cap|github_error) — Toots logged SOFT feedback via the /ask note_feedback tool, recorded as a comment on one rolling feedback-log issue (label feedback, plain body so claude-code-action ignores it) for mods to triage. The lighter lane beside file_fix: no preflight/kitchen/pipeline gates, just a per-guild FEEDBACK_DAILY_CAP |
|
fix_update |
cogs/order.py | guild_id, user_id (the regular), logged; on logged=True: issue_number, order_id; on logged=False: reason (unknown_order|terminal|daily_cap|github_error) — Toots appended NEW INFO from a regular’s follow-up reply onto an existing in-flight fix-order’s issue via the /ask update_fix tool (add_fix_update), as a plain attributed comment (no @claude, so it records context without spawning a duplicate PR run). The counterpart to file_fix (file opens the order, update adds detail); scoped to this guild’s non-terminal orders, runaway-guarded by the per-guild AUTO_ORDER_DAILY_CAP (shared with file_fix, separate counter) |
|
railway_api |
utils/railway.py | op (list_deployments|redeploy|deployment_logs|build_logs|gql), ok, duration_ms — one Railway GraphQL call (the /undo rollback path AND the read-only check_deploys /ask diagnostics tool) |
|
tool_call |
cogs/ask.py + cogs/message_search.py + cogs/discord_info.py + cogs/playercard.py | tool (the /ask tool that ran: break_down_board, lookup_player_props, scoreboard, api_sports, standings, matchup_preview, box_score, game_markets, match_highlights, list_sports, game_lookup, engagement, discord_lookup, look_at_image, find_image, find_media, listen_to_audio, read_media, search_messages, self_serve, check_deploys), ok, + the promoted shared dims where they apply (query, matched, count, reason, phase, sport, aspect, action, source, guild_id, user_id, host, url_host, error) + a demap’d detail dict for tool-specific fields — the UNIFIED on-demand /ask tool-run event (#1296: folded ~20 near-identical per-tool *_lookup kinds into one parameterized event keyed by tool, via utils.events.emit_tool; the tool-volume/hit-rate dashboards group by tool, tool-specific signals ride detail off the field budget). memory_search (recall dashboard), reference_lookup + catalog_lookup (integration-health), and social_search (timed latency) deliberately stay their OWN kinds |
|
auto_order |
cogs/order.py | guild_id, user_id (the reporting regular), filed; on filed=True: issue_number, order_id; on filed=False: reason (gated = kitchen-closed/pipeline-red/in-flight cap/AUTO_ORDER_DAILY_CAP daily budget; plumbing/refused = preflight rejected; preflight_error/github_error = downstream failure) — Toots filed (or declined to file) a fix-order ON HER OWN via the file_fix /ask tool off a regular’s bug report. The autonomous counterpart to a mod’s /order; does NOT consume the server order budget (its own tighter daily cap) and still honors kitchen/pipeline/in-flight gates + the order preflight (protected paths) |
|
health |
cogs/health.py | integration (the watched source: apple_music, the market/odds feeds api_sports/sgo/the_odds_api/polymarket/kalshi, plus the #968 app-backbone set embeddings/elevenlabs_tts/elevenlabs_stt/perplexity/twitterio/odds_divergence), action (filed|dedup|alert|usage_alert); crater actions also carry failure_rate + attempts (the numbers it judged on) + recent (#725: True = a sharp RECENT-tail crater the full-window average still hid — assess checks the last _RECENT_TAIL_EVENTS calls too, so a fresh throttle self-announces while it’s fresh instead of waiting ~15 min to dominate the window; this is why she stayed quiet on the SGO blip before). action=filed: a fix-order filing was attempted — ok=True with guild_id + issue_number + order_id on success, ok=False with reason (gated = every guild blocked by kitchen/pipeline/cap; plumbing|refused|preflight_error|github_error = the order text itself rejected) on a miss (the filing also emits auto_order). action=dedup: a crater was found but already filed within the 24h window, held back. action=alert: an owner-facing crater alert posted to #bot-logs (guild_id) with an @master ping, delivered=bot_logs (#968: #bot-logs ONLY, never a content room), gated by master switch + a per-(guild, integration) durable cooldown — deliberately NOT mood-gated (an ops alert isn’t proactive content). Fires for every crater, incl. the ops-only (notice="") backend watches that used to file a fix-order silently — the self-healing watch (#443) now also reaching the owner, closing the gap the drained twitterapi.io wallet exposed. delivered=suppressed (reason=backstop_healthy): the crater has a backstop integration (a Watch.backstop) that’s provisioned + healthy and carrying the load, so the owner alert is held (the fix-order still files for ops) — it only fires when the backstop is ALSO down. The SGO betting-lines alert rides this (#748): once the Odds API SGO-down backstop shipped, SGO going flaky no longer drops betting lines, so she stays quiet unless the_odds_api is degraded too (“everything flaky and down”). action=usage_alert: a metered-wallet early warning posted to #bot-logs (guild_id, integration=the source) with an @master ping — a prepaid wallet under its floor (twitterio/scrapecreators) or a capped source ≥85% of its limit, caught before the 402/429 wall (deduped per (guild, usage:<source>) on a 12h cooldown) |
|
db_query |
db.py (_run) |
op (coarse sql_op() label, never params), method (execute|fetch|fetchrow|fetchval), ok, duration_ms — one DB query through the central choke point (singleton queries; transactional paths bypass _run). Ops monitor keys these db:<op> so each query shape gets its own p99. SAMPLED: only emits for queries ≥ _DB_QUERY_EMIT_FLOOR_MS (50ms) or any failure, so the hot-path sub-ms majority doesn’t flood logs |
|
span |
utils/instrument.py (@timed / timed_span) |
name (function / flow-step label), ok, duration_ms — the catch-all latency primitive for heavy functions (feeds.recent_messages, markets.get_context, the memory write pipeline) and discrete flow steps (ask.parallel_fetch, ask.memory_context, ask.vector_recall, recap.produce_recap, …). Ops monitor keys these span:<name>; report-only (no per-span ceiling), but the trend gate catches a climbing tail |
|
pplx_<purpose> (pplx_ask/pplx_discourse/pplx_recap/pplx_chimein/pplx_music/pplx_icebreaker/pplx_trending/pplx_cinema_desk) |
utils/perplexity.py | ok, duration_ms; on success: input_tokens, output_tokens, response_chars, hedged, source_count, context_size, recency; on failure: error. pplx_icebreaker is the idea-tuned conversation-starter hook fetched per quiet-room opener (surface="icebreaker": current debates/divisive/nostalgic angles to springboard off, never cited, always fresh, fail-open). pplx_trending (#1272) discovers the SPECIFIC current viral moments that fit a channel’s vibe (surface="trending", this-week), distilled by parse_trending_phrases into short searchable phrases the trending-clip gather searches — the fix for the raw-topic-string clip query |
|
link_stripped |
claude_client.py (discourse, ask, recap, music_post, chimein_post, discourse_icebreaker, commentate) |
purpose, reason (hallucinated | redundant | dead_link), count, urls (openers + live commentary are linkless, so any URL is stripped as hallucinated) |
|
discourse_relabel |
claude_client.py (react_to_trending) |
purpose (discourse_trending), reason (scaffolding_leak | unresolved_pick), ok — a trending clip react echoed its internal candidate label into the shipped text (“clip B out here selling…”, #clip3-leak; the [A]/[B] tag that binds a vision frame to a list entry, meaningless to a reader). The PICK was fine, only the wording leaked, so instead of discarding a good clip it re-reacts to JUST the picked clip (resolved by the link the reaction ended on) — a single clip carries no letter scaffolding, so the retry structurally can’t leak. ok=True = the clean single-clip retry shipped; ok=False = dropped (the slot falls through to a normal discourse take): reason=scaffolding_leak when the retry itself came back empty, reason=unresolved_pick when the leaked reaction carried no clip link to pin the pick to. Bounded to one extra call (_allow_retry), never a loop |
|
market_fetch |
utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/espn.py | source (sgo/polymarket/kalshi/api_sports/the_odds_api/espn), query, ok, duration_ms, cache_hit, result_count, error, http_status (on a kalshi open_events pagination_failed: the HTTP code the retried page gave up on — 429 throttle vs 5xx vs network None). source=espn (#1113): the FREE, keyless ESPN scoreboard, the last-resort score/finals backstop for the SGO-only individual sports — tennis (only when SGO degraded) + MMA/UFC (only when API-Sports degraded); query=sport (tennis |
mma), result_count=matches parsed, error=all_feeds_failed. Reuses market_fetch so the ops-monitor’s per-source integration health + the ops-only espn health Watch cover it with no new wiring. source=sgo reason=budget_shed (#786): the proactive budget governor (SportsGameOddsClient._shed_heavy_call) DROPPED a heavy league-wide read — the full game-board or league-wide player-props (the priciest calls on the tight rookie-tier monthly entity cap) — because the entity budget is near the cap; NO network call (cache_hit=True → excluded from integration-health like a no-op guard), result_count=0, ok=True, caller degrades fail-open (no board/props). The proactive complement to #1177’s scoreboard-cadence throttle: #1177 slows the CORE fetch, this drops the OPTIONAL depth first, reserving budget for the cheapest critical reads |
market_filtered |
cogs/market_alert.py (via utils/markets.py:dropped_by_filter) |
guild_id, reason (the filter that cut it: horizon|unwatched_sport|trailing_leg), kind (the shape it WOULD have fired as: swing|near_lock_crack|favorite_collapse), + the folded detail map: ticker, topic (≤80 chars), move (|price_change| in points), resolves_in_days (days to resolution per the best-available date) — a market that WOULD have alerted (a qualifying swing or conviction break, _would_alert) was cut by a market_alert pool filter before its move was ever examined — the observability for a silent candidate-exclusion filter (#world-cup-spike: the Men’s World Cup winner Spain leg spiked 20→55% but was dropped by the “within a week” horizon because Kalshi stamps a placeholder ~2yr close_time on open tournament legs, so no alert ever fired). The generalizable lesson: a filter that removes candidates from a pool is as load-bearing as the surface it feeds, so it needs the same instrumentation — verify the property that matters (does this date reflect reality?), not the convenient proxy (does the field exist?). Emitted per dropped would-alert market (bounded to the top few by move size so a wide pool can’t flood; a persistent market re-fires ~every 20min tick and the ops-monitor dedups by ticker). The ops-monitor renders a “Filtered markets” line every run + flags market_movers_dropped when ≥ MARKET_FILTERED_MIN (2) distinct hot markets are cut (horizon medium, unwatched_sport low); a dropped market always resolves >a week out (that’s why it was cut), so the sample’s resolves Nd is the tell — a human confirms whether that far date is REAL (a legit future) or another placeholder-date bug hiding a near-term market. The market_drop’s horizon exclusion is already observable via market_drop_skipped(reason=all_far_off) (#1092), so only the alert’s silent pool filters needed this. reason=trailing_leg (#netflix-race, made BLANKET + fail-closed in #1878) is a DELIBERATE routing choice, not a silent bug: a NON-leader leg of a multi-outcome Kalshi race (each movie in “Top US Netflix Movie” is its own yes/no leg) is dropped from the swing/conviction pool so a collapsing LOSER is never headlined on its own (“72 Hours sits at 18%, closes in 2 days” — owner: “why do we care who’s not leading… no narrative just a dry report”). Instead the moved leg’s whole EVENT is resolved into a leaderboard race (_resolve_races → get_event_markets → _race_snapshot: the full legs, since a stable longshot is filtered out of the movers pool, folded into an {outcome: prob} map + per-leg chart_specs so the card draws the real crossover) and routed through the existing lead-change path (an OVERTAKE narrated as “A Toxic Love Story overtook 72 Hours for the top spot, now most likely at 74%”); only the leader’s own moves or a real switch make the feed. So the ops-monitor RENDERS trailing_leg in the “Filtered markets” line but NEVER flags it as market_movers_dropped (it’s intentional, not the #world-cup-spike class). reason=ladder_rung (#ladder-forecast) is the same shape of deliberate routing: a Kalshi SCALAR market is a LADDER of “Above N” rungs over ONE underlying number, and 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, the Spider-Man KXRT-SPI-90 rung swung 66%→48% while the figure Kalshi publishes for that market held flat at 90, and this surface posted “a favorite falling to a coin flip” about a market that hadn’t moved in two weeks, three times in three days. So rungs are dropped from the swing/conviction pool entirely (utils.kalshi_ladder.is_ladder_rung, the same test market_drop has used since #1011), leaving a scalar market a shape this surface simply doesn’t narrate. A RISING ladder_rung rate is HEALTHY — it counts the false alerts no longer posted — so the ops-monitor renders it and never flags it. What a ladder DOES forecast (its E[X], the figure Kalshi prints) alerts via the forecast_move kind (a material move in the forecast vs a durable per-event baseline, debounced) — and a ladder CLOSING soon posts through the ending-soon lane reframed onto its forecast (#forecast-card-copy, owner steer “the forecast is the story, the odds is a subtitle qualifier”): the take reads “X is forecasted to hit ~17M” with the close as the last-call hook, the card heroes the forecast VALUE, and the rung’s odds ride only as the card’s subtitle qualifier (“75% to clear 16.5M”, _rung_qualifier) — the Bieber views board had shipped the inversion (75% heroed, the ~17M figure unmentioned) because the ending-soon lane never saw the swing pool’s rung filter; a closing rung of an UNREADABLE ladder (bucket/thin, no forecast) is dropped fail-closed with this same ladder_rung reason. (The same change fixed the forecast trigger having shipped DORMANT: ladder_forecast reads the raw book fields (yes_bid_dollars/yes_ask_dollars/last_price_dollars), which _kalshi_market_to_snapshot never threaded onto snapshot meta — so _resolve_ladders always saw None-book rungs and no forecast ever resolved; the mapper now carries them.) reason=unconfirmed_leg (#1878) is the ONLY leader-rule drop that is neither editorial nor a correctness guard, and the fail-CLOSED half of the leader rule: a named-outcome leg may be headlined only when its event’s own leg set CONFIRMS it either leads its race or isn’t in one (_event_leader over any event with ≥2 named priced legs — a lower bar than the ≥3-leg leaderboard fold, since a two-horse race has a loser too), and anything unconfirmed is SUPPRESSED rather than posted blind. The rule existed before but fail-OPEN, so every gap in resolution came out as a trailing leg headlined with the leader unmentioned: a Charli xcx album sank to 15% on the 26-album Billboard 200 board and got its own alert while Olivia Rodrigo led it at 71% — the room told who was LOSING a race it was never told anyone was winning. What left it unresolved was the per-tick resolve cap (3) being spent on scalar LADDERS, whose rungs carry named-looking labels and so passed the race candidate test (that tick, three Rotten Tomatoes / YouTube-views rungs moved 47-52 points and took every slot). So rungs are excluded from the race candidates (they have their own lane, making the two candidate lists disjoint and each event fetched once), candidates are ordered would-alert-FIRST (a conviction break fires BELOW the swing bar, so move size alone buries it), and the budget is split: every event that can FIRE (_RACE_MAX_EVENTS 50, sized to a live sweep — Sports 39, Entertainment 13, ≤20 elsewhere) plus a handful resolved only for lead-change reach (_RACE_CONTEXT_EVENTS 5), so a quiet day’s long tail of small movers costs nothing. Live-swept: 107 trailing legs suppressed vs 29 leaders still alertable, 5 lone binaries untouched. Because nearly EVERY Kalshi market is a named leg (299/299 would-alert markets on a live sweep), the event fetch is now load-bearing for the whole surface — so a sustained unconfirmed_leg rate means it’s going DARK, not quiet, and this is the one filter reason the ops-monitor FLAGS (medium, pointing at get_event_markets) rather than only rendering. That fetch must therefore be COMPLETE, and #1879-follow-up is why (_legs_complete): get_event_markets read ONE /markets page (100) and dropped the cursor, so any bigger field came back silently truncated — and a partial leg set doesn’t weaken this rule, it INVERTS it, crowning the best leg on page 1 while suppressing the real favourite as trailing. Live: the 146-runner Rocket Classic came back cut at 100 with Cameron Young (the true 26% favourite) missing entirely, so page 1 read Patrick Cantlay at 14% as the leader; 1 of 279 live events exceeded a page, but large fields (golf, a 32-team futures, a big candidate slate) recur weekly. So the fetch now follows the cursor (page size raised to Kalshi’s 1000 cap, so the extra round trip is only ever paid past 1000 markets — the golf field drains in one) and STAMPS event_legs_complete on every leg; an undrained walk (page budget spent, or a later page failing) marks the set partial and _resolve_races treats it exactly like a failed fetch — left out of the map, every leg unconfirmed_leg. Suppressing the true leader alongside the pretender is the accepted cost of not knowing, and is the same direction every other gap in this rule takes |
|
market_image |
utils/market_cards.py | guild_id, surface (drop|alert), market_source (kalshi|polymarket), image_source (the ladder rung that answered: chart|polymarket|banner; wikipedia|itunes as later PRs land), ok; on image_source=chart also: outcomes (# lines drawn), with_series (# with REAL price history vs a flat line) — a market post’s CARD IMAGE was resolved (the link-unfurl fix). kalshi.com links don’t unfurl (Vercel bot wall serves Discord’s crawler a “security checkpoint” instead of OG tags), so cogs/market_drop + cogs/market_alert build their OWN embed (clickable market link + her take as the embed description + image) instead of a bare link. The image is the self-rendered chart card (utils/market_chart.py — market name + each outcome’s price line + big %, co-branded “Tootsies on {source}”) on ~every post; the polymarket native image → committed floor banner are fallbacks only if the chart can’t render. This records which RUNG answered so the ops monitor watches the mix + catches the chart silently failing (a spiking banner rate) or the candle fetch degrading (a with_series=0 rate). ok is True (a card image always resolves — the banner is the floor); image_source + with_series are the real signals |
|
circuit_breaker |
utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/espn.py (via utils/circuit_breaker.py, #615 Kalshi / #725 SGO + API-Sports + Odds API / Polymarket / #1113 ESPN) |
integration (kalshi/sgo/api_sports/the_odds_api/polymarket/espn), from / to (closed/open/half_open), failure_rate, samples — an outbound-client circuit-breaker state change. Same mechanism, one instance per client. Kalshi/SGO crater from the datacenter IP (50%+ failure windows); API-Sports got its own breaker in #725 (it’s the live-scores + final-settlement backbone), and the Odds API got one too (it’s now load-bearing — the /bet slate’s SGO-down source AND the settlement backstop). Polymarket got one once it became load-bearing for the live Bookie reprice — at parity with Kalshi (breaker + bounded retry on every read), so a Polymarket crater short-circuits straight to Kalshi with no wasted round-trips. Once the failure rate over the window crosses 50% with enough samples the breaker trips OPEN and short-circuits every read — no network call — for a cooldown, so the bot stops hammering the downhost and degrades gracefully (a peer source answers). Two degraded signals are load-bearing: the API-Sports breaker routes the bookie’s settlement to the Odds API /scores backstop (payouts survive), and the Odds API breaker makes get_odds serve its stale-on-error cache (_STALE_FALLBACK_SECONDS, 10min) so /bet keeps showing recent games through a blip instead of blanking. After the cooldown one HALF_OPEN probe tests recovery: success → CLOSED, failure → re-OPEN. Each breaker is scoped to its client instance (a crater of one never stalls the others). Fail-open, pure + clock-injectable, telemetry-agnostic (the client wires this event as its transition hook) |
|
odds_health |
cogs/commentator.py (via utils/sportsdata/odds_health.py, #968) |
game_key, sport, sources (the source names compared), home_pcts ({source: home implied %}), max_divergence (max pairwise gap in home %, 0 if <2 sources), incoherent (sources whose sides don’t sum to ~100±vig) — a STATELESS cross-source odds snapshot for one LIVE game. With every source for the game already in hand (SGO/enriched line via game_sides_pct, The Odds API via event_sides_pct, Polymarket + Kalshi via prediction_sides_pct — no extra fetch), the commentator emits each source’s home implied % so a source that’s stale/wrong (it diverges from the others) or mis-parsed (its sides don’t sum sanely) is catchable with no oracle — disagreement IS the signal (the 37%-on-a-93%-game bug reads as a ~56pp divergence between a frozen book line and the live prediction markets). #1458: a LIVE single-leg knockout ALSO gets a match_winner source — the full-MATCH (win-by-method) line via prediction_match_winner_sides_pct (Kalshi Method of Victory, summed per team; one extra fetch but the Kalshi pick is cached + shared with the Bookie reprice). This is the one comparison the regulation-only sources CAN’T provide: at 0-0 full time EVERY regulation line collapses to the same 50/50 (both win legs → ~0), so they AGREE on the wrong number and the cross-check saw no outlier — the match-winner line (Spain ~71%) is what makes the stale regulation line an outlier, self-flagging the 50%-at-0-0 bug this PR fixes even if the fold ever silently stops engaging. Emitted only with ≥2 sources (divergence needs a comparison); computed from THIS tick and forgotten (no storage, no history — owner steer). Pure helpers in utils/sportsdata/odds_health.py (summarize_odds_health + the per-source *_sides_pct extractors, all unit-tested); fully fail-open (observability never breaks commentary). Ops-monitor keys odds_divergence (gap ≥ ODDS_DIVERGENCE_PP 15pp → a source stale/wrong) + odds_incoherent (off-sum line → parse/mapping drift), and renders an “Odds health” line every run. Reactivity (did-it-move-on-a-goal) was deliberately dropped — it’s the one signal needing stored per-tick history; divergence already catches a frozen source as an outlier |
|
prediction_match |
cogs/commentator.py (via utils/sportsdata/odds_health.py:diagnose_prediction_match, measure-to-improve #kalshi) |
source (kalshi|polymarket), game_key, sport, matchup (away @ home, ≤80 chars), candidates (# raw snapshots the source returned before matching), stage (no_coverage = returned nothing / no coverage of this game/league; no_match = returned snapshots but none matched this game; mapped_fail = a snapshot matched but yielded no usable line; ok = produced a two/three-way line), reason (on mapped_fail: unmapped_labels = an outcome label didn’t resolve to home/away/draw, the cross-provider NAME gap; no_outcomes; incoherent_or_ambiguous = labels mapped but the line was rejected downstream), unmapped (up to 5 offending outcome LABELS on reason=unmapped_labels — the actual failing team names to fix, e.g. a nickname/abbreviation that didn’t substring-match the bookie’s full name) — the per-source game-line FUNNEL for ONE live game, turning “is Kalshi matching?” into a queryable number + the exact names that missed. Emitted per prediction source on every live game every tick (comprehensive coverage, alongside odds_health, from the SAME already-fetched snapshots — no extra fetch). Pure diagnose_prediction_match (unit-tested); fully fail-open. Ops-monitor aggregates a render-only “Prediction line funnel” per source (the no_coverage/no_match/mapped_fail/ok distribution + the top unmapped labels) so the Kalshi-vs-Polymarket gap and its cause are watchable every run |
|
bet_settle_backstop |
cogs/bookie.py (#725) | source (the_odds_api), bets_settled — the Odds API /scores PAYOUT BACKSTOP settled open bets because API-Sports (the primary final-score feed) was down (api_sports.degraded). The symmetric resilience to the /bet slate fix: a World Cup/NBA bet still pays out W/L through an API-Sports outage by settling off the Odds API final (matched by the cross-provider match_key, names aligned by canonical_team #739). Budget-safe: fires only when the API-Sports breaker is open, only for sports with open bets (db.open_bet_sports), reusing the shared idempotent _settle_game. Normally never fires (bet_settle_tick.backstop_settled=0) |
|
bookie_live_reprice |
cogs/bookie.py | game_key, sport, source (polymarket|kalshi|none), repriced (bool), matched (bool), sides (# bettable sides after), line_type (moneyline|match_winner, only meaningful when repriced) — a LIVE game whose sportsbook line was stale / incoherent / absent was repriced off the prediction markets’ live implied %s. SGO is our only live sportsbook feed; when it’s down (its monthly entity cap recurs exhausted) the Odds API h2h fallback is a pre-match-style line that doesn’t reprice in play (the frozen Norway −205 / Draw +1000 served on a live 0-0 game — the screenshot bug), while the prediction markets move in real time. _supplement_with_live_predictions reprices a live game _sides finds no coherent line for, fetching Polymarket AND Kalshi CONCURRENTLY as a unified resilient enricher (#prefer-kalshi: KALSHI preferred — CFTC-regulated + US-web-bettable — Polymarket the fallback, and kept for a SOCCER game whose Kalshi line came back a draw-less 2-way, via the usable_prediction_line guard so a soccer draw is never dropped; feed list derived from the source-capability matrix utils/sportsdata/sources.py, each feed circuit-breaker + retry guarded) and pricing off the first that yields a coherent line (a sanity guard, three_way_coherent, rejects a single-digit-% draw / near-decided market before it reaches a bettor; an incoherent fold is cleared via clear_moneyline so no half-line lingers). matched=True, repriced=False is the defensive-parsing / silent-thinning signal: a market for the game EXISTED but yielded no coherent line (schema drift, e.g. a Polymarket label change, or a near-decided market) — distinct from matched=False (no coverage). ODDS-ONLY via prediction_line_odds/fold_prediction_as_line: maps outcomes to home/away/draw through our internal canonical_team mapping (the SAME key match_key/settlement use; fail-safe on an ambiguous name) and writes only game.odds — never home/away/title/meta, so reconciliation is provably unaffected. Fail-open per game + per feed. #968: also carries implied_pcts (a demap’d {side: pct} JSON string of the ACTUAL implied %s the fold wrote — ONE field, not three home_pct/draw_pct/away_pct fields, #1296; before this, repriced=true told us a fold happened but never WHAT it priced, so a stale 37%-on-a-93%-game fold was invisible in logs). MUST-WIN (single-leg knockout) games are priced off the MATCH-WINNER line, not the Regulation-Time moneyline (line_type=match_winner): a knockout is settled on who wins the MATCH (_winning_team pays the advancer via ET/penalties, #1120), but the regulation 1X2 market’s two win legs both COLLAPSE to ~0 at 0-0 full time and renormalize to a meaningless 50/50, then the near-resolved floor drops the line entirely — the Spain vs Argentina 2026 final, where bettors saw a 50% card then no odds heading to penalties, while Polymarket/Kalshi plainly showed Spain ~71%. So for a game that clears is_single_leg_knockout (the same predicate the draw-suppression uses), the reprice fetches Kalshi with KALSHI_INTENT_MATCH_WINNER (a prose intent — the Haiku picker lands on the per-game ‘Method of Victory’ market, NOT the Regulation moneyline; no ticker hardcoding, so it generalizes across competitions) and folds it via fold_prediction_match_winner/prediction_match_winner_odds, which SUMS each team’s win-by-method legs (Reg + Extra Time + Penalty Shootout) into a 2-way — the true full-time line that stays correct through ET/pens. It’s gated Kalshi-only-in-practice (Polymarket’s per-game event is Regulation-scoped, so its snapshot carries a Draw leg and the summed fold bails to the normal path), fully fail-open (no MoV market → the existing moneyline path runs, nothing regresses), and the summed fold normalizes by the total so an over-round leg set can’t crash the American converter. The reprice runs on the bettable-slate refresh (~60s) and fetch_prediction_snapshots is deliberately UNCACHED, so each fold uses genuinely fresh prediction odds; the bettor reads the slate cache, so a bet’s line is as fresh as that ~60s fold. The warm loop re-folds live games even when betting is idle (#968): during an active session it runs the full metered refresh, but when idle AND a game is live it does a CHEAP re-fold of just the live games’ odds off the FREE prediction markets (_supplement_with_live_predictions(force_refresh=True) — re-folds an already-prediction-sourced line, leaves an SGO self-priced one) — closing the gap where the commentator pulled fresh odds for a live game but the picker sat stale because no /bet was driving the slate refresh. There is deliberately no placement-time rejection guard (removed per owner: a stale lock is rare enough with the fresh fold that a re-quote isn’t worth the UX) |
|
usage_fetch |
utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/tts.py + utils/github.py + utils/twitterio.py (#727, #753, #1255) | source (sgo/api_sports/the_odds_api/elevenlabs/github/twitterio), ok, duration_ms; on failure: error (+ http_status on an HTTP miss) — one metered-source usage read, the cheap authoritative call behind the quota poll. SGO /account/usage is quota-EXEMPT (it answers even while every other SGO read 429s on the entity cap — exactly when you need it); API-Sports /status is a free account read; the Odds API refreshes its free header budget via /sports; ElevenLabs /v1/user/subscription is a free read of the monthly character cap (#753; may 401 if the key lacks the user_read scope → ok=False); GitHub /rate_limit is free and doesn’t count against the limit (#753); twitterapi.io /oapi/my/info is the quota-exempt prepaid CREDIT-BALANCE read — NOT breaker/rate-limiter guarded, so it answers even while the paid reads fail, which is exactly when you need the balance (#1255). Highlightly, Giphy, and OpenAI have no usage endpoint, so they’re read passively off captured response headers (no usage_fetch) |
|
quota |
cogs/health.py (#727, #753, #1255) | source (sgo/api_sports/the_odds_api/highlightly/elevenlabs/github/giphy/openai/twitterio), ok; on ok=True: tier, metric (month_entities/day_requests/credits/month_characters/hour_core_requests/day_searches/minute_requests/…), period, used, limit (None = source reports it unlimited), remaining, pct (used/limit, None when unlimited); on ok=False: error — a metered API’s usage vs its cap, emitted per (source, metric) by the health-watch usage poll (~30min) so the ops-monitor flags a quota nearing exhaustion (quota_low, high) before it 429s. #1255 added twitterio (credits, the curator’s X source): a PREPAID wallet with no fixed cap, so limit/pct are None and it can’t flag via the generic pct-based quota_low; instead the ops-monitor fires a dedicated balance-FLOOR finding (twitterio:budget, high) when remaining drops under TWITTERIO_CREDITS_LOW — the same prepaid-wallet model as the Odds API’s own budget finding. #753 extended the poll to ElevenLabs (month_characters, the voice-budget SGO-entities analog), GitHub (hour_core_requests, the REST-core hourly cap the /order filing path spends against), Giphy (day_searches, daily cap captured passively off search headers), and OpenAI (minute_requests, per-minute RPM captured passively off embed headers — period="minute" is INFORMATIONAL, rendered but never flagged since a rolling rate can’t fill ahead of time; the ops-monitor’s _INFORMATIONAL_QUOTA_PERIODS guard on QuotaReading.low enforces this). The early-warning that was missing when SGO’s monthly ENTITY cap (100k, rookie tier) silently filled and took /bet dark while API-Sports sat at 90% of its daily request cap. Rendered every run in the ops report’s “Metered-API usage quotas” table; on-demand via the token-gated GET /debug/usage. the_odds_api keeps its own real-time budget finding (off market_fetch credits_remaining, #734), so it’s excluded from the quota-driven finding to avoid a double-flag |
|
music_fallback |
cogs/music.py | guild_id, channel_id, channel_name, reason | |
music_scored |
cogs/music.py | guild_id, channel_id, channel_name, score, reason, must_post, post_preview | |
awards_posted |
cogs/awards.py (#1017) | guild_id, period (week|month), ok; on ok=True also: channel_id (None when staged), awards (# categories handed out), winners (# people tagged), manual (a mod’s /awards vs the scheduler), delivered (room = posted to the awards channel, the awards experiment in PRODUCTION; staged = auditioned in #bot-logs, the experiment in STAGING), cards (# themed champion-card images rendered), hypes (# per-award memory HYPE-UPS that generated — claude.awards_hype on the per-guild awards model, default Opus; hypes=0 with winners is the silent-degradation signal that the memory/model came back empty across every award); on ok=False also: reason (no_channel = production but none set on /menu; no_window = no seed / no snapshot to diff against, run /stats period:; no_winners = the window was too quiet; empty = the voice call came back blank) — the weekly (single-award “Player of the Week”, same fanfare as the monthly, Sundays) / monthly (gallery led by the “{month} MVP” e.g. “June MVP” + funniest/besties, the 1st) engagement awards posted (weekly + monthly headline are the SAME engagement metric titled per cadence; NO OVR/”Player of the Month” award). Each award is the top of a WINDOWED board (seed − the period snapshot); pings the winners. Gated by the awards experiment (off skips, staging auditions in #bot-logs, production posts to the awards channel) + master kill switch + mood != off; idempotent per ISO-week / per-month with anti-repeat over the prior period’s winners |
|
curator_fetch |
utils/twitterio.py (#curator) | phase (user|search|trends), handle, count (# posts / trends parsed), ok, duration_ms; on a phase=user timeline read also guild_id + stop_reason + pages (#curate truncation audit): the fetch is PER-GUILD (each guild passes its OWN seen-set as stop_ids, so the paged walk terminates at a different depth per guild — guild_id makes it splittable), and stop_reason records WHY the walk stopped — hit_seen (reached an already-posted tweet = fully CAUGHT UP, nothing dropped), page_end (endpoint has_next_page=false), cursor_end (has_next_page true but no next_cursor), max_pages (walked the whole _MAX_PAGES_PER_FETCH cap and STILL no seen id = unambiguous truncation, more backlog than we can fetch), limit (legacy no-stop_ids single-window cap), page_error (a later page failed, kept page 1). A page_end/cursor_end/max_pages (never hit_seen) with count at a full-page boundary is the truncation signature; a hit_seen means the drop is DOWNSTREAM (best-home routing / fit-judge, see curate_routing/curator_evaluated), not the fetch. pages (# pages walked) folds into detail. On a miss: error (unprovisioned|no_handle|empty_query|HTTP |
|
curator_posted |
cogs/curator.py (#curator) | guild_id, channel_id, delivered (room = posted to the curator channel, curator experiment in PRODUCTION; staged = auditioned in #bot-logs, experiment in STAGING), handle (the source account), external_id (the posted source id, the dedup key), engagement (the chosen post’s blended magnitude), source (in_channel|learned|adjacent, the discovery tier), mode (image|text, which fit-judge ran, #1232 Phase 1.5), via_repost (#curator: True = the pick came from a seed’s REPOST, resolved to the endorsed original — watch this rate to see the repost-supply path landing, esp. for aggregator-seeded channels like #her) — the curator posted a fresh on-theme link into a curator channel (the SUCCESS half of the ship funnel). The pick cleared the structural gates (dedup + original-with-media) and a Haiku fit-judge (claude.pick_curator vision, or pick_curator_text) against the channel’s own recent posts, chosen from an account-diversified slate (no engagement pre-filter); posts the source permalink (fixupx-rewritten so it unfurls). Gated by the curator experiment + master kill switch + mood; deduped by the durable curator_seen set (never reposts an id) |
|
watch_link |
utils/watch_link.py (#music-video-linking) | guild_id, surface, ok, query (the truncated search phrase), count (hits weighed); on ok=True: source (youtube), url_host; on ok=False: reason (not_music_video|declined|no_results|no_link|unprovisioned|pick_error) — resolved (or declined to resolve) the real WATCHABLE link for a wire post that’s about a music video. A discourse take sourced from an X wire post ships to @tootsiesbar with NO link: its source link is stripped (an x.com link would render the quote card the owner banned for discourse) and the wire post carries only photos, so there’s nothing to re-host natively — a take about a music video shipped as a take + a stock press photo with nothing to click, the whole payoff missing. So this searches YouTube and links the REAL video, which X unfurls into a playable embed (shipped WITHOUT the photo so the link drives the embed — the same reason the video_link fallback drops its still). The claim comes from the SOURCE POST, not her take — that’s what makes it work: her take names the PEOPLE (“Travis Scott directing James Blake and Ludwig Göransson”) while the source post names the RELEASE (“The video for ‘When I’m Home’ was directed by…”); searching the take finds nothing. CLASSIFY, THEN ROUTE (owner steer), the same shape as the image resolver (entity_image.resolve_desk_image) including its deterministic-first discipline: claude.video_subject (Haiku) reads the post into a clean {kind, title, artist} subject, and the KIND routes to an authoritative source — film/tv → TMDB’s own /videos (tmdb.trailer_url, a LOOKUP: the studio’s curated trailer keys, so a hit is the real trailer by construction, nothing to pick between or gate on) and music_release → a YouTube search + pick (the one kind with no authoritative endpoint — nobody publishes “the official video for X” as a lookup — so search_query_for builds the query → “Kendrick Lamar squabble up” and claude.pick_music_video (Haiku, text-only) confirms the hit on SONG IDENTITY + PROVENANCE) — load-bearing rather than ceremonial, since the live search for this story returns the right song’s official upload alongside a DIFFERENT James Blake single, a soundtrack trailer, and three fan re-uploads. The two-stage split is what closed the leak the single-call design couldn’t: the POST-level question (“is this even announcing a video”) used to sit inside the picker beside two CANDIDATE-level tests, so it competed with them and a chart-position post linked 5/5 — and both attempts to strengthen the rule IN PLACE measured worse (one made the lead frame fight the rule, the other blew max_tokens on reasoning and started accepting fan channels). Moving the question to its own classifier is the restructure those rewrites were reaching for, and it deleted the brittle half too (the quoted-span regex, the capitalized-word scan, the stopword list). artist is a name to SEARCH WITH, not necessarily the credited act — measured: requiring the crediting artist declined the exact reported post 5/5, because a wire post names the title and the DIRECTOR (“The video for ‘When I’m Home’ was directed by Travis Scott”) and not the singer; anchoring on whoever the post DOES credit restored it at no off-lane cost. The name must appear IN THE POST — inferring it from model knowledge is the wrong-artist failure mode this codebase keeps re-learning. FAIL-CLOSED throughout — it only ever ADDS a link to a post that would otherwise ship without one, so every uncertain path leaves the post exactly as it was (a wrong video is far worse than none); not_music_video is the common, healthy outcome (most takes aren’t about a video). The question stays POST-LEVEL and that restraint IS the design (owner steer): “does a video exist for this subject” is true of nearly every film and song, so it would attach a link to almost every take — loud, and at X’s ~$0.20 link price against ~$0.015; “is this post ANNOUNCING a video” is what keeps the surface quiet. SPORTS is deliberately absent — Highlightly needs both team names AND the game date, which a wire post’s prose doesn’t carry (the commentator posts clips because it holds a live game object with those fields), so the case where we actually have a game is already served there. MEASURED (n=5/case end-to-end through the real resolver, live lookups, scripts/dryrun_watch_link.py): on-lane 29/30, off-lane 30/30 — every off-lane case declines at the SUBJECT stage before any lookup runs, including two that name a subject which really does have a video (a live performance; a casting note for a film with a trailer), the exact subject-level behaviour the post-level question refuses. The one on-lane miss is the reported post’s music lane, re-measured at 11/12 on its own (~92%, the hardest case — its anchor name is the DIRECTOR so the candidate list is genuinely ambiguous), not a regression; the earlier 5/5 reading was equally consistent with 92%, since n=5 can’t separate the two. Watch the pick_error rate (a BROKEN picker — the one reason that’s a problem); a climbing not_music_video share is just the classifier doing its job. COST: a resolved link ships as crosspost mode=tweet with has_link=true, which X prices at ~$0.20 vs ~$0.015 for a link-free post; bounded because discourse crossposts run ~hourly and most decline, but an ok=true rate approaching the discourse crosspost rate means the picker has gone loose and is spending that ~13x broadly |
|
curate_run |
cogs/curate.py (#curate) | guild_id, channel_id, delivered (room = posted to the curator channel, curate experiment in PRODUCTION; staged = auditioned in #bot-logs, STAGING), handle (the source X account), external_id + platform (x, the shared curator_seen dedup key), engagement (the chosen post’s blended magnitude), mode (image|text, which fit-judge ran), via_repost (True = the pick was a repost), trigger (scheduled) — the scheduled curate-feed surface posted: it distributes a mod-configured X account’s posts + reposts (set on /menu) across the curator channels. Each due slot DRAINS all eligible on-theme posts for a channel, best-first (owner steer — “curate all eligible recents vs slow drip”), not one: the slot is the trigger, but on it the loop repeatedly composes the best UNSEEN eligible post via the curator’s fit-judge + delivers it (marking it seen, so the next compose slides to the next-best) until the judge declines or nothing eligible remains — bounded by _MAX_DRAIN_PER_SLOT per slot (the remainder carrying to the next slot; curator_seen makes that safe), so curate_run fires once PER drained post (multiple per slot). discord.py backs off + retries on a 429 so a batch never drops a post to rate limits. Rides the curator’s /menu calendar cadence + the curator’s experiment (one toggle for both); gated by that experiment + master kill switch + mood; dedup shared with the curator (curator_seen), so a tweet lands in one room and never double-posts. A drained-nothing slot (or the terminal compose that ends a drain) emits curator_evaluated (source=curate_feed) with the skip reason (no_home/none_fit/pick_error/no_candidates/no_posts) instead |
|
wire_identity |
utils/wire_identity.py (#wrong-person follow-up) | ok, source (the desk surface), guild_id, count (replies read), query (the resolved name, on ok/no_web_check), reason (no_replies|no_id|no_web_check) — the thread-identity path ran on a wire moment about an UNNAMED person: the replies under the source post identify who’s in an unnamed viral clip (the @DailyLoud “Brazilian model is going viral” → Gabriela Moura case), where a web search on the post’s vague words matches the WRONG story (the Larissa Nery substitution). A Haiku gate (claude.wire_subject_named) stops the path on a named subject BEFORE the metered replies fetch (emitting nothing — only real identity attempts count); an unnamed one pools TWO reply pages (the newest slice + the post’s first EARLY_WINDOW_SECS minutes, where “who is this” gets answered), like-ranks them, extracts a CREDIBLE identification (claude.identify_from_replies — detail or cross-reply agreement, never a lone bare name/joke/lookalike gag, answer-first NAME:/NONE verdict), and web-verifies the name (a Perplexity bio check; a crowd ID alone never ships a name — fail-closed). A resolved identity replaces the vague-words confirm and lands in the compose blob via thread_identity_block (name + the wire’s view count as the moment’s hard number + a no-body-comment line; the compose still holds the final coheres-with-the-media judgment). Pop desk only for now |
|
curate_routing |
cogs/curate.py (#curate ops) | guild_id, candidates (# fresh posts routed this tick), routed (# assigned a best-home room), dropped (# routed NOWHERE — fits no room / route classifier failed) — the best-home ROUTE funnel, the DROP signal: a high dropped/candidates rate = the feed is sending ~everything away, so a curate feed dropping everything is watchable + flaggable (ops-monitor renders a Curate-feed health line + flags curate_dark when it ships ~nothing over enough attempts, dominant-reason-guided like curator_dark; source=curate_feed skips are kept OUT of the curator’s funnel so the two don’t cross-contaminate) instead of silent. A route-classifier API failure also emits error (source=curate_route) so it hits error-triage |
|
crosspost |
utils/x_crosspost.py | guild_id, surface (music|market_drop|discourse|market_alert|bet_board|bet_alert|bet_value|cinema_desk — which drop surface sourced it; cinema_desk mirrors the movie/TV big-number card (take + card image + Kalshi/TMDB link), the cinema sibling of the market_drop crosspost; the curator is deliberately excluded (owner steer) so its reposts of OTHER accounts don’t flood the timeline and drown out Toots’ own tweets — crossposting is reserved for her original-voice surfaces), mode (tweet|quote|repost|media|video|video_link — an X-origin link in the drop (a discourse trending-X reaction, auto-detected from the line via x_fetch.x_tweet_id) QUOTE-tweets when she added a take, else NATIVE-REPOSTS the bare link; a chart rides as media; video (#video-upload) is a WIRE DESK (pop_desk/sports_desk/cinema_news) re-sharing the source post’s OWN clip on her standalone take by fetching the source X post’s mp4 and uploading it as NATIVE media (utils.video_fetch.fetch_clip_bytes → XPoster.upload_video), so the video plays inline on her own broadcast — NOT a quote/link to the wire account (the #video-detection-failure fix). The clip fetch prefers the highest-quality rendition ≤1080p (X re-encodes 4K, so it’s wasted bytes; x_fetch._largest_mp4), capped by X_CLIP_MAX_BYTES (64 MB). video_link is the FALLBACK when the clip can’t be downloaded (an undownloadable/HLS-only clip, a CDN block from the datacenter IP, an oversize file, or a clip over X’s native-video ceiling — utils.video_fetch.exceeds_x_video_limit / X_MAX_VIDEO_SECONDS, default 120s: the @tootsiesbar account tier refuses a longer video with 403 "This user is not allowed to post a video longer than 2 minutes.", and it refuses at POST time, AFTER the multi-MB download + chunked upload, then eats a second identical 403 on the video-only retry — and too late for this very fallback, which only fires when the UPLOAD failed, so the take was dropped from the timeline entirely (6 of 61 video crossposts over 14 days). fxtwitter reports the duration on the resolve, so fetch_clip_bytes gates BEFORE the download and the post ships with the deep-link instead of not shipping; the TikTok path checks the same ceiling off aweme_detail.video.duration. An UNKNOWN duration is never blocked — fail-open, better to spend the call than silently downgrade a clip that would have posted. Emits x_clip_too_long): rather than settle for a still, it appends the source .../status/<id>/video/1 deep-link IN-TEXT (_video_deep_link, host-normalized to x.com), which X SOMETIMES embeds as the playable video (owner steer “it works sometimes”) — a shot at the real video beats the photo, so it’s posted WITHOUT the photo so the link can drive the embed (and it’s the link-priced post, has_link=True). The whole thing is fully fail-open along a LADDER: native video re-upload → video_link deep-link → the found photo (mode=media) → a link-free text take — never a dead in-text link. It takes the media slot over the found photo (video wins); the take is kept clean of any x.com link so the quote auto-detect stays off and it rides the plain-tweet path. She LIKES the quoted/reposted post (a fail-open courtesy that rides the same quote, never touching the quote that already shipped). A borrow-the-audience REPLY under the quoted post was tried (#1732) and REMOVED: @tootsiesbar’s X API tier 403s any reply to a post where she isn’t mentioned/author (“You can only reply to or quote posts where you are mentioned or are the author”), so a reply under someone else’s tweet can’t land — and unlike the quote (which survives that same limit via the in-text-link card fallback) a reply has no equivalent workaround. So there’s no reply crosspost path; the quote + like are the amplification. delivered (posted = tweeted to @tootsiesbar’s X timeline | staged = auditioned in #bot-logs, the x_crosspost experiment in STAGING | skip), reason (None on posted/staged; unprovisioned = production but the 4 X_* OAuth env vars aren’t set; post_rejected = X rejected the tweet; surface_off = the per-guild picker disabled this surface; over_length = the take overshoots X’s ceiling; dedup = another guild already tweeted this content; error = the call raised — a no_art skip floor was tried here and REMOVED per owner steer “if the post is good don’t skip”: a good take with genuinely no art still SHIPS as a text tweet; art coverage is the desks’ multi-layer resolution (alternate entities + the composed-take retry), not a boundary filter) — a drop was MIRRORED to the @tootsiesbar X timeline (the outbound half of the X integration). Fires ONLY at each surface’s real room-delivery success (a STAGED drop never crossposts), ONLY for a guild that OPTED IN (the per-guild x_crosspost experiment != off), and only if not excluded by the optional X_CROSSPOST_GUILD_ID single-guild lock. The cog-level disposition; the API call itself emits tweet_posted (client latency/outcome), mirroring the image_reply / image_generated split. Fail-open: a crosspost is a courtesy mirror of a post that already shipped, so any miss never touches the Discord delivery |
|
tweet_posted |
utils/x_poster.py | ok, duration_ms, chars (tweet length) / bytes (media upload), mode (tweet|quote|reply|media|repost|like|upload|upload_video), delivered (posted|skip), tweet_id (on success, folded into detail); on a miss: error (unprovisioned|empty_text|post_rejected|no_user_id|no_media_id|too_large|init_failed|processing_failed|HTTP <status>|breaker/exception class), reject_detail (X’s OWN one-line reason for a refused write, parsed off the error body by the pure _reject_detail; folded into detail). That sentence IS the diagnosis: three unrelated bugs hide behind one HTTP 403 — a video over the account’s 2-minute ceiling, duplicate content, and the third-party quote/reply limit — and the body was previously only LOGGED, so telling them apart meant grepping Railway rather than reading the telemetry — one X API v2 write call (POST /2/tweets, .../retweets, .../likes, or v1.1 media/upload; OAuth 1.0a user-context as @tootsiesbar). The outbound half of the X integration (the read side is curator_fetch/x_fetch), provisioning-gated on the 4 X_* env vars; posts/quote-tweets/in-thread replies (reply, #1497 — the “correct” reply under a game winner) + likes the post she amplifies (like — the courtesy favorite that rides alongside a crosspost quote/repost, maybe_crosspost, fail-open) + uploads chart media (upload) or a chunked async video (upload_video, #1497 — INIT/APPEND/FINALIZE/STATUS, since X rejects bare audio so an iTunes preview clip ships as tweet_video); guarded by a circuit breaker (integration=x_poster) + rate limiter + retry, fail-open. Its integration health is rolled up as x_poster in the ops-monitor (a silent all-fail — bad creds / X down / write-cap wall — flags integration_unhealthy, benign unprovisioned/empty_text excluded), the p99 gated by the tweet_posted latency ceiling |
|
x_filter_rule |
utils/x_filter_rules.py (#1497) | op (add|update|delete|get), ok, duration_ms; add carries rule_id on success, update/delete carry rule_id + (update) is_effect, get carries count; on a miss: error (unprovisioned|no_rule_id|HTTP <status>|exception class) — one twitterapi.io tweet-filter RULE call, the “no polling” answer-detection for the X guess-the-song game. A game round registers a to:tootsiesbar filter rule (add_rule → inactive → update_rule is_effect=1 to activate), twitterapi.io POSTs matching replies to /x/webhook, and the round delete_rules it at close. Reads (get) use retry_http; rule WRITES stay single-attempt (a retried add could duplicate the rule). Same prepaid TWITTERIO_KEY credits as the read XProvider; fail-open. The precise conversation-id match is done in-handler, so the rule grammar stays coarse/robust |
|
x_webhook |
utils/healthcheck.py /x/webhook (#1497) |
ok, rule_tag, tweets (# in the payload), dispatched (handed to the XGame cog’s handle_webhook_tweets); on a miss: reason (unauthorized — X-API-Key mismatch | bad_json | dispatch_error) — a twitterapi.io filter-rule webhook POST was received (matching replies to a game tweet). Verified on the echoed X-API-Key shared secret (TWITTERIO_KEY); always 200s on a valid authorized POST so the sender never greys out the endpoint (parsing/dispatch fully fail-open). The route exists independently of the XGame cog (P2 ships before P4), so a missing cog is a clean no-op |
|
x_game_round |
cogs/x_game.py (#1497) | action (open|close), ok, guild_id; on open ok: delivered (room = posted to the @tootsiesbar X timeline | staged = auditioned in #bot-logs, no post to X), tweet_id + has_rule (production); on open miss: reason (no_song|no_clip|unprovisioned|upload_failed|post_failed); on close: delivered + won (did anyone guess it) — an X guess-the-song round opened or closed. Toots posts an urban-90s+ song CLIP to @tootsiesbar, a to:tootsiesbar filter rule catches the replies (webhook → handle_webhook_tweets), the first correct guess ends the round with a single board reply (a music-drop-toned reveal + a light nod to the winner + the running leaderboard) under the clip + a music-drop quote of the clip with the answer’s Apple Music link, else a NO-WINNER round skips the board/leaderboard entirely and just quote-reveals the answer (no “nobody got it” complaint). ACCOUNT-global (one round open at a time across guilds); gated by the x_game experiment (OFF default — posts to a public external timeline like x_crosspost) + master switch + mood. Song pool reuses the /guess game’s generate_song_pool (urban bundle + 90s-onward era), each pick iTunes-verified to a real preview + art. Fail-open |
|
x_game_answer |
cogs/x_game.py (#1497) | ok, won=True, username (the X handle, folded into detail), count (their new win total) — the FIRST correct guess in an X guess-the-song round. Fires once per round (guarded by the atomic x_game_claim_winner first-winner claim, so a race between two correct replies still yields one winner + one point); the win ENDS the round with the single board reply (crown + reveal + leaderboard) under the clip, and a leaderboard point. A wrong guess emits nothing |
|
curator_evaluated |
cogs/curator.py (#curator ops) | guild_id, channel_id, reason (no_seeds = channel has no source accounts, post links | no_history = channel empty/unreadable | no_posts = the source fetch returned nothing | no_candidates = every fetched post is already seen or non-postable (reply/repost/no-media) | none_fit = the fit-judge rejected all | no_channel), mode (image|text, when known) — the curator ran a slot but posted NOTHING (the SKIP half of the ship funnel). With curator_posted it makes a surface going quietly DARK visible: the ops-monitor renders a Curator health line (post rate + skip-reason funnel + source/mode split) every run and flags curator_dark (high when none_fit-dominated = users see silence; medium when no_seeds/no_history = channels just need links). The curator has no 0-1 score event (the judge is pick-or-skip), so this is its surface-dark telemetry |
|
comment_read |
utils/comments.py (#1272 follow-up) | url_host (≤40 chars), count (# comments after like-rank), ok; on a miss: error (unprovisioned|unsupported_url|fetch_failed|bad_shape) — the read_comments /ask tool ran: the audience REACTION on a specific TikTok/YouTube/Instagram/Reddit/X (Twitter) post (top comments ranked by likes), returned as TEXT for the model to synthesize (“read the room”, no links; the crowd’s-eye complement to read_media’s transcript). TikTok/YouTube/Instagram/Reddit ride ScrapeCreators (bot.scrapecreators); X REPLIES ride twitterapi.io (bot.xprovider_primary → XProvider.fetch_tweet_replies over /twitter/tweet/replies — ScrapeCreators has no tweet-replies endpoint, so the X reply thread is the “comments” there). Per-platform mappers normalize to one Comment(text, likes) shape; NSFW-agnostic (comment text only). Offered when EITHER client is provisioned; fail-open. Pure mappers + formatter in utils/comments.py (unit-tested); handler cogs/ask.py:_read_comments |
|
social_lookup |
utils/social_profile.py (#1272 follow-up) | platform (tiktok|instagram|youtube|twitter, normalized from x/ig/yt/tt aliases), handle (≤40 chars), ok; on a miss: error (unprovisioned|bad_platform|no_handle|fetch_failed|not_found) — the social_profile /ask tool ran: ONE account’s PROFILE (follower count, verified badge, post/video count, bio, account age) on TikTok/Instagram/YouTube/X-Twitter via ScrapeCreators (/v1/tiktok/profile, /v1/instagram/profile, /v1/youtube/channel, /v1/twitter/profile). The structure complement to social_search (which FINDS posts) — the model relays the real stats, never invents a follower count. Per-platform pure mappers normalize to one SocialProfile shape; provisioning-gated on bot.scrapecreators; fail-open. Pure mappers + formatter in utils/social_profile.py (unit-tested); handler cogs/ask.py:_social_profile. Distinct from search_socials (find posts) and discord_lookup (this server’s members) |
|
reddit_search |
utils/reddit.py (#1272 follow-up) | query (≤80 chars; a subreddit scope folds in as r/<sub>: <query>), count (# posts after score-rank + NSFW filter), ok; on a miss: error (unprovisioned|empty_query|fetch_failed|bad_shape) — the search_reddit /ask tool ran: Reddit DISCUSSION/opinion on a topic via ScrapeCreators (/v1/reddit/search, or /v1/reddit/subreddit/search when scoped), returned as TEXT for the model to SYNTHESIZE — NO links surfaced (owner steer: Reddit is an INPUT source we work into her own voice, never a URL she pastes — “not reddit-nerdy”; RedditPost has no permalink field). Each post carries title + subreddit + the discussion text + a traction signal (score + comments); NSFW (over_18) posts are dropped, results score-ranked. Provisioning-gated on bot.scrapecreators; fail-open. Pure mapper + formatter in utils/reddit.py (unit-tested, incl. a “never links” assertion); handler cogs/ask.py:_search_reddit. For opinion/discussion, distinct from web_search (breaking news) |
|
social_search |
utils/social_search.py (#1272) | query (≤80 chars; the search terms, #<tag> for hashtag, or trending:<REGION> for the trending tool), platform (all|tiktok|youtube|instagram), count (# merged hits returned), ok; on a miss: error (unprovisioned|empty_query|bad_platform) — the search_socials / discover_trending / search_hashtag /ask tool ran: unified cross-platform social DISCOVERY via ScrapeCreators, MERGED + engagement-ranked, so the model FINDS fresh social content uniformly (the “search all social platforms uniformly” ask) instead of a per-platform tool. search_socials is a keyword SEARCH (TikTok /v1/tiktok/search/top + YouTube /v1/youtube/search + Instagram reels /v2/instagram/reels/search); search_hashtag is the #-tag sibling (TikTok /v1/tiktok/search/hashtag + YouTube /v1/youtube/search/hashtag); discover_trending is the no-keyword TRENDING feed (TikTok /v1/tiktok/get-trending-feed region-scoped + YouTube /v1/youtube/shorts/trending). Each hit carries the platform, caption/title, author (with a ✓ verified badge), engagement (likes/plays/views), duration, and the LINK (Discord unfurls it). Provisioning-gated on SCRAPECREATORS_API_KEY (bot.scrapecreators); fail-open per platform (a source miss contributes nothing). Pure mappers + orchestrators in utils/social_search.py (unit-tested); the handlers are cogs/ask.py:_search_socials/_discover_trending/_search_hashtag. Full field-coverage ledger in docs/INTEGRATIONS.md. Distinct from read_media (read ONE known URL) and web_search (a general web fact) |
|
music_dedup |
cogs/music.py | guild_id, channel_id, channel_name, decision (similarity_gate/song_gate), signal (same_link/text_similarity/shared_run/content_overlap/same_song), post_preview — a composed drop was too similar to a recent one (the shared take-text/link dedup, similarity_gate) OR it re-picked a SONG already dropped in the last ~30 days (the durable song-identity reuse block, song_gate/same_song — the “Folded keeps re-dropping” fix; scheduled drops only, one retry then skip the slot) |
|
music_link_missing |
cogs/music.py | guild_id, channel_id, channel_name, must_post, attempt, post_preview | |
commentary_posted |
cogs/commentator.py | guild_id, channel_count (rooms sent to; 0 on a staged audition), trigger (pregame|goal|halftime|period|final|interval|clutch_interval), sport, game_key, delivered (room = posted to the sports channel(s), live_scores experiment in production; staged = the line auditioned in #bot-logs, experiment in staging, room kept quiet), post_preview (the shipped line, POST_PREVIEW_CHARS; graded by the live-log eval pass on delivered=room), depth_legs (a comma-joined list of which optional depth blocks landed on this post — odds #476, props #390, team_stats/pregame/standings #432, payout #432 Epic E, market_edge #622, bets #623 — folding the former 8 has_* booleans into ONE field, #1296; leg names are non-overlapping so depth_legs contains '<leg>' reads each leg’s landing rate), depth_shed (#779: API-Sports near its daily-request cap so the OPTIONAL per-game depth — leaders/team-stats/pregame/standings, all API-Sports — was SKIPPED to preserve budget for the load-bearing live scores + bookie settlement, reverting to score+events only; read from bot.usage_status at the 0.85 LOW_PCT bar, fail-open). The live sports commentator posted a line for a live game; gated by the live_scores experiment + mood + master kill switch, paced by the pure utils.sportsdata.cadence decision. Ops-monitor watches the SHIPPED depth mix (a “Live sports commentary depth” line: room-post count + trigger spread + each depth_legs leg’s landing rate) so a silently-broken depth leg — the standings fetch dying, final/pregame never firing — is visible every run, backed by the api_sports market_fetch integration-health rate |
|
commentary_scored |
cogs/commentator.py | guild_id, game_key, trigger, sport, score (0-1), reason, shipped (>= the 0.6 ship floor), post_preview — the commentator’s self-gate (commentate_score, Haiku) scored a composed line before sending. Parity with discourse_scored/music_scored: a sub-floor line is dropped (no must_post here), and the ops monitor watches it for low_quality. Fail-open: a scorer exception ships the line anyway |
|
commentary_deferred |
cogs/commentator.py | guild_id, game_key, trigger, sport, reason (stagger) — cross-game spacing (#502) held a routine read back so concurrent live games don’t post a wall to one channel. The guild already hit its per-tick post budget (_MAX_ROUTINE_POSTS_PER_TICK); the deferred game’s PostState is left untouched so the cadence re-fires it next tick. MILESTONE triggers (goal/final) are never deferred (a reaction loses value if held) but count toward the budget so routine reads space around them |
|
highlight_fetch |
utils/highlightly.py | sport, query (away @ home + date), ok, result_count (# VERIFIED clips), duration_ms; on a miss: error (no_key|unmapped_sport|HTTP <status>|<exc>) — a Highlightly /highlights API call, the commentator’s post-game video-clip source (#432, covers soccer + NBA). Key-gated on HIGHLIGHTLY_API_KEY, fail-open to [] |
|
highlight_posted |
cogs/commentator.py | guild_id, game_key, sport, delivered (room = posted to the sports channel(s); staged = auditioned in #bot-logs, live_scores experiment in staging), source (the clip’s aggregation source, e.g. youtube/twitter), ok, channel_count (room) — the commentator posted a post-game highlight clip for a completed game. Once per game (durable dedup via sports_post_state.highlight_posted_ts); clips lag the whistle 0-48h so a completed game is re-checked on a pace (_HIGHLIGHT_RECHECK_SECS, 60min) until a VERIFIED clip lands. Gated by the live_scores experiment + mood + kill switch (#432) |
|
abuse_warned |
utils/abuse_tracker.py | guild_id, user_id, violations | |
abuse_silenced |
utils/abuse_tracker.py | guild_id, user_id, violations | |
bot_power |
cogs/settings.py | guild_id, user_id, enabled (the master kill switch / “complete off button” was flipped on /menu. enabled=False takes Toots fully dark in the guild: no mentions, voice, chime-ins, scheduled posts, memory writes, or slash commands; only /menu stays reachable so a mod can flip her back on. Distinct from mood=off, which only mutes the proactive surfaces. Per-guild servers.bot_enabled flag, default on, checked at every entry point via db.is_bot_enabled: the listeners/schedulers gate inline and the slash surface is gated globally by bot._GatedTree.interaction_check) |
|
scheduler_firehose |
cogs/calendar_view.py | guild_id, user_id, enabled (≥1 surface on firehose), count (# surfaces) — the PER-SURFACE scheduler firehose set was changed on the /menu calendar (the 🔥 button → a surface-picker sub-view, _FirehoseView). A surface in the guild’s firehose SET fires at EVERY working-hours slot (its per-slot calendar assignment ignored); the rest of the grid still governs the other surfaces — resolved in the single chokepoint schedule_calendar.calendar_hours (surface in firehose_surfaces → all_slot_times(working_hours), else the stored calendar). Per-surface, not all-or-nothing (the affordable shape per the #firehose cost pull: firehose the cheap token-only surfaces curator/discourse — no metered wall — and leave the metered market_drop/bet_board, which are SGO-capped and already resource-bound, on the normal calendar). Still thinned by each surface’s channel config + dedup + the 0.6 self-gate + rate limits; never overrides a muted (OFF) mood; grid stays fully editable. Off by default |
|
reaction_added |
utils/reactions.py + cogs/starboard.py | source (chimein/starboard/…), guild_id, channel_id, message_id, emoji |
|
starboard_report |
cogs/starboard.py | guild_id, user_id, period (1h|1d|1w), threshold, channels_swept, messages_scanned, eligible (# cleared-but-not-promoted), wall_known — the /star sweep listed messages that cleared a board but aren’t on the wall, as jump links for a human to react to (MEE6 ignores the bot’s own reaction, so only a person reacting re-triggers MEE6; an earlier bot re-react mode was removed for that reason). Runs in the background and posts the list when done. Filters out already-promoted messages via the jump-link ids on the wall’s MEE6 posts |
|
walloffame_leaderboard |
cogs/starboard.py | guild_id, user_id, entries (total bangers across members), contributors (# members with a banger; 0 = no seed yet) — the bangers board ran (in the /leaderboard stats hub): ranks all-time BANGERS per member (posts that CLEARED the wall bar, 10+ 😂/😭) read STRAIGHT FROM the member_engagement seed (no scan). The seed’s would-be-wall count — a SUPERSET of MEE6’s actual wall promotions (the seed doesn’t capture those; the card’s separate “wall bangers” flex still reads the wall). All-time, uncapped; names resolve via the guild / identity map |
|
reaction_leaderboard |
cogs/starboard.py | guild_id, user_id, contributors (# members with reactions; 0 = no seed yet) — the reactions board ran (in the /leaderboard stats hub): ranks all-time reactions pulled per member, read STRAIGHT FROM the member_engagement seed (no scan; the windowed 6mo/1w/1d live scans were dropped when it moved to the seed). All-time, uncapped; embed-fixer reposts folded onto the real user, MEE6/other bots skipped; names resolve via the guild / identity map |
|
funniest_leaderboard |
cogs/starboard.py | guild_id, user_id, contributors (# members with laughs; 0 = no seed yet) — the funniest board ran (in the /leaderboard stats hub): ranks all-time laughs pulled per member by laugh_reactions (😭/😂), read STRAIGHT FROM the member_engagement seed (no scan) — the comedy-VOLUME twin of the bangers board (wall-clearing posts) and the leaderboard mirror of the awards’ Funniest. A public board in the shared toggle (alongside bangers + reactions); all-time, uncapped; names resolve via the guild / identity map |
|
card_leaderboard |
cogs/starboard.py (built off cogs/playercard.py) | guild_id, user_id, contributors (# active members ranked; 0 = no seed yet) — the card OVR board ran (in the /leaderboard stats hub): active members ranked by their /card OVR off the SAME member_engagement seed the card reads (so a member’s board OVR matches their card), rendered through the shared _board_pages. Rows show the OVR number. Gated on the playercard experiment (PRODUCTION=everyone, STAGING=mods-only preview, OFF=hidden — the same gate /card uses); it joins the toggle picker (bangers / reactions / card / besties) only in PRODUCTION, 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) |
|
besties_leaderboard |
cogs/starboard.py (off utils/social_graph.py) | guild_id, user_id, scope (everyone|current — the show: membership filter, defaults current), pairs (# pairs ranked; 0 = no seed yet / none cleared the floor) — the besties board ran (in the /leaderboard stats hub): the server’s closest member PAIRS ranked by Ochiai closeness (== cosine similarity) over the engagement seed’s reply+mention graph, activity-normalized so it surfaces genuine duos not the loudest hubs. Reads the same seed /card does (no scan); playercard-experiment-gated. A pair board (A ↔ B · % rows) IN the shared /leaderboard toggle picker (no separate command). scope is the UNIVERSAL show: filter (here-now / everyone) that applies to every leaderboard board, defaulting current: keeps only pairs where both are still in the guild; everyone includes departed members (resolved to real names via the identity map). Pure closeness core in utils.social_graph.closest_pairs (unit-tested) |
|
engagement_snapshot |
cogs/stats.py (#1017) | guild_id, members (# rows snapshotted), trigger (scheduled = after the daily catch-up fold; bootstrap = the first snapshot on cog-load so windows aren’t empty until the next daily run; backfill = a /stats period: reconstructed snapshot stamped at a PAST day so the windowed board reads accurately immediately) — a SNAPSHOT of a guild’s engagement seed was frozen into engagement_snapshots so the windowed (weekly/monthly) /leaderboard stats boards can diff against it (window = current seed − snapshot from N days ago). The daily one is a ~1-line server-side copy of member_engagement.stats (NOT a re-scan), idempotent per UTC day; the backfill one is a reconstructed (current − scanned window) blob (reconstruct_snapshot) past-dated to today−N. Retention bounded by prune_engagement_snapshots. One daily snapshot serves BOTH weekly + monthly by subtraction — no extra scan jobs |
|
memory_write |
cogs/memory.py | guild_id, tier (hourly|daily for the room’s attributed memory; self_hourly|self_daily for Toots’s own-takes pyramid), ok, chars|skipped (low_activity|empty|truncated), message_count, channel_count, rolled_up (# of hourlies synthesized into a daily), backfill | |
memory_forget |
cogs/memory.py | guild_id, user_id, notes_deleted, facts_deleted (consented user_facts rows wiped alongside the observational notes) |
|
user_fact |
cogs/ask.py | guild_id, user_id, stored (True = a new consented fact saved; False = blank/duplicate/db_error), reason (None on store; duplicate|db_error on skip) — Toots stored (or skipped) a fact a regular told her to remember about THEMSELVES via the remember_about_you /ask tool. Consent-based, per-(guild, user), storage uncapped with the per-reply read-back bounded to the newest USER_FACTS_READBACK; the fact only personalizes her replies to that user (read back via the <about_them> block), never disclosed to others; /forget wipes it |
|
poll |
cogs/polls.py | guild_id, channel_id, user_id (the asker; absent on anonymous reads), action (create|list|read|update|end), ok, reason (None on success; invalid|no_permission|not_found|forbidden|not_mod|already_closed|error); create also: options (count), duration_hours, multiple, emojis (# answers given an emoji); list also: count — Toots created or managed a native Discord poll on a regular’s behalf via the create_poll/list_polls/read_poll/update_poll/end_poll /ask tools (#888). create_poll can attach a sanitized emoji per answer; reads are GUILD-WIDE (every readable channel, current first — Discord has no guild-poll API) and read_poll surfaces who voted each option (public votes). Create/list/read are open to anyone (poll-create rides the ask bucket); end/update are mod-gated. Discord can’t edit a live poll, so update CLOSES it + reposts fresh (votes reset). Fail-open: a bad request / missing permission / discord blip degrades to a soft status line |
|
scheduled_event |
cogs/scheduled_events.py | guild_id, user_id (the asker; absent on anonymous reads), action (create|list|read|update|end|delete|start), ok, reason (None on success; invalid|no_permission|not_found|forbidden|not_mod|already_over|not_scheduled|error); create also: has_location, has_description; list also: count — Toots created or managed a Discord scheduled event via the create_event/list_events/read_event/update_event/end_event/delete_event/start_event /ask tools (#888). read surfaces who’s interested (top names, public); end leaves a cancelled/over tombstone, delete removes the event entirely, start flips a not-yet-started event live early. List/read open to anyone; create/update/end/delete/start are mod-gated AND need the bot’s Manage Events permission (checked in the cog). External events (free-text location); fail-open throughout |
|
memory_search |
cogs/ask.py | guild_id, query, hits, vector (True = a semantic embedding cosine hit answered it; False = the keyword/FTS path did, and aliases/expanded/fell_back only apply to that path), aliases (rename-aware alias terms OR’d into the query), expanded (thin literal match → the concept was expanded to keyword terms via Haiku and re-searched), fell_back (still thin → backfilled with the durable daily arc so conceptual recall like “messiest thing” gets real material to reason over), detail (True = the recall_detail hour-grained drill-down), proactive (True = the always-on [relevant memory] recall Ask._relevant_memory runs on EVERY answer keyed on the incoming message, not the on-demand search_memory tool; hits is how many notes were injected after dedup) |
|
memory_reindex |
cogs/memory.py | guild_id, and ONE of: tagged (# of legacy notes given concept keywords) OR embedded (# given a semantic-recall vector) — the keyword and embedding /remember reindex passes each emit their own |
|
embedding |
utils/embeddings.py | ok, model, duration_ms; on success: chars, dims, batch (# inputs on an embed_batch, absent for single embed); on failure: error (empty_input|no_embedding_in_response|HTTP <status>|<exc>) — one OpenAI text-embedding call (a memory note at write/reindex time, a recall query, or the Kalshi D3 catalog/query embed) |
|
kalshi_shortlist |
utils/markets.py | ok, duration_ms, chars; on success: hits (# shortlisted), catalog (index size); on a query-embed miss: error (query_embed_miss) — the D3 (#480) semantic Kalshi discovery ranked the FULL series catalog (cosine over the SeriesVectorIndex) for a query and returned the top-k shortlist handed to pick_kalshi_series in place of the volume-top-1000 title list (finds an on-topic but low-volume series, and shrinks the stage-1 prompt). Fail-open: an empty shortlist / no index / no OPENAI_API_KEY falls back to the title list |
|
kalshi_lexical_search |
utils/markets.py | ok, duration_ms, chars; on success: hits (# events returned), catalog (event-index size, ~7k) — the EVENT-level LEXICAL (BM25) Kalshi discovery (KalshiClient.search_events, utils/lexical_index.py). Kalshi has no search API and team/player names live at the EVENT (“Spain vs Saudi Arabia: Goalscorer”) + MARKET (“Lamine Yamal: 1+”) level, NOT the series title (“World Cup Assists”); a first semantic event index leaked badly on proper nouns (“Spain Saudi Arabia” matched Israel–Saudi geopolitics / OPEC / WTA, never the soccer game — entities want exact tokens, not embeddings) and was reverted for this BM25 lexical index. Built once per hourly refresh over the ~7k open-event docs (each doc = event title + sub_title + every nested market label, captured for free by flipping the existing open-events walk to with_nested_markets=true); pure/in-process/no embeddings, prices re-fetched live per query (get_event_markets via /markets?event_ticker=X, the filter that works — /events?event_ticker is silently ignored). Routes BOTH the general kalshi tool (event-first, series-index as fallback) and kalshi_player_props (matched matchup events, team-token + _is_player_prop_event guards, per-player collapse). Last-mile SELECT (#kalshi-line): the FTS ranks a matchup’s ~25 markets by lexical relevance, so the game moneyline is rarely the top hit (measured: KXWCGAME ranked #4 for “England Argentina”, behind first-team-to-score/correct-score/advance), and the old event-first path took events[0] blindly — grabbing “First Team to Score” over the moneyline (the ARG@SUI live-bet outage: the /bet board offered a game whose _sides() was empty because the wrong Kalshi market was folded). The unified MarketsManager.kalshi_search(query, *, intent, n, k, expand) now does (optional OR-expand for recall) → FTS → ClaudeClient.pick_kalshi_events (a Haiku last-mile pick of 1..N events matching the caller’s stated intent), so FTS stays the cheap recall narrowing and Haiku is the precision selector over the small candidate set (no whole-catalog model call, no external search service). Callers pass their intent: KALSHI_INTENT_MONEYLINE (the game-winner line — bookie reprice + commentator via fetch_prediction_snapshots, the betting surfaces, and the /ask game-market tools) vs KALSHI_INTENT_GENERIC (the open kalshi tool). The selection is cached per (folded_query, intent, n, sport) so the ~60s/live-game reprice+commentator paths collapse to ~one Haiku pick per game (prices stay fetched fresh); fail-open to the top FTS hit if the picker errors / isn’t wired. Emits claude_api purpose=kalshi_event_pick. SPORT-SCOPING + richer candidate metadata (#kalshi-line): an OR-rewritten matchup query (db._or_search_terms, #1325 — needed since Kalshi indexes the CITY only, so a full-team-name AND-query found nothing) floods on a common city token — “Los Angeles” pulled 361 candidates across SIX sports (MLB/MLS/NBA-summer/WNBA/T20-cricket/NFL) and pushed the MLB moneyline to rank 21, out of the old k=12. Two levers fix it: the index now captures Kalshi’s product_metadata.competition/competition_scope + a derived sport column (_kalshi_competition_to_sport: ‘Pro Baseball’→baseball, ‘FIFA World Cup’/’MLS’→football, …), so kalshi_search(sport=...) (the caller’s known game.meta['sport'], threaded through fetch_prediction_snapshots) SCOPES the FTS to that sport (WHERE sport = $) and the cross-sport flood drops out; and k is widened to 40 (_KALSHI_SEARCH_K) so a buried moneyline is still retrieved. A sport-scoped fetch that returns EMPTY (stale/unmapped sport) falls back to the UNSCOPED fetch, so scoping only ever narrows. The picker also now SEES the richer per-candidate metadata (subtitle matchup+date · competition·scope market-domain/type · market_labels outcome legs, via _format_kalshi_candidate) so it tells a game moneyline from a season/futures/prop market of the same name. Dry-run-validated 8/8 across soccer/MLB/NBA/NFL + generics with a real Haiku pick (scripts/dryrun_kalshi_matrix.py); the sport/competition/scope columns fill on the next hourly index refresh after deploy. Fail-open to [] |
|
catalog_lookup |
utils/apple_music.py + cogs/ask.py | Emitted at two layers: (1) the apple_music raw-call event — query, entity (song|album|artist|artist_songs), results, ok, duration_ms (the iTunes-call latency telemetry); (2) the cogs/ask.py FAILOVER event (#847) — query, entity, source (itunes|deezer|none = which feed answered after the iTunes→Deezer failover), ok (source≠none). The cog event has no duration_ms (stays out of the latency table); its source field makes the failover observable — a spiking deezer rate = iTunes throttling, a none rate = both feeds down. The lookup_catalog tool now routes through the utils.music facade: Apple Music / iTunes primary (exact durations / tracklists / discographies / the catalog-wide artist_songs longest-song scan), falling back to Deezer’s public catalog API on an empty-or-throttled iTunes result (song/album/artist full parity; artist_songs degrades to the discography with a (via Deezer; iTunes was throttling) note), and on a both-miss a throttle PROBE (apple_music.probe_throttled) distinguishes a real (catalog feeds are throttling...) DOWN message from a genuine (no catalog match for that) — the catalog twin of the markets DOWN-vs-empty fix |
|
feed_status |
cogs/ask.py | guild_id, degraded (the source keys whose circuit breaker is OPEN — sgo/the_odds_api/api_sports/polymarket/kalshi), near_cap (the source keys at/over the LOW_PCT usage threshold from the health cog’s cached snapshot) — a proactive LIVE FEED STATUS block was injected into an /ask answer (#847): the model was shown which data feeds are DOWN / near-cap right now AND where to route instead (failover from utils.sportsdata.sources.CAPABILITIES), so it never reads a downed feed as “no games” or fabricates a slate (the proactive complement to the reactive per-handler DOWN-vs-empty messages). Built from in-memory breaker + usage flags only (utils.feed_status.build_feed_status, NO network); the usage snapshot is cached on bot.usage_status by the health cog’s ~30min poll. Emitted ONLY when the block is non-empty (a degraded/near-cap feed exists); the healthy case injects + emits nothing |
|
reference_lookup |
utils/reference.py + cogs/ask.py | also emitted by the standalone song_credits /ask tool (#847 — Genius promoted into its own tool, emitting this SAME event with source=Genius\|none so its integration-health telemetry survives bypassing the reference dispatcher). query, aspect, source (Genius|MusicBrainz|Wikidata|Wikipedia|none = the source that ANSWERED), tried (the source names SELECTED by intent, in order — a source in tried but NOT source matched the intent but returned None and fell through; source=Wikipedia + tried=[Genius, Wikipedia] = Genius was selected and came back empty, the signal for a silently-dead keyed source like Genius vs one merely never triggered), result_kind (chart|article|music|factoid|credits|samples|annotation|song|none), url, entries (chart kind), cache_hit (served from the durable reference cache, namespace reference), ok, duration_ms, error (empty_query|no_result on a miss) — the lookup_reference /ask tool ran: an authoritative citable fact from the reference library, routed by intent across sources — Genius (song credits, samples/interpolations, annotations/meaning, identify-by-lyric; link only, token-gated), MusicBrainz (release metadata), Wikidata (date/age factoids), Wikipedia (Billboard Hot 100 chart-table peaks #292/#335, or the general WIKI READER: resolves the article and hands the model its body prose + tables AND, for a detail/list question, follows the most relevant SUB-article (the filmography, the awards list, the electoral history) and renders its tables too - any topic, not just music) |
|
mention_routed |
cogs/ask.py | guild_id, user_id, intent (recap|discourse|icebreaker|music), period (1h|1d|today for recap, else null) | |
reply_suppressed |
cogs/ask.py | guild_id, channel_id, user_id, reason (broadcast) — someone replied to one of Toots’ own BROADCASTS (a bookie/market card, commentary line, discourse/music/icebreaker post, chime-in) WITHOUT @-mentioning her, so she stayed silent. The reply-restriction firing: she only answers a no-mention reply when it’s a reply to her actual conversational answer (MessageType.reply); a reply to a broadcast (top-level, MessageType.default) is suppressed. Emitted ONLY for this narrow case (not the firehose of every unaddressed message); an explicit @-mention bypasses the skip (she answers) so only genuine suppressions log |
|
links_copied |
cogs/clipboard.py | guild_id, user_id, channel_id, period (1h|1d|1w|1mo|6mo), buffer (the buffer name), count (total links in the buffer after the merge), added (# this copy added; 0 = an idempotent no-op, the buffer already held them all), messages_scanned (# messages walked), hit_ceiling (the scan hit _MAX_SCAN_MESSAGES so the reply warned older links may be missing — no SILENT loss) — /copy scooped a channel’s links (over the window, bot posts NOT skipped so embed-fixer webhook reposts are caught, deduped in original order) into a named per-user buffer. Re-copying MERGES (union, deduped, order-preserving) rather than overwriting: every prior link is kept (no loss) and a re-copy of the same window adds nothing (idempotent). URLs are extracted as the history streams, so only the link set is held in memory. Mod-only; the scan runs in the background |
|
links_pasted |
cogs/clipboard.py | guild_id, user_id, channel_id, buffer (the buffer name), count (links in buffer), delivered (# actually sent), via (always bot — posted as Toots via channel.send, no webhook), ok (all links sent) — /paste posted a saved link buffer into a channel (possibly in a different server), one link per message (each unfurls), paced + run in the background |
|
links_distributed |
cogs/clipboard.py | guild_id, user_id, buffer (the buffer name), count (links in the buffer), routed (# a room was found for), dropped (# fit no room → left in the buffer), delivered (# actually posted), channel_count (# rooms it placed into) — /paste out:curate sorted a buffer’s links across the guild’s curator channels by best fit (/paste run through curation): each link enriched (utils.link_enrich) → routed to its single best-fit room by claude.route_curator (the curate feed’s best-home judge, reused) → posted there fixup-rewritten so it unfurls; a link fitting no room stays in the buffer. Mod-gated, per-run bounded (_DISTRIBUTE_MAX), background task |
|
ask_answered |
cogs/ask.py | guild_id, channel_id, question (the asker’s prompt — the message she answered, not the wider room buffer), post_preview (HER FULL answer, untruncated). Logged so the live-log eval pass grades the real QUESTION+ANSWER pair for fabrication (#315), same framing as the synthetic eval. Internal ops telemetry for the judge — carries the whole answer + the question, an owner-approved departure from the 120-char/no-content preview convention (answer is her own output; question can carry the asker’s info, so logged consciously) | |
answer_length |
cogs/ask.py + cogs/voice.py | guild_id, user_id, chars, sentences — measures a reply’s length on the way out (a typed @mention via cogs/ask.py, or a spoken voice-note summons via cogs/voice.py’s shared deliver; the typed-mention-voiced case records in ask.py, so the voice cog only emits for user_sent_voice to avoid a double count), observation only, it never trims or rewrites her words. Length is owned by the model: the ask prompt carries a declarative LENGTH POLICY case dictionary (a take/opinion/banter = two sentences; a real how-it-works explainer / a requested list / code runs as long as it must; a deflection = one line) and she matches her answer to the case and adheres. This event records what she actually shipped so a drift back toward wordy takes (the Spielberg-films report) surfaces as a climbing length distribution (a rising p90 sentences/chars) — the early-warning that the policy prompt needs tightening (a prompt edit, never a length value). Deliberately trust + observe, not a regex enforcement guard (owner steer on #676): the live dry run showed she self-adheres to a clean case dictionary, so a hard backstop is only added back if the telemetry proves she drifts |
|
tts_synthesize |
utils/tts.py | ok, mode (speak|sing); on success: chars, bytes, duration_ms; on failure: error (+ chars/duration_ms/detail) | |
stt_transcribe |
utils/stt.py | ok; on success: bytes, chars, duration_ms, plus (when the upstream returns them) language (detected language code) and speakers (# distinct speakers diarized — a multi-speaker clip comes back as a speaker-attributed transcript, surfaced via client.last_language); on failure: error (+ bytes/duration_ms/detail) |
|
video_transcribe |
utils/video_fetch.py | ok, source (captions|audio|x_text|scrapecreators|none|unavailable); on success: chars, video_secs, duration_ms, lang (captions only), has_meta; on failure: reason (no_info|no_captions_no_stt|too_long|transcribe_failed|no_text_no_video) (+ video_secs/has_meta). x_text = an X/Twitter post resolved to its tweet-text floor via fxtwitter (#491); audio covers a YouTube clip OR an X post’s mp4; scrapecreators = a TikTok/Instagram transcript (+ TikTok metadata) resolved via ScrapeCreators (#1272) — yt-dlp resolves those two hosts poorly from a datacenter IP, so fetch_video routes them there first when provisioned, a miss falling through to yt-dlp. A long/foreign transcript is then summarized to English by a claude_api call with purpose=video_summary |
|
x_fetch |
utils/x_fetch.py | ok, duration_ms; on a hit: has_text, has_video; on a miss: error (no_tweet_id|HTTP |
|
x_media_fetch |
utils/video_fetch.py (_download_media) |
ok, duration_ms; on a hit: http_status, bytes; on a miss: error (HTTP |
|
voice_note_sent |
utils/discord_voice.py | channel_id, bytes, duration_secs, is_reply | |
voice_reply |
cogs/voice.py (shared deliver, fired for voice-note replies AND typed @mention answers the model nominated for voice — a plain text mention with no <voice>/<sing> tag skips deliver entirely and emits nothing) |
guild_id, user_id, delivered (speak|sing|text; text = voice unprovisioned, a gate veto, or a synth/send fallback), gate_reason (reply_in_kind = a voice note; model_candidate = the model nominated voice on a typed mention; no_trigger/disabled/too_long = stayed text), model_candidate, sing — voice is a permanent surface: it speaks whenever ElevenLabs is provisioned (bot.tts) and the gate clears |
|
image_generated |
utils/image_gen.py | ok, model; on success: chars, bytes, duration_ms, revised (bool: OpenAI returned a safety-rewritten revised_prompt, surfaced full-text to the caller via client.last_revised_prompt but only flagged — not logged — here, since the revised text is user-derived); on failure: error (empty_prompt|too_long|no_image_in_response|HTTP <status>|<exc>), plus chars/duration_ms/detail where available (chars only, never the prompt text) |
|
image_reply |
cogs/ask.py | guild_id, user_id, delivered (image|capped|text; image = PNG + caption sent to the room; capped = per-guild daily image budget spent, caption went out as text; text = the model nominated an image but generation/send failed so the caption went out as a plain reply), has_caption (on a delivered image), reason (generate_failed|send_failed|unsupported_source|daily_cap, on a text/capped fallback). Image is a permanent surface: it posts whenever OpenAI is provisioned (bot.image), bounded by the per-guild image_daily_cap budget (#326) |
|
gif_search |
utils/gifs.py | query, ok, results (# candidates), duration_ms, error (on failure) — a Giphy search ran (GIPHY_API_KEY-gated client) |
|
x_reply_draft |
cogs/x_reply_draft.py | guild_id, channel_id (the room the content shipped in; None for scout), ok, delivered (bot_logs|skip), reason (dedup|daily_cap|fetch_failed|declined|send_failed|error), trigger (listener|reply) — Toots drafted the X reply she’d leave under a sourced tweet and posted the draft to #bot-logs — the owner’s working queue — master guild only, pinging the master user only (users-only allowed-mentions), with the tweet’s BARE fixup link so Discord unfurls it inline under the draft; the OWNER posts the reply on X by hand — the hand-off loop that scales replies past the API tier’s third-party-reply 403 (#1732, epic #1793; measured: 12 owner replies out-viewed 48 originals ~13×). Two triggers, one path: listener = a tweet she linked in a Discord post; reply = the wire desks’ THIRD lane (owner steer: alongside biggest + breaking, named “reply”) — each pop_desk/sports_desk/cinema_news slot drafts the freshest-hottest story (pick_reply_stories: the same order_for_breaking ranking on a wider 3h X_REPLY_WINDOW_HOURS) the posting lanes did NOT claim (those get listener drafts on delivery), riding the same slot + wire fetch via the fire-and-forget kick_reply_lane (never delays desk delivery; X_REPLY_PER_SLOT=1). Grounded on the fetched tweet (fxtwitter, keyless) + the live context beat (utils.context_beat.gather_context_beat, Grok X-crowd + Perplexity, framing="breaking", purpose=x_reply_draft — the draft USES the context/angle the beat surfaces (that’s what it’s paid for), with only its NUMBERS fenced from being quoted as her own stat; fail-open to a tweet-only draft) + steered to ADD not echo (the owner rejected auto-quotes as dead-similar); the model is the per-guild x replies Models-page knob (tunables.SETTING_X_REPLY_MODEL → claude_client.x_reply_model_id, resolved live so a /menu flip needs no restart), defaulting SONNET (a deliberate COST call, #1839 — see the model note below); it gets its OWN row rather than borrowing discourse’s or market drop’s, because those families share their donor’s OUTPUT SHAPE while a reply draft is vision-heavy (~2.8k input tokens/call) and leans on WORLD KNOWLEDGE — a different axis — so a guild must be able to run discourse on Sonnet while replies run Opus; self-declining (EMPTY → reason=declined, a path that has never actually fired — deliberate, since the owner is the filter and a weak draft still surfaces the reply TARGET). De-hollowed (#1834, owner report “her x replies are hollow”): a real-wire dry run scored only 13% of drafts substantive — the rest captions for the photo (“that artwork is doing something”). The cause was three clauses that left captioning as the only legal move — a hard brevity wall (under 15 words, no comma-chained clause) that forbade carrying a specific AND a read, the chatter block being fetched then BANNED from use (compose_market_drop’s framing reused on a surface with no grounding blob to quote instead — so the one source of outside substance was paid for and thrown away), and “react to what you actually SEE” inviting the caption directly. Rewording the three (never adding a “be substantive” rule, per docs/PROMPT_OPTIMIZATION.md) took substance 13% → 38% — that reword is what carries this surface, and it is model-independent. The MODEL was a second, optional lever, tried and then REVERTED on cost (#1839, owner steer “move back to sonnet opus is too expensive”): on the same rebuilt prompt and identical material Opus scored 66% vs Sonnet’s 34% (n=50 each, +32pp, z≈3.4) — it brings real world knowledge where Sonnet reaches for the visual (it clocked a “puss puss” caption as Swedish for “kiss kiss” and the panicking repliers as wrong; it caught that a July 30 Harry Potter birthday post is a day early since the 30th is Neville’s) — so it briefly shipped on Opus (#1834), but the owner priced that ~26pp against the spend and chose Sonnet — and the gap is CONCENTRATED, which is what makes Sonnet-by-default a fair trade rather than a flat loss: on tweets carrying their OWN facts (a transfer clause, a meeting, a date) both models score 5/5, and the whole premium buys the tweets needing outside knowledge (a foreign-language caption, an NBA read). So expect any residual hollowness on the pop/celebrity image posts, not the sports/transfer wire. This is a SETTLED tradeoff, not an oversight — don’t “helpfully” route it back to Opus; it’s now a /menu Models-page pick (x replies), so re-buying the gap is a dropdown, not a code change. Ablations that came back a WASH and were therefore dropped: cutting the image clause entirely (40% vs 43% — the residual captioning is the image’s salience in vision, not the instruction) and adding explicit “say something only you’d know” / “not a caption” blocks (50% vs 43%, inside noise). Scoped to an ALLOWLIST of the channels explicitly configured for the X-post surfaces (the union of the /menu channel sets — discourse/music/music-news/music-market/cinema/cinema-news/pop/sports-desk/prediction/betting, via _CHANNEL_SOURCES; so curator rooms, #bot-logs, and any unwired room are out by construction, and the scope tracks the /menu config with no code edit — owner steer); durable per-tweet dedup (x_reply_draft kv ns, 7d) + a runaway daily cap (X_REPLY_DRAFT_DAILY_CAP, 100 — a backstop for a bulk-op burst, not a throttle) + master kill switch + staging-audit/own-tweet/draft-message guards; fully fail-open |
|
x_audience |
cogs/x_audience.py | ok; folded detail: followers, following, statuses, count (sample size), tweets_24h/replies_24h (bot output + owner reply pace), median/total views + likes split by replies vs originals; ok=False reason (fetch_failed|error) — the nightly X audience sweep (epic #1793 item 13): one user/info + one raw recent-tweets page via bot.xprovider_primary, folded by the pure utils.x_audience.audience_summary, one event per UTC day (daily 08:00 UTC + an idempotent boot run, kv-stamped so the every-merge redeploys never dup). The follower curve + the replies-vs-originals split the growth experiment steers by. Bot-wide (not guild-gated), X-source-provisioned, fail-open |
|
gif_reply |
cogs/ask.py | guild_id, user_id, query, ok, delivered (room = shipped to the channel; no_result = search whiffed; vision_skip = she looked and none fit; cooldown = the per-channel gif gap held it back; daily_cap = the per-guild daily gif budget is spent), vision (picked = chosen by looking at the candidate frames; fallback = the vision pick errored so the top relevance hit was used; none_fit = skipped; no_candidates) — the model nominated a gif on a typed @mention via a <gif: query> tag (utils.gif_signal), the inverse-of-voice taste signal: a gif rides ALONGSIDE the text rather than replacing the delivery channel. gif is a permanent surface: it ships whenever Giphy is provisioned (bot.gifs), paced by the per-channel cooldown + per-guild daily cap. She vision-picks which candidate to send (claude.pick_gif, the outbound twin of the inbound gif vision) rather than sending the top text-relevance hit |
Adding a new event: call emit("your_kind", key1=..., key2=...) and add a row to
the table above + the events.py docstring. Use snake_case for kinds and fields. Don’t
include full message content (data minimization, per the constitution).
A feature isn’t done until it’s observable. Every new capability — especially a new outbound integration (an API client, a model call, an external fetch) — ships its instrumentation in the SAME change, not as a follow-up. This is mandatory and unprompted on every change going forward: apply the full bar below to anything that adds or touches a surface, integration, or outbound call — every time, without waiting to be asked. “Do we have full instrumentation for this?” should already be yes by the time the PR opens — never a question the owner has to raise. The bar, in order:
@instrument("your_kind") (or timed_event for a block, @timed/timed_span
for a heavy flow step) so it emits one event with ok + duration_ms. Never
hand-roll a stopwatch — utils/instrument.py owns the clock. Any event carrying
duration_ms is auto-picked-up by the ops monitor’s p50/p95/p99 table; add a
ceiling to LATENCY_CEILINGS_MS (or a LATENCY_CEILING_PREFIXES rule) for each
one if a blown tail should gate, not just render. Instrument EACH distinct
external step as its own event, not one event for the whole feature — if a flow
does an API resolve, then a CDN download, then an STT call, that’s three events,
so the three failure modes stay separable in the logs (the X path’s
x_fetch + x_media_fetch + stt_transcribe split is the worked example,
#491). One bundled event hides which leg actually broke. Naming convention:
an event KIND names WHAT was called (market_fetch, claude_api); a SHARED
integration hit from many surfaces (SGO/Kalshi/Perplexity/Claude from
bet/commentator/ask/discourse/music) also needs WHO called it. Don’t rename the
kind (it’s the stable dashboard contract) — instead wrap the surface’s outermost
entry method in @scoped_surface("commentator") (or with surface_scope(...)),
and utils/instrument.py auto-stamps every nested event/span with surface= +
a hierarchical path (surface.kind[.source], e.g.
commentator.market_fetch.sgo) so the latency + error tables group by the CALL
SITE, not just the integration. Additive + fail-open (no scope → no surface
field, byte-identical to before). SPAN names (@timed) are already hierarchical
(surface.function) — keep that. Wire a new surface at its one outermost entry
method, never per call. Field budget (the Axiom schema cap): every DISTINCT
field NAME across ALL events counts against the dataset’s per-dataset field limit
(the tootsies dataset is on the Axiom Cloud plan: 1,024 fields, up from the
free plan’s 256 that #1296 hit; the whole EVENT+log stream shares this one
dataset, so it’s one budget), and AT the cap a never-seen field name SILENTLY
DROPS the whole event at ingest (a 400 naming the field, or a 200-with-failures
partial) — invisibly degrading the exact observability the ops-monitor + health
cog depend on (#1296, how curator_posted went missing on the old 256 cap). The
1,024 cap stopped the dropping, but the owner may downgrade back to free (256),
so the discipline below is kept precisely so free stays a viable fallback — after
the #1296 “full maps push” the footprint is ~146, well under 256 (a
downgrade would be clean), and the tripwire in tests/test_event_schema.py
ratchets it further DOWN as consolidation continues; don’t bump it up.
utils/event_schema.py is the single source of
truth for the field vocabulary, split into three ledgers: SHARED_FIELDS
(the reusable cross-event vocabulary — reuse a name from here when the meaning
matches: source/kind/reason/ok/count/query/phase/delivered, …,
before inventing mycog_specificthing), _EVENT_SPECIFIC (the top-level
fields a dashboard / ops-monitor / consumer actually READS — these keep a real
Axiom column), and _FOLD_FIELDS (the ~124 diagnostic singletons that no
consumer reads: emit() auto-folds any of these into the ONE shared detail
map wherever emitted — the “specific events use maps” model, so they cost NO
top-level column and can never hit the cap; query them with
parse_json(detail)['<name>'], see the _FOLD_LOG for the transition-window
coalesce bridge). To ADD a field: reuse a SHARED name if one fits; else, if a
consumer must read it top-level, add to _EVENT_SPECIFIC; else fold it (add to
_FOLD_FIELDS). tests/test_event_schema.py fails CI on an unregistered field
(the conscious reuse-or-add-or-fold gate) and ratchets the footprint DOWN toward
the 256 free fallback. Two more runtime guards back the registry so a leak can’t
slip past the static lint: emit() JSON-stringifies any dict field value
(_demap), so a
variable-key dict can NEVER flatten into per-key Axiom fields
(home_pcts.<source>/genre_tags.<genre>/context.<key> were exactly this —
put high-cardinality / variable-key data in ONE stringified dict field, not
separate fields named after the keys), and utils/axiom.py inspects the ingest
response’s failed/failures and logs (rate-limited) when Axiom rejects rows —
a silent drop becomes a visible signal. When the count nears the cap:
python -m scripts.axiom_setup --fields audits live-vs-registry (dead +
drop-risk fields), --vacuum reclaims dead-field slots (renamed/removed events),
and a wide/separable event family can route to its OWN dataset via an
AXIOM_*_DATASET env (off the main budget). The dataset is currently on the paid
Cloud plan ($25/mo, 1,024-field cap) which lifted the immediate wall; the footprint
is now under the free 256 (via the fold), so a downgrade is clean — the
consolidation levers above stay the primary tools to keep it there.emit_error(source=..., exc=..., recoverable=...)
so error-triage groups them. Set recoverable honestly (user-impacting →
False).ok=False raises NO
exception, so error-triage never sees it. Wire it into the ops monitor’s
integration-health rate tracking (an OkRate keyed by its dimension +
an integration_unhealthy-style finding) so the silent degradation surfaces.
This is the single most-missed step.events.py
docstring, and (if it changes what the QA routine watches) update the ops
monitor section. The scripts/ops_monitor.py deterministic core is pure and
unit-tested — add a test for any new finding/health branch.The litmus test: if this surface started silently failing in production at 3am, would a graph or a flagged finding show it by the next ops-monitor run? If not, it isn’t instrumented yet.
Field coverage is part of the feature too. When you add or change an outbound
integration, hand the model every answer-valuable field the upstream returns (the
data-minimization rule governs telemetry + her replies, NOT the in-context input),
and make each surfaced field self-describing in its rendered block or tool
description (a legend/label, so the model knows what the value IS). docs/INTEGRATIONS.md
is the per-API coverage ledger: it lists, for every integration, the fields we
surface and the fields we deliberately omit (with the reason). The rule is 100%
of the answer-valuable fields, or an explicit entry there saying what’s missing
and why — keep it in sync when you touch an integration.
Railway dashboard queries: filter logs for the EVENT prefix, then parse the JSON
suffix. Typical queries: count of event=command per minute, p95 of duration_ms
where event=claude_api, sum of output_tokens where purpose=ask for cost tracking.
scripts/ops_monitor.py + .github/workflows/ops-monitor.yml are the bot’s one
automated QA routine, running twice daily (cron: 0 8,20 * * *, + manual dispatch).
A deterministic pass pulls the last ~12h of Railway EVENT logs (GraphQL, via
RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID) and flags both halves of QA:
LATENCY_CEILINGS_MS + LATENCY_CEILING_PREFIXES; the gate is p99, not the mean, since a healthy average can hide a blown tail). Latency is self-contained in one LatencyStats class and tracked uniformly for every instrumented surface — LLM purposes AND external HTTP clients (Perplexity/markets/TTS/STT/video/image/gifs/catalog/link-enrich/link-check/GitHub/Railway/slash commands, anything carrying a duration_ms); claude_api is no longer special-cased, it flows through the same map keyed by purpose (its token stats stay separate in PurposeStats, which just composes a LatencyStats). The report renders one unified p50/p95/p99 table with a trend column, and flags two things: a latency finding when a gated surface’s p99 crosses its ceiling, and a latency_trend finding when a surface’s tail is climbing within the window (earlier-half vs later-half p99, an early-warning before it crosses). Plus hallucinated-link rate per surface, ungrounded chime-ins (0-search posts), failed slash commands, rate-limit pressure, and video transcription health — the video_transcribe resolution fail-rate (no_info/transcribe_failed over real attempts, excluding provisioning/too_long skips); a high rate flags video_unreachable, the datacenter-IP-gating signal (the #1 risk the video feature shipped with), and a video-health line renders every run so the hit-rate is watchable even when it’s fine. Plus integration health — the live success rate (cache hits / no-op guards excluded) of link_enrich per platform, market_fetch per source, the Perplexity hedge rate per surface, embedding per model, openai_api per purpose (the GPT TEXT backend, #openapi-parity), and the ScrapeCreators /ask tools (comment_read/reddit_search/social_lookup/social_search, #1272) rolled up under one scrapecreators key (they share the client) — with benign routing misses excluded (unprovisioned/not_found/empty_query/… don’t count, only a real fetch_failed/bad_shape/HTTP error does, so a user typo can’t false-flag the API): a high fail/hedge rate flags integration_unhealthy / pplx_hedged (a silently-failing external surface — X enrichment dying to guest-token gating, a market source going dark, Perplexity punting, OpenAI embeddings down so semantic recall silently drops to keyword/FTS, a GPT-routed surface silently failing over to Claude — these ok=False/hedge outcomes raise no exception so error-triage never sees them), with an Integration-health line rendered every run. The GPT→Claude provider_fallback count is rendered every run (per purpose), render-only (a graceful fallback isn’t itself an incident — the openai_api fail rate is the flagged signal), so the failover rate is watchable the moment a surface is opted onto GPT. Plus the The Odds API metered budget (#725/#725) — the key (a monthly credit allowance, plan-dependent so verify live via /debug/usage rather than trusting a number here; observed ~100k/month as of 2026-06, was 20k earlier) is the SGO-down backstop for /bet odds + live scores, and it has no usage endpoint, so the ops monitor tracks the lowest credits_remaining off its market_fetch events: a the_odds_api budget line renders every run, and a quota_low finding (high) fires under ODDS_API_CREDITS_LOW (2,000) so a runaway burn (a sustained SGO outage driving the live-scores fallback) is caught before the key exhausts mid-month.discourse_scored/music_scored); a surface whose recent posts mostly land
below SCORE_FLOOR (the bot’s own 0.6 ship gate) is flagged low_quality, and
must_post posts that shipped under the floor are called out (users saw them). Silent
degradation (canned fallbacks / skipped slots / missing links) is flagged from the
*_fallback / discourse_skipped / music_link_missing events.(surface, model)
for any surface that ran on >1 model in the window (the A/B signal — did the GPT
swap score worse than Opus on that surface?), off the model field the scored events
carry; a “LLM spend by provider” section rolls the claude_api/openai_api token
totals up to an anthropic vs openai split with a rough $ estimate (raw tokens are
the exact signal, _MODEL_PRICING the approximate/env-updatable rates) so “is the GPT
opt-in moving spend off the Anthropic bill” is answerable every run; and a low-volume
all-fail floor flags integration_unhealthy when a GPT surface fails 100% below
the 5-attempt rate floor (LOWVOL_FAIL_MIN) — the class the GPT-vision-400 (2 calls/day)
slipped through invisibly.surface_dark) — the louder sibling of low_quality: when a
scored surface lands ~everything below the ship floor (DARK_SCORE_RATE 90%+ of
DARK_SCORE_MIN+ posts) it isn’t “scoring low”, it’s shipping nothing — users see
silence. Flagged high and cause-agnostically across every scored surface
(commentary/discourse/music/…), distinct from low_quality (medium) so it doesn’t
read as “her posts are mediocre” — the misattribution that let the #697 self-gate-drop
hide (the scorer truncated every commentary score to 0.0, so 0 posts shipped). Takes
precedence over low_quality for the same surface (no double-report). The companion
Self-gate health line renders every run (ship-rate per surface + each *_score
judge’s truncation rate) so the blackout signal is watchable even at a healthy 100%/0%.scorer_truncated) — a *_score Haiku judge whose JSON verdict
hits max_tokens gets cut off (the #697 root cause: a truncated reason that dropped the
score to 0.0). Tracked off the claude_api stop_reason; a sustained truncation rate
(SCORER_TRUNC_RATE 15%+ of SCORER_TRUNC_MIN+ calls) on any judge is flagged high
with “raise the budget” — the cause-specific signal that points straight at the bug,
vs surface_dark’s cause-agnostic downstream symptom.settlement_broken
(any bet_settle_tick ok=False → open bets aren’t paying out, high), settlement_stalled
(bets placed but ZERO settle heartbeats → the loop is dead, high), bet_place_failing
(system-reason placement fails — game_gone/no_line, the stale-slate race — over a
rate, excluding user-choice bad_stake/insufficient_funds), payout_undelivered
(settled-result pings not landing), and picker_degraded (/bet opens served
loading/empty). The settle loop emits bet_settle_tick EVERY tick (the alive-heartbeat)
and a paired error (source=bookie_settle) on failure, so a silently-broken payout is
caught by the next ops-monitor run.curator_dark when she posted ~nothing over
CURATOR_DARK_MIN+ slot attempts (none_fit-dominated → high, users see silence; a
supply issue no_seeds/no_history → medium, the channels just need links). Reads the
curator_posted + curator_evaluated funnel; curator_fetch integration health (the
twitterapi.io crater, self-healed by the health cog #1257) backs it up.x_game_round: x_game_open_failing (high)
when opens fail at/over X_GAME_OPEN_FAIL_RATE across X_GAME_OPEN_FAIL_MIN+ attempts —
the game can’t post a round (dominant reason named: no_song=pool-gen/iTunes down,
upload_failed/post_failed=X write, unprovisioned=env vars); and x_game_no_wins
(medium) when X_GAME_NOWIN_MIN+ production rounds close with ZERO wins — clips post but
no guess ever lands, so the answer webhook (the twitterapi.io filter rule + /x/webhook,
x_filter_rule/x_webhook events) likely isn’t delivering, or the pool is too hard /
no engaged followers yet. Staged (audition) rounds are kept OUT of the win funnel so a
staging preview can’t false-trip the no-win watch.(source, exception_class), split by
the recoverable tag from emit_error (non-recoverable = high / user-impacting;
all-recoverable = low, bumped to medium on a burst ≥ BURST_ERROR_MIN), with the
inline traceback + context attached.The report also renders an unconditional Quality spot-check (recent posts + their self-gate scores per surface) so the judge eyeballs voice/quality even on an otherwise clean run — this is what makes it a quality eval, not just a regression alarm.
claude-code-action then judges the flagged samples and files deduped auto-eval
issues (it searches open auto-eval issues first, so no spam; “All clear” files
nothing). The deterministic core (parse/aggregate/evaluate/render) is pure and
unit-tested in tests/test_ops_monitor.py; the Railway I/O is integration-only
(pragma: no cover).
This replaces the standalone Railway log-monitor routine — error monitoring now lives here, one interface and one cadence. There is no separate log-monitor workflow.