a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
How @Toots mentions (the ask flow), /recap, /discourse, the scheduler, and /order pre-flight actually work, plus what to tune when behavior feels off.
Each section follows the same shape:
If you’re trying to fix “Toots feels off when X”, find the command, scan the knobs.
The discourse mood setting (set in /menu) is the single dial that controls every periodic Toots surface. Manual surfaces (@Toots mentions, /recap, /discourse category:, /order) are unaffected by mood, they only see the per-user / per-server rate limits in utils/rate_limits.py.
| Surface | off |
chill |
yaps |
|---|---|---|---|
/discourse scheduled posts |
silent | 2/day at 12pm + 7pm ET | 5/day at 9am, 12pm, 3pm, 6pm, 10pm ET |
| Chime-in | silent | up to 5/day, 40 min cooldown, score >= 0.8 | up to 10/day, 20 min cooldown, score >= 0.6 |
| Hours window (both) | n/a | 9am to 2am ET | 9am to 2am ET |
| Tick frequency | n/a | scheduler: 1/min, chime-in: 1/min | scheduler: 1/min, chime-in: 1/min |
Mood-independent periodics: the DB pruner runs once every 24h (bot.py).
Same backend, same rate limit. The mention handler in cogs/ask.py is a thin Discord-event wrapper around the same _answer() method.
/menu), user under the per-user daily cap.recent_messages(). Bot/webhook posts are filtered out by default (we want what humans are saying).format_for_prompt(). Each message becomes one line: display_name: content [media-labels]. Embed text from auto-unfurled URLs (X posts, articles) is inlined as [embed: title / description (url)].recent_image_urls() walks the messages newest-first and pulls up to 8 image URLs (from attachments OR Tenor/GIPHY embed previews) under the 5 MB vision cap.cache_control: ephemeral so repeat calls hit the prompt cache (~1 k tokens saved each).[ctx, current time...]\n\n{question}\n\nRecent channel chatter: ...{"type": "image", "source": {"type": "url", "url": ...}}.web_search_20250305, always available, Claude decides when to invoke.claude_api event with model/purpose/tokens/latency.| What | Where | Current | Effect of changing |
|---|---|---|---|
| Per-user daily cap | utils/rate_limits.DEFAULT_PER_USER_DAILY |
100 | Higher = more daily usage allowed; /menu setting overrides per-guild |
| Channel context size | cogs/ask.py:_answer() recent_messages(limit=30) |
30 messages | Higher = richer context, more input tokens |
| Vision images per call | cogs/ask.py:_answer() recent_image_urls(limit=8) |
8 | Higher = better visual context, more cost (~85 tokens fixed + variable per image) |
| Vision hard cap | claude_client._call image_urls slice |
10 | Final safety cap regardless of caller |
| Image size cap | utils/feeds._VISION_MAX_BYTES |
5 MB | Anthropic’s vision ceiling; raising won’t help (API rejects) |
| Mention auto-reply on Discord-reply | cogs/ask.py mention handler |
Ignores auto-mention, requires explicit @Toots | Make stricter if mentions get noisy |
| Web search availability | cogs/ask.py:_answer() use_web=True |
Always on | Disabling cuts cost but kills accuracy on factual questions |
| System prompt cache | claude_client._call cache_control |
ephemeral | Remove only if you want to A/B-test prompt changes faster |
limit=30; bump cautiously._period_to_window() in cogs/recap.py maps the choice to a timedelta:
1h → last 60 minutes1d → rolling 24 hourstoday → since midnight UTC (so at 2am UTC this is a 2-hour window)recent_messages(limit=200, within=window, include_bots=True). include_bots=True is critical for feed channels and for /recap to see Toots’s own scheduled posts as part of the room.is_channel_dead(messages) returns True only when the list is literally empty. Anything else (even 1 short message) goes to Claude, who decides whether to quip about thin content.recap_deflected event with the diagnostic (no_permission vs no_messages), post a one-line diagnostic to #bot-logs at full verbosity, return a CHANNEL_DEAD canned quip.claude_client.recap():
web_search_20250305.| What | Where | Current | Effect |
|---|---|---|---|
| Per-user daily cap | shared with /ask via DEFAULT_PER_USER_DAILY |
100 | Same as /ask |
| Channel read limit | cogs/recap.py recent_messages(limit=200, ...) |
200 messages | Higher = more thorough recap, more input tokens |
| Dead-channel threshold | utils/feeds.is_channel_dead() |
0 (only literally empty) | Raise to require N+ messages (we trust Claude to handle thin content now) |
| Vision images for recap | cogs/recap.py recent_image_urls(limit=8) |
8 | Same trade as /ask |
| Period choices | cogs/recap.py:_period_to_window() |
1h / 1d / today | Add longer windows here (e.g. 3d, week) if mods want |
today timezone |
UTC | UTC | Could be made per-guild via /menu; punted for now |
category:)_compose():
include_bots=True.discourse_history table, with timestamps. Used as anti-repeat context.claude_client.discourse() with all sources concatenated:
must_post=True for manual invocations, even if recent topics cover the field, pick a different angle rather than skipping.web_search_20250305.must_post=True but defense in depth), fall back to a DISCOURSE_FALLBACK canned quip and emit discourse_fallback event.discourse_history for future dedup. Stored value is the first 200 chars of the post (likely contains the state info Claude was instructed to bake in).claude_api event, consume server slot, reply.| What | Where | Current | Effect |
|---|---|---|---|
| Per-server daily cap | utils/rate_limits.DEFAULT_PER_SERVER_DAILY |
20 | /menu setting overrides per guild |
| /order cooldown | utils/rate_limits.DEFAULT_ORDER_COOLDOWN_MINUTES |
15 min | /menu setting overrides per guild (0 disables) |
| Feed channels per call | cogs/discourse.py:_compose() feeds[:5] |
5 | More = richer source pool, more tokens |
| Feed read window | same file | 24 hours | Shorten for tighter “what’s fresh” feel |
| Feed messages per channel | same file | 10 | Wider sampling vs token cost |
| Current channel read | same file | 1 hour / 20 msgs | Adjust based on guild activity level |
| Dedup history window | db.recent_discourse() |
72 hours | Plan documents this; shorten if topics evolve fast in your community |
| Categories | cogs/discourse.py CATEGORIES |
pop, sports, cinema, hiphop, nba, custom | Add new categories here + feed channels in /menu → Feeds |
/discourse mood:<chill | yaps | off>)scheduler_tick in cogs/discourse.py is a tasks.loop(minutes=1). Every minute:
discourse_schedule.mood.off: skip.chill = [12:00 PT, 19:00 PT], yaps = [10:00, 14:00, 18:00, 22:00 PT].expected. How many have we actually posted in today’s bucket? That’s state.posts_today.posts_today >= expected: we’re caught up, skip.claude_client.mood_post(recent_with_timestamps=...): Haiku, no web_search, with explicit instructions to return literal “EMPTY” if all topics are stale repeats.record_schedule_post() increments posts_today. This is intentional: retrying every minute would burn API calls. Next attempt is the next scheduled slot.
_icebreaker_fallback generates a linkless opener question (claude.icebreaker) and runs it through the same engagement floor (discourse_score with surface="icebreaker", which scores openers on answerability instead of requiring a take). It’s reached on both quiet-room signals: the take path returning EMPTY (no fresh news to peg a take to) and the take scoring under the 0.6 floor (a weak take is the same “nothing worth a hot take right now” signal). In practice the under-floor case is the common one — the model usually produces a weak take rather than literal EMPTY — so without this the icebreaker almost never fired. If the opener clears the floor it’s posted like any other scheduled post (unless staging holds it — see below); otherwise the slot skips. Manual /discourse is unchanged (an under-floor manual take retries once, no icebreaker). This is what keeps a quiet, slow-news room from going fully silent.
ICEBREAKER_CATEGORIES (ranking / hot_take / this_or_that / confession) so openers vary instead of converging on hot-take bait.utils.memory_context.memory_tier_mix, shared with /ask, powers callbacks), the soft topical hook (the Perplexity pull already run for this compose, handed over for free), and a market line — reused from compose if it fetched one, else its own fetch only in sports rooms (looks_like_sports on the channel name + theme), since a betting-flavored line only lands where the room is already about sports. Material-only: there is no evergreen/persona floor, so a slot with none of the three sources present skips early (no generation call, emits reason=no_material). A dead-quiet room with nothing to draw on stays quiet rather than getting a from-nowhere opener.recent_discourse_all, guild-wide, timestamped) as a hard “don’t repeat the topic OR the template/shape” signal. This is the fix for the repeat-icebreaker bug: the dedup block previously fed room chatter (recent_messages, which excludes her bot posts), so she couldn’t see what she’d just posted and kept reusing shapes (“most overrated X, be honest” / “top 3 <team>”). The post-gen duplicate_reason check still guards near-exact text on top of this._icebreaker_fallback reads the channel’s live history and counts human messages since Toots’ own most recent post. If she posted recently and fewer than MIN_HUMAN_MSGS_BETWEEN_ICEBREAKERS (3) humans have spoken since, it holds (emits reason=room_unanswered, no generation). This is the single chokepoint for every icebreaker source — the scheduled-fallback path and the quiet-room path run on separate clocks (slot schedule vs. chime-in cooldown) and don’t otherwise see each other’s posts, so without this floor an opener from one path and an opener from the other could stack minutes apart with nobody talking between them (two unanswered cold opens, Toots talking to herself). Reading live history makes it shared and restart-proof (the in-memory lull state is wiped on redeploy; the channel history isn’t). manual (/discourse category:icebreaker, mention-routed) bypasses it — the mod explicitly asked for one.icebreaker experiment, set on /menu page 3, read via db.resolve_experiment_state; default production). Three stages, replacing the retired global ICEBREAKER_STAGING env flag:
#bot-logs and NOT posted to the discourse channel (🧪 … → STAGING); the event records staged=True, shipped=False.🧊 … → shipping, shipped=True).
Manual /discourse category:icebreaker (and the @toots icebreaker route) bypass the stage entirely — the mod/user explicitly asked to see one — so a floor-clearing opener ships now regardless.#bot-logs (the staged-for-review one, or the live one in production). Gated/no-material attempts are left to the structured discourse_icebreaker event (read the logs for what got cut), not #bot-logs.discourse_icebreaker with category, score, shipped, staged, and has_memory / has_topical / has_market.discourse_history under category "scheduled".| What | Where | Current |
|---|---|---|
| Scheduled times | cogs/discourse.py CHILL_TIMES / YAPS_TIMES |
chill: 12pm/7pm PT; yaps: 9am/12pm/3pm/6pm/10pm ET |
| Tick frequency | @tasks.loop(minutes=1) |
1 min |
| Cross-category history depth | recent_discourse_all(limit=20) |
20 most recent posts |
| Model | claude.mood_post() uses HAIKU |
Haiku 4.5 |
| Web search | NOT enabled (different from manual /discourse) | Off; flip to on if scheduled posts feel stale |
chill → yaps, or add more scheduled times to CHILL_TIMES.chill or off, or shrink YAPS_TIMES.discourse_history (currently 72h).In claude_client.preflight_order():
ALLOW: <one-line summary>, valid order, summary is what to buildPLUMBING: <which protected path>, would touch constitution/persona core/CI/Dockerfile/etc.REJECT: <reason>, moderation, NSFW, incoherent, off-scope("reject", "unparseable preflight response: ...").The cog then branches on the verdict for the user-facing message (different deflection quip for plumbing vs reject) and the bot-logs post (🔧 vs 🚫).
| What | Where | Current |
|---|---|---|
| Protected paths | system prompt inline in preflight_order() |
constitution.py, persona.py core, .github/, Dockerfile, railway.toml, Procfile, db.py connection, bot.py boot, requirements.txt deletions |
| Exceptions | same prompt | voice library additions, new tables/cogs/deps, new optional env vars |
| Model | Sonnet 4.6 | Don’t downgrade; preflight is the safety net. |
| Max tokens | 250 | Plenty for “ALLOW: …” verdict |
Toots leans into the conversation when she has something real to say. No commands of its own, it rides on two settings already in /menu:
discourse_channel. Whatever room is your “chatter” / “general” channel is the one Toots will listen in on.mood=off silences chime-in; chill makes her reserved (5/day, 40 min cooldown, 0.8 threshold); yaps makes her chatty (10/day, 20 min cooldown, 0.6 threshold).Chime-in (and /discourse) is meant to get the ROOM talking to each other, not to start a back-and-forth between one user and Toots. Both prompts (chimein_post, discourse, mood_post) explicitly tell the model: drop the take or the prompt, don’t ask questions aimed at you, don’t tee yourself up for a reply. Toots is the bartender setting up the room’s next argument and walking off, not a participant.
In cogs/chimein.py:
discourse_channel to an in-memory deque (max 50 messages).tasks.loop(seconds=60) tick refreshes each guild’s listen channel from settings, then walks every (guild, channel) with new buffered activity and runs the gate sequence in _maybe_chime_in_one():
off, skipchimein_score() on the buffer, returns (score, vibe, hook){vulnerable, catchup, other} (Toots doesn’t interrupt private moments)discourse_score ≥ 0.6) is what protects output quality, so a low bar lets more moments get composed without lowering what ships.chimein_post() with the buffer + hook + recent image URLs (vision + web search both available) to generate the one-line take.chimein_history for cooldown + daily cap tracking, emit chimein_posted event.Note: a per-room adaptive presence loop (issue #445) once drifted this threshold based on whether chime-ins drew engagement. It was removed — in production it had saturated every room to its floor (the engagement signal counted any room activity, so a live room never read as “ignored”), so it stopped adapting. The bar is now the fixed chatty per-mood constant above.
Reaction path (cheap, no post). When Toots can’t post (post on cooldown / at the daily cap) or the score is below the post threshold but still a near-miss (>= REACT_THRESHOLD, vibe not skipped), she drops a single emoji reaction instead of going silent, the “I clocked that” move. It’s decided after scoring (so it still fires in the post-cooldown/cap silent gaps), reuses the scorer’s reaction emoji (chosen by stance, 🔥 cosign vs 🧢 cap), piles onto the room’s top existing reaction if there is one, and is one-per-message. Reactions never post or consume the post cooldown/cap; they ride their own REACT_COOLDOWN plus a mood-tuned react_cap, both DB-backed in chimein_reactions. Because a reaction is free (no API call, no clutter, no ping), react_cap sits well above the post cap.
Quiet-room icebreaker (the inverse of a chime-in). A chime-in leans into an active thread (the gate sequence above only runs once the buffer has >= BUFFER_MIN_FOR_SCORE fresh messages). The opposite branch, _maybe_break_quiet(), runs off the same tick for channels that are below that bar. The silence window — no human message for 2x the mood’s chime-in cooldown (QUIET_SILENCE_MULTIPLIER, so chill 80 min / yaps 40 min: reviving a dead room is a higher bar than chiming into a live one) — plus the mood / hours / cooldown / daily-cap gates are only the cheap precondition. The actual decision is model-judged, and built to mirror the chime-in scorer as closely as possible: a quiet room past those gets one Haiku quiet_room_score call — the inverse of chimein_score, sharing the same _extract_scored_json parse spine and the same _recent_self_block context format — that weighs recent context (was she just mentioned / answering someone? did a thread just pause, or is the room genuinely dead?) and only fires when the score clears the same mood-tuned tuning.threshold chime-in uses (0.7 chill / 0.5 yaps; the cutoff is the model’s call, not a per-guild knob). The model defaults to “leave it alone,” so a cold open is rare and never lands right after she was engaged. Before that paid call, the deterministic human-activity floor (see the icebreaker section) runs as a cheap early pre-gate: a single human_msgs_since_self history read, and if she posted recently with too few human replies it holds right there (reason=room_unanswered) — no quiet_room_score Haiku call, no produce_icebreaker context/Perplexity fetch. When it passes, produce_icebreaker is called with skip_activity_floor=True so the shared chokepoint doesn’t read history a second time (the pre-gate already did). The verdict (break/hold, score, reason) is logged as quiet_room_decided, and _lull_broken is set whether it breaks or holds — so she asks at most once per lull (a human message lifts the flag), which bounds the cost and lowers the likelihood further. On a break it is not wall-clock scheduled, purely activity-driven (the quiet counterpart to chime-in), and defers to the discourse cog’s produce_icebreaker(manual=False, trigger="quiet_room") — so the opener is experiment-gated exactly like the scheduled-slot fallback (off skips, staging auditions to #bot-logs, production ships) and runs the same material + engagement floors. One opener per lull: an in-memory _lull_broken flag is set whether or not an opener actually ships, so she doesn’t nag a dead room every tick; it’s lifted the moment a human speaks again (in on_message, which also resets the _last_activity silence clock). A shipped opener records on the chime-in clock (record_chimein, vibe icebreaker, with score) so it counts against the shared cap, is recorded in the discourse pool tagged "icebreaker" (the same label the scheduled-fallback and manual opener paths now use), and is logged as discourse_icebreaker with trigger=quiet_room. Delivery is the shared voice path: the opener is generated with allow_voice and delivered through the same _deliver_chimein a chime-in uses, so it can ship spoken/sung when the model nominates it (a bare <voice>/<sing> tag, parsed off before dedup/record) and the per-guild voice experiment allows; otherwise it posts as text. Same as a chime-in, any voice miss degrades to text.
The icebreaker is a third room surface alongside the discourse take and the chime-in, and it should carry the same guardrails. This table is the single place that says what each surface has, so a gap doesn’t get rediscovered one item at a time. Keep it in sync when a surface gains or loses a guardrail.
| Capability | discourse take | chime-in | icebreaker |
|---|---|---|---|
Post-gen quality gate (discourse_score ≥ 0.6) |
✅ | ✅ surface=chimein |
✅ surface=icebreaker |
Text dedup (duplicate_reason) |
✅ | ✅ | ✅ |
Shared dedup window (DEDUP_RECENT_LIMIT = 20) |
✅ | ✅ | ✅ |
| Generator sees its own recent posts (anti-repeat) | ✅ | ✅ | ✅ recent_self_posts |
Link stripping (enforce_source_links → link_stripped) |
✅ | ✅ | ✅ (linkless: strips any URL) |
Voice / sung delivery (model-nominated, voice experiment) |
n/a (text) | ✅ | ✅ (quiet-room path) |
| Rate cap + cooldown | ✅ slot cap | ✅ daily cap + cooldown | ✅ (quiet shares the chime-in clock; scheduled rides the slot cap) |
| Behavioral eval + golden | eval_discourse_post |
eval_chimein_post |
eval_icebreaker (per-opener + diversity judge) |
| Live-log grading of real shipped posts | ✅ own judge | ✅ own judge | ✅ own surface (icebreaker), per-post + batch diversity |
| Ops-monitor quality spot-check | ✅ discourse_scored |
(via live eval) | ✅ shipped discourse_icebreaker |
By design (NOT gaps): the icebreaker has no image/vision, enriched_links, or recently_seen_urls (it’s a linkless opener); no must-post retry (it skips instead); and the scheduled-fallback opener does not record on the chime-in clock (it’s paced by the mood slot schedule — a deliberate “keep the clocks separate” call).
| What | Where | Current |
|---|---|---|
| Min buffer to score | BUFFER_MIN_FOR_SCORE in cogs/chimein.py |
5 messages |
| Buffer max | BUFFER_MAX |
50 messages per channel |
| Per-mood cadence | MOOD_TUNING defaults; live via tunables.chimein_tuning, editable in /menu |
chill: 0.8 / 5 / 40min · yaps: 0.6 / 10 / 20min |
| Reaction floor | REACT_THRESHOLD default; live via tunables.react_threshold, editable in /menu |
0.45 (below the post threshold, above silence) |
| Reaction cooldown | REACT_COOLDOWN default; live via tunables.react_cooldown, editable in /menu |
10 min (flat, DB-backed) |
| Reaction daily cap | MOOD_TUNING.react_cap; live via tunables.chimein_tuning, editable in /menu |
chill 15 / yaps 30 (well above the post cap; reactions are free) |
| Hours window | HOURS_START_ET, HOURS_END_ET_NEXT_DAY |
9am to 2am ET |
| Skip vibes | SKIP_VIBES |
vulnerable, catchup, other |
| Tick frequency | TICK_SECONDS |
60s (cheap, only scores buffers with new activity) |
| Scoring model | Haiku 4.5 | One-shot scoring, returns JSON-like line |
| Posting model | Sonnet 4.6 | Same model + tools as /discourse for tone parity |
_parse_chimein_score() is deliberately tolerant of model drift: strips markdown fences, extracts the first {...} block, clamps score to [0, 1], coerces unknown vibes to other, and falls back to (0.0, "other", "") on any parse failure. This guarantees a bad response skips the slot rather than risking a misfired post.
Two event kinds in utils/events.py:
chimein_evaluated: emitted once per skipped slot with decision field naming which gate stopped it (mood_off_gate, hours_gate, cooldown_gate, daily_cap_gate, vibe_gate, threshold_gate, empty_generation) plus mood where relevant. Lets you plot “where are we losing chime-in candidates?”chimein_posted: emitted on every actual post with score, vibe, hook, mood. Lets you see what Toots actually weighed in on and under which mood.mood from yaps to chill (or vice versa) in /menu. That’s the intended dial./menu (chime-in threshold / cap / cooldown / reaction cap, per mood) — no redeploy. The code-level defaults still live in MOOD_TUNING in cogs/chimein.py (sourced from utils/tunables.py); change those to move the default for every guild. Bumping the chill threshold up makes her even more reserved when chill; dropping the yaps cooldown makes her even chattier when yaps.recent_self_posts block in chimein_score() is the existing dedup; pass more history if needed.Every Claude call gets a prefix in the user message:
[ctx, current time: 2026-05-24 09:00 UTC, 2026-05-24 02:00 PDT, weekday: Sunday]
Built in claude_client._time_context(). Costs ~25 tokens, fixes day-of-week hallucinations. Spelled-out weekday so Claude doesn’t have to compute.
The full system prompt (~1 k tokens) is sent with cache_control: {"type": "ephemeral"}. Anthropic caches it for the next ~5 minutes; repeat calls within that window pay only for cache reads.
Two flavors in utils/rate_limits.py:
@Toots mentions, /recap), default 100, override via the /menu tune page (per_user_daily_limit)./discourse manual, /order), default 20, override via the /menu tune page (per_server_daily_limit)./order has one (default 15 min per user), override via the /menu tune page (order_cooldown_minutes, 0 disables).All three are per-guild overrides in the settings KV table, read live on every check (a tune-page change applies with no restart) and editable by mods through the /menu tune page (its last page). That page is the editor for every user-facing cap/cooldown/cadence knob, not just these three — see Tunable knobs below.
The full set of mod-editable knobs lives in utils/tunables.py:TUNABLES (the single source of truth) and is surfaced through /menu’s last page (reached via more ▸) — a paged flat list of rows (one row per knob; tapping a row opens a one-field modal to edit it, int or float). Paging is what lets the list grow past a modal’s 5-input cap. Each knob is a per-guild override in the settings KV table read live by a resolver, with the code-level constant as the fallback default:
| Group | Knobs | Default |
|---|---|---|
| Rate / order | per-user daily cap · per-server daily cap · /order cooldown · max orders in flight |
100 · 20 · 15 min · 3 |
| Chime-in (chill) | score threshold · daily cap · cooldown · reaction cap | 0.8 · 5 · 40 min · 15 |
| Chime-in (yaps) | score threshold · daily cap · cooldown · reaction cap | 0.6 · 10 · 20 min · 30 |
| Reactions | cooldown · score threshold | 10 min · 0.45 |
| Scheduled posts | discourse/day (chill·yaps) · music drops/day (chill·yaps) | 2·5 · 1·2 |
The scheduled-post caps trim the fixed CHILL_TIMES/YAPS_TIMES slot lists earliest-first (reduce-only; can’t invent new slots). Engineering internals (buffer sizes, tick intervals, retry backoffs, memory write gaps) are intentionally not exposed — they’re not mod-facing.
The tune editor is /menu’s last page (reached via more ▸); numbers can’t be a select, so it’s a modal-per-row paged list rather than the select rows the other pages use. There is no standalone /tune command.
Hitting a cap emits a rate_limit_hit event. The bot returns a Toots-voice deflection from voice.RATE_LIMIT_HIT rather than a sterile error.
Image URLs are attached as Anthropic image content blocks:
{"type": "image", "source": {"type": "url", "url": "..."}}
_call() hard-caps at 10 images per call regardless of caller. Per-image cost: ~85 fixed tokens + variable detail tokens (image-size dependent).
Every metric-worthy event flows through utils/events.emit() which writes a single EVENT {...json...} log line with the tootsies.events logger name. See CLAUDE.md → Structured events for the event catalog and how to query in Railway dashboards.
| Desire | Change |
|---|---|
| “Toots is too chatty” | /discourse mood:chill or /discourse mood:off |
| “Toots feels under-informed” | already always-on web search; consider raising image cap in _answer() |
| “@Toots answers are too short” | persona’s ~140 char cap in persona.py; not enforced by code, only by prompt, Claude tries |
| “Recaps miss context” | already on; if recap is dead, channel might be quiet or bot perms missing |
| “Too many low-quality /order PRs” | tighten preflight system prompt in claude_client.preflight_order |
| “/discourse repeats itself” | shorten 72h window in db.recent_discourse() or raise to widen |
| “Costs are too high” | drop image cap in _answer() / recap(); shorten channel context |
| “Want to add a new command” | /order add a /foo command that does Y and let the bot ship it |