# SPEC-001 — Responder + response envelope Characterization of the current (v2) responder contract in `fjerkroa_bot/ai_responder.py`. The model answers with a JSON envelope: `answer`, `answer_needed`, `channel`, `staff`, `picture`, `picture_edit`, `hack`. FDB-005 will replace the transport of this contract (structured outputs); the *behavioral* requirements below survive that change unless marked otherwise. ### ENV-01 — Model answer reaches the user (coverage: feature) When the model envelope carries a non-empty `answer` and `answer_needed` is true, `AIResponder.send()` returns an `AIResponse` with that answer text and `answer_needed == True`. This is the core loop: user talks, bot answers. ### ENV-02 — Suppressed answer stays silent (coverage: feature) When the model envelope sets `answer_needed` to false (and the message is not direct, mentions nobody, and no staff note is set), the returned `AIResponse` has `answer_needed == False`. The bot may observe without butting in. ### ENV-03 — Staff note forces delivery (coverage: feature) When the envelope carries a non-null `staff` text and a non-null `answer`, the returned response preserves the staff text and has `answer_needed == True`. Staff alerts must never be silently dropped. ### ENV-04 — Direct messages are always answered (coverage: feature) When the incoming `AIMessage` is marked `direct` and the model returns a non-null answer, `answer_needed` is forced to `True` regardless of the model's own `answer_needed`. A user addressing the bot directly gets a reply. ### ENV-05 — Short-path rules skip the model (coverage: feature) When a configured `short-path` `[channel-regex, user-regex]` pair matches the message, `send()` appends the message to history, trims history to the limit, persists it, and returns an empty response (answer `None`, `answer_needed False`) **without calling the model**. Cheap archival of noisy channels. ### ENV-06 — Malformed model output is repaired (coverage: withdrawn — successor ENV-18) Withdrawn 2026-07-13 with FDB-005: strict structured outputs make the repair model obsolete. Malformed output now counts as a failed attempt — see ENV-18. ### ENV-07 — History is trimmed to the limit (coverage: feature) After a completed exchange, `len(history) <= history-limit` holds. Trimming happens both before the model call and after appending the new question/answer pair. ### ENV-08 — Markdown links are unwrapped (coverage: feature) In the final answer text, `[label](url)` becomes `url` and `@[label](url)` becomes `label`. Discord renders raw URLs; markdown link syntax from the model reads as noise. ### ENV-09 — Missing channel falls back to the message channel (coverage: feature) When the envelope `channel` is null/none/empty, the response channel is the channel the message came from. ### ENV-10 — System prompt template substitution (coverage: test) `message()` substitutes `{date}` (YYYY-MM-DD), `{time}`, `{memory}` (current memory string) in the system prompt; `{news}` is replaced with the news file content when the configured file exists and stays literal when it does not. ### ENV-11 — Per-channel history shrink prefers busy channels (coverage: test) `shrink_history_by_one()` removes the oldest entry whose channel has more than `history-per-channel` (default 3) entries; when no channel exceeds the cap, the oldest entry overall is removed. ### ENV-12 — Exhausted retries raise, attempts are spaced (coverage: test) When the model returns no usable answer three times in a row, `send()` raises `RuntimeError`. Consecutive attempts are separated by an exponential-backoff sleep — failures never hammer the API back-to-back (D1). ### ENV-13 — Reaction-clear events are recorded (coverage: test) `on_reaction_clear(message, reactions)` — the discord.py signature — records the clearing in the channel's memory. (D7: the previous handler declared `(reaction, user)` and crashed on dispatch.) ### ENV-14 — update_memory persists its argument (coverage: test) `update_memory(memory)` sets the responder's memory to the passed value and persists that value. (D12: the argument was ignored.) ### ENV-15 — retry-model is used after a rate limit (coverage: test) After a rate-limited attempt, the next `chat()` attempt uses the configured `retry-model` instead of `model`; a successful attempt switches back. (D2: the fallback was assigned to a local and never took effect.) ### ENV-16 — Model calls are never served from a disk cache (coverage: test) `openai_chat`/`openai_image` call the client every time. The pickle response cache (`openai_chat.dat`) is a test-era artifact and must not exist in the production path (D3/D4). ### ENV-17 — IGDB tool execution runs in a worker thread (coverage: test) `_execute_igdb_function` executes the synchronous IGDB library off the event loop (worker thread), returning identical results. The event loop keeps serving Discord events during lookups (D5). ### ENV-18 — Malformed or refused output is a failed attempt (coverage: test) Model output is requested as schema-validated JSON (strict structured outputs). Output that still fails to parse, or a model refusal, counts as a failed attempt (backoff + retry per ENV-12) — there is no repair model, no `fix()` path, no relaxed-JSON fallback. Replaces ENV-06. ### ENV-19 — The envelope schema is pinned (coverage: test) Every chat call carries `response_format` = strict JSON schema named `envelope` with exactly the fields `answer`, `answer_needed`, `channel`, `staff`, `picture`, `picture_edit`, `hack` — all required, `additionalProperties: false`, nullable where the protocol allows null. Tool-followup calls carry the same format.