Przeglądaj źródła

Improved triage

Efren Yevale Varela 1 miesiąc temu
rodzic
commit
5399275524

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ transcripts/
 audio/
 audit/
 triage/
+brief/
 models/
 speakers/
 *.onnx

+ 10 - 3
AGENTS.md

@@ -64,12 +64,19 @@ publish-only.
   `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface).
 - `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its
   `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like
-  `config.toml`. `dump_io = true` writes one JSON per triage under `triage/` (also gitignored).
+  `config.toml`. `dump_io = true` writes one JSON per triage under `triage/` (also gitignored);
+  a coalesced brief update writes two files. The brief engine itself is driven by
+  `[llm].triage_system_prompt` (plain text, no JSON) and gated by the fail-open
+  `substantive_update` signal.
 - `[speaker]` is opt-in per-utterance speaker identification, off by default, backed by the
   `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`,
   `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`,
   `confirm_s`, `max_prototypes`, `provisional_ttl_h`, `store_dir`, `join_timeout_s`). Needs
   `uv sync --extra speaker` plus a model under `models/`. Full reference: `docs/SPEAKERS.md`.
+- `[brief]` is opt-in durable-brief persistence, backed by the `BriefConfig` dataclass (13th
+  known section; 3 keys: `write` (default `false`, gates disk persistence only), `dir`,
+  `filename`). The default writes `brief/brief.md` (cwd-relative, gitignored); with `write =
+  false` the brief stays in the TUI only. Full reference: `docs/TRIAGE.md`.
 - Never treat the live `config.toml` as a source of defaults; it is gitignored user state.
 
 ## Transcription backends
@@ -83,7 +90,7 @@ publish-only.
 
 ## Testing
 
-- `tests/` has 733 tests, all hardware- and network-free. Preserve that: inject fakes instead of
+- `tests/` has 838 tests, all hardware- and network-free. Preserve that: inject fakes instead of
   touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake
   embedder; no ONNX in tests).
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
@@ -102,7 +109,7 @@ publish-only.
 ## Conventions & gotchas
 
 - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`,
-  `triage/`, `speakers/` (biometric), `models/`, `.omo/`.
+  `triage/`, `brief/`, `speakers/` (biometric), `models/`, `.omo/`.
 - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   non-read-only.

+ 1 - 1
README.md

@@ -143,7 +143,7 @@ See [docs/CLI.md](docs/CLI.md) for every subcommand and [docs/CONFIGURATION.md](
 - [docs/AUDIO-SETUP.md](docs/AUDIO-SETUP.md) — virtual sink creation and audio routing options.
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
-- [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, LLM extraction, and questions file.
+- [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, the living brief, and questions file.
 - [docs/SPEAKERS.md](docs/SPEAKERS.md) — speaker identification: enrollment, thresholds, store, and commands.
 - [docs/OUTPUT.md](docs/OUTPUT.md) — stdout, JSONL, and WAV output formats and fields.
 - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — common errors and how to fix them.

+ 49 - 36
config.example.toml

@@ -69,44 +69,52 @@ enabled = true
 dir     = "audit"
 
 [llm]
-enabled                  = true
-endpoint                 = "http://localhost:9931/v1/chat/completions"
-temperature              = 0.7
-max_tokens               = 1024
-timeout_s                = 120.0
-cache_prompt             = true
-max_context_tokens       = 131072
-compaction_safety_margin = 256
-compaction_enabled       = true
-compaction_trigger_ratio = 0.8
-compaction_keep_turns    = 6
-compaction_max_tokens    = 512
-compaction_timeout_s     = 30.0
-compaction_input_tokens  = 8192
-compaction_system_prompt = "You compress a conversation. Preserve names, numbers, decisions, user preferences, and unresolved questions. Omit pleasantries. Output only the summary."
-inference_queue_size     = 8
-trim_block_turns         = 4
-max_tool_iterations      = 3
-system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
-system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
-triage_system_prompt     = "You extract structured signals from a spoken conversation. Output only a JSON object with exactly these keys: {\"summary\": str, \"opportunities\": [str], \"doubts\": [str], \"action_items\": [{\"what\": str, \"why\": str}], \"risks\": [str]}. Output no prose and no markdown fences. Use empty lists when a category is empty."
+enabled                        = true
+endpoint                       = "http://localhost:9931/v1/chat/completions"
+temperature                    = 0.7
+max_tokens                     = 1024
+timeout_s                      = 120.0
+cache_prompt                   = true
+chat_slot_id                   = ""
+command_slot_id                = ""
+max_context_tokens             = 131072
+compaction_safety_margin       = 256
+compaction_enabled             = true
+compaction_trigger_ratio       = 0.8
+compaction_keep_turns          = 6
+compaction_max_tokens          = 512
+compaction_timeout_s           = 30.0
+compaction_input_tokens        = 8192
+compaction_system_prompt       = "You compress a conversation. Preserve names, numbers, decisions, user preferences, and unresolved questions. Omit pleasantries. Output only the summary."
+brief_compaction_system_prompt = "You compress a triage briefing conversation. Preserve every opportunity, blocker, owner, responsibility, decision, deadline, and open question; drop pleasantries. Output only the summary."
+inference_queue_size           = 8
+trim_block_turns               = 4
+max_tool_iterations            = 3
+system_prompt_chat             = "You are a concise voice assistant. Reply in 1-3 sentences."
+system_prompt_command          = "You translate a spoken request into a single tool call when one applies."
+triage_system_prompt           = "You maintain a living brief of a business conversation. Given the current brief (your previous reply, if any) and a new transcript segment, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions."
 
 [triage]
-enabled              = false
-questions_file       = "questions.json"
-window               = 500
-state_char_budget    = 3000
-predict_max_tokens   = 1024
-confidence_field     = "answer_confidence"
-escalate_threshold   = 0.6
-choice_min_confidence = 0.85
-extract_enabled      = true
-extract_max_tokens   = 512
-extract_timeout_s    = 30.0
-device               = ""
-preload              = ["multilingual"]
-default_checkpoint   = "multilingual"
-dump_io              = false
+enabled                   = false
+questions_file            = "questions.json"
+window                    = 500
+state_char_budget         = 3000
+predict_max_tokens        = 1024
+confidence_field          = "answer_confidence"
+escalate_threshold        = 0.6
+choice_min_confidence     = 0.85
+extract_enabled           = true
+extract_max_tokens        = 1536
+extract_timeout_s         = 30.0
+brief_slot_id             = ""
+brief_gate_enabled        = true
+brief_gate_threshold      = 0.5
+brief_max_chars           = 4000
+brief_pending_char_budget = 3000
+device                    = ""
+preload                   = ["multilingual"]
+default_checkpoint        = "multilingual"
+dump_io                   = false
 
 [speaker]
 enabled           = false
@@ -123,3 +131,8 @@ max_prototypes    = 8
 provisional_ttl_h = 24.0
 store_dir         = "speakers"
 join_timeout_s    = 2.0
+
+[brief]
+write    = false
+dir      = "brief"
+filename = "brief.md"

+ 37 - 15
docs/ASSISTANT.md

@@ -138,18 +138,29 @@ the note is UI-only and never sent to the model.
 A reply cut off by the output limit (`finish_reason: "length"`) surfaces as
 `error{code:"truncated"}` instead of disappearing silently; an empty truncated turn is not stored.
 
+Each engine keeps its own history and compacts independently: the chat engine and the brief
+engine each inject their own rolling summary as a second `system` message. The brief engine
+uses `[llm].brief_compaction_system_prompt` as its summarizer. A compaction shifts that
+engine's prompt prefix, so the server's prefix cache is invalidated once for it, and the
+summarizer call can hold the shared LLM lane for up to `compaction_timeout_s`.
+
 ## Inference queue
 
-Every LLM call — chat turns, command executions, and the compaction summarizer — runs through a
-single worker thread, bounded by `inference_queue_size`. This serializes requests to the one
-llama.cpp server. Commands jump ahead of chat turns; when the queue is full an execute is refused
-with `error{code:"busy"}` and the oldest queued chat turn is dropped. Leaving chat mode discards
-queued chat turns.
+Every LLM call — chat turns, command executions, the compaction summarizer, and triage brief
+updates — runs through a single worker thread, bounded by `inference_queue_size`. This
+serializes requests to the one llama.cpp server. Commands jump ahead of chat turns; when the
+queue is full an execute is refused with `error{code:"busy"}` and the oldest queued chat turn
+is dropped. Leaving chat mode discards queued chat turns.
+
+Brief updates run on this same lane as non-evictable, single-outstanding jobs: a full queue
+refuses the brief rather than evicting a queued chat turn, and the refused turn's deltas stay
+buffered for the next attempt (see [TRIAGE.md](TRIAGE.md)).
 
 ## Triage
 
-An optional display-only panel that classifies recent speech. Off by default; enable `[triage]`
-and install the extra to use it (see [TRIAGE.md](TRIAGE.md)).
+An optional display-only panel that classifies recent speech and keeps a living plain-text
+brief. Off by default; enable `[triage]` and install the extra to use it (see
+[TRIAGE.md](TRIAGE.md)).
 
 The classifier runs **in-process** in the assistant on its own background thread (one worker plus a
 loader), never on the tick thread and never on the `InferenceQueue`. The Laya **multilingual**
@@ -160,15 +171,26 @@ three may be parallelized.
 On each settled turn in any mode (idle, chat, or command; `turn_ended()`), independent of chat,
 the lane classifies the last `window` transcribed utterances from the STT stream (not the chat
 history). Results flow through a `signals -> content` two-stage exchange: the signals stage always
-arrives first (per-category confidences), and the assistant then submits one `triage-extract` job to
-the existing LLM lane for a structured JSON extraction, which arrives as the content stage and is
-merged over the signals. Extraction runs after every Laya pass when `extract_enabled` is true and an
-LLM client is wired; on a Laya error it still runs with transcript state only. The deployment
-thresholds (`escalate_threshold`, `choice_min_confidence`) only drive the per-item `⚠` marker.
+arrives first (per-category confidences), and, when `extract_enabled` is true and the fail-open
+gate on `substantive_update` passes, the assistant queues one `brief-update` job on the existing LLM
+lane. That job runs a dedicated brief conversation (`[llm].triage_system_prompt`) that folds the
+pending speaker-labeled deltas plus the current signal words under a `Focus:` header into a
+plain-text brief, which arrives as the content stage and is rendered below the signals; on a Laya
+error it still runs with the deltas only. The job is token-guarded and single-outstanding: while one
+is in flight further turns are refused and their deltas stay buffered (bounded by
+`brief_pending_char_budget`), and a refusal, empty reply, or exception re-queues those deltas and
+clears the flag so the next turn retries. A successful update coalesces a flush when deltas remain.
+The brief engine's own history compacts independently with
+`[llm].brief_compaction_system_prompt`; a compaction resets its prompt prefix and can hold the shared
+LLM lane up to `compaction_timeout_s`. The deployment thresholds (`escalate_threshold`,
+`choice_min_confidence`) only drive the per-item `⚠` marker, not the gate and not the brief.
 The panel renders the last-triage clock (transcript time format/color) and both stages; it is
 display-only and triggers no modes, commands, or tools. Leaving chat mode does not clear the panel or
-its buffer; an explicit `clear` does. With `dump_io = true` each triage also writes one JSON file
-under `triage/` (see [TRIAGE.md](TRIAGE.md)).
+its buffer; an explicit `clear` does (it also truncates the durable brief file). With
+`[brief].write = true` the latest brief is snapshotted to `dir/filename` (default `brief/brief.md`,
+gitignored); with the default `write = false` it stays in the TUI only. With `dump_io = true` each
+triage also writes its I/O under `triage/` (a coalesced update writes two files; see
+[TRIAGE.md](TRIAGE.md)).
 
 ## Speakers
 
@@ -189,7 +211,7 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 `s` swaps the triage pane between the live signals view and a structured view of the loaded `questions.json` (one block per question with its id, type, instructions, and criteria); it is a no-op when `[triage].enabled = false`. See [TRIAGE.md](TRIAGE.md).
 
-**Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation / command output [inf N]` shows the number of LLM inference jobs queued, with `· busy` appearing while one is running. These counters refresh about once per second, driven by the heartbeat cadence.
+**Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation / command output [inf N]` shows the number of LLM inference jobs queued, with `· busy` appearing while one is running. The triage pane carries the same `[inf N]` depth and `· busy` marker, since brief updates share the LLM queue. These counters refresh about once per second, driven by the heartbeat cadence.
 
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse
 reporting by default), so dragging does **not** select text the way it normally does in

+ 43 - 9
docs/CONFIGURATION.md

@@ -99,6 +99,8 @@ temperature              = 0.7
 max_tokens               = 1024             # max output per request; reasoning models spend tokens before the answer, so keep generous
 timeout_s                = 120.0
 cache_prompt             = true             # reuse the KV prefix (append-only history keeps it warm)
+chat_slot_id             = ""               # optional llama.cpp id_slot for chat; empty/unset omits it (server needs -np 2)
+command_slot_id          = ""               # optional llama.cpp id_slot for command tool-calls; empty/unset omits it (server needs -np 2)
 max_context_tokens       = 131072           # the server's --ctx-size (llama.cpp `GET /props` -> n_ctx); the prompt budget derives from it
 compaction_safety_margin = 256              # tokens reserved for the token estimate error + template/tool overhead
 compaction_enabled       = true             # summarize old turns instead of deleting them
@@ -108,12 +110,13 @@ compaction_max_tokens    = 512              # summary length cap
 compaction_timeout_s     = 30.0             # summarizer timeout, so a compaction cannot hold the inference lane
 compaction_input_tokens  = 8192             # summarizer input cap before chunk-and-fold
 compaction_system_prompt = "You compress a conversation. Preserve names, numbers, decisions, user preferences, and unresolved questions. Omit pleasantries. Output only the summary."
+brief_compaction_system_prompt = "You compress a triage briefing conversation. Preserve every opportunity, blocker, owner, responsibility, decision, deadline, and open question; drop pleasantries. Output only the summary."
 inference_queue_size     = 8                # bounded single-lane inference queue
 trim_block_turns         = 4                # fallback block trimming, not per-turn, to limit re-prefill
 max_tool_iterations      = 3
 system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
-triage_system_prompt     = "You extract structured signals from a spoken conversation. Output only a JSON object with exactly these keys: {\"summary\": str, \"opportunities\": [str], \"doubts\": [str], \"action_items\": [{\"what\": str, \"why\": str}], \"risks\": [str]}. Output no prose and no markdown fences. Use empty lists when a category is empty."
+triage_system_prompt     = "You maintain a living brief of a business conversation. Given the current brief (your previous reply, if any) and a new transcript segment, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions."
 
 [triage]                                    # Display-only signal panel (Laya lane; no socket, no extra process)
 enabled              = false                # Master gate: false = no lane, no panel, behavior identical to today
@@ -124,9 +127,14 @@ predict_max_tokens   = 1024                 # Max tokens sent to Laya; the multi
 confidence_field     = "answer_confidence"  # Gating confidence: "answer_confidence" (calibrated max(p)) | "confidence"
 escalate_threshold   = 0.6                  # noul answers at or above this p are flagged (⚠ marker only)
 choice_min_confidence = 0.85                # choice answers below this confidence are flagged (⚠ marker only)
-extract_enabled      = true                 # Master switch for the LLM JSON extraction after every Laya pass
-extract_max_tokens   = 512                  # Extraction reply token cap
-extract_timeout_s    = 30.0                 # Extraction request timeout
+extract_enabled      = true                 # Master switch for the brief engine (plain-text brief update after each Laya pass)
+extract_max_tokens   = 1536                 # Brief reply token cap; the brief is then capped to brief_max_chars characters
+extract_timeout_s    = 30.0                 # Brief request timeout
+brief_slot_id        = ""                   # optional llama.cpp id_slot for the brief engine; empty/unset omits it (server needs -np 2)
+brief_gate_enabled   = true                 # fail-open gate: update the brief only when the top signal's p >= brief_gate_threshold
+brief_gate_threshold = 0.5                  # gate threshold; the top signal at/above this p triggers a brief update
+brief_max_chars      = 4000                 # display cap for the brief (truncated, never dropped) when over this
+brief_pending_char_budget = 3000            # pending transcript chars that force an immediate flush of a coalesced update
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
@@ -147,6 +155,11 @@ max_prototypes    = 8                        # Cap on stored prototypes per spea
 provisional_ttl_h = 24.0                     # Prune provisionals that never reach confirm_s within this many hours (startup)
 store_dir         = "speakers"               # Store root (cwd-relative); gitignored (biometric artifacts)
 join_timeout_s    = 2.0                      # Embedding-lane join timeout before emitting the transcript
+
+[brief]                                      # Durable brief artifact (plain-text markdown snapshot; off by default)
+write    = false                            # false = brief shown in the TUI only; true = also persist to disk
+dir      = "brief"                          # Destination directory (cwd-relative); gitignored
+filename = "brief.md"                       # Snapshot filename inside dir
 ```
 
 `[speaker]` adds opt-in speaker identification: a dedicated in-process sherpa-onnx embedding lane
@@ -190,8 +203,10 @@ can never disagree. A legacy `max_prompt_tokens` key is ignored with a warning.
 `[triage]` adds a display-only signal panel. On each settled turn in any mode, independent of
 chat, the assistant classifies the last `window` transcribed utterances (from the STT stream,
 not the chat history) with a local Laya **multilingual** checkpoint on a dedicated
-in-process lane, then always asks the `[llm]` lane for a structured JSON
-extraction (`extract_enabled`; on a Laya error the prompt carries transcript state only).
+in-process lane, then, when `extract_enabled` is true, asks the `[llm]` lane on a dedicated
+brief conversation to fold the turn into a living plain-text brief (on a Laya error the prompt
+carries transcript state only). `extract_enabled` stays the brief engine's master switch: with
+it false the panel still classifies but no brief update is requested.
 `device = ""` means auto: it maps to `None`, so Laya picks the
 default device for the multilingual checkpoint. `preload = ["multilingual"]` is the fixed default
 and `default_checkpoint = "multilingual"`; the multilingual checkpoint is pinned for every call
@@ -205,9 +220,28 @@ exactly as before. `dump_io = true` additionally writes one JSON file per triage
 the audio files) containing the Laya request/response and the LLM request/response; it is
 off by default, best-effort (a dump failure never disturbs triage), and gitignored.
 
-`[llm].triage_system_prompt` is the strict JSON-only instruction used for the extraction cascade;
-it must return `{summary, opportunities[], doubts[], action_items[{what, why}], risks[]}` and
-nothing else.
+`[llm].triage_system_prompt` drives the brief engine: given the current brief (its previous
+reply) and a new transcript segment, it must return the updated brief as plain text only, with
+no JSON and no markdown fences. The model reply is bounded by `[triage].extract_max_tokens`
+(default 1536) and `extract_timeout_s`, and the stored/displayed brief is then capped to
+`brief_max_chars` characters (truncated, never dropped). `[llm].brief_compaction_system_prompt`
+is the summarizer used when the brief conversation's own history is compacted.
+
+The gate `brief_gate_enabled` (default true) is fail-open: a turn updates the brief only when
+the top signal's calibrated `p` is at least `brief_gate_threshold` (default 0.5), and any
+missing or non-numeric signal passes. `brief_pending_char_budget` bounds how many pending
+transcript characters accumulate before a coalesced update is flushed, so a burst of turns does
+not stack one in-flight brief job after another.
+
+`chat_slot_id`, `command_slot_id`, and `brief_slot_id` pin each engine's request to a specific
+llama.cpp `id_slot`. When unset (the default) the `id_slot` field is omitted from the request
+body; setting any of them requires the server to expose slot support (`llama-server -np 2` or
+more), and the server rejects an out-of-range slot.
+
+`[brief]` controls persistence of that living brief. With `write = false` (the default) it stays
+display-only: the TUI renders it and nothing is written to disk. With `write = true` the
+assistant snapshots the current brief as plain text to `dir/filename` (default
+`brief/brief.md`, cwd-relative, gitignored); clearing the brief truncates the file.
 
 Upgrading from a v5-draft config: `socket`, `model`, `predict_timeout_s`, `checkpoint`,
 `subfolder`, and `max_len` are **not** `[triage]` keys and are silently ignored by `load_config`

+ 5 - 3
docs/DEVELOPMENT.md

@@ -20,6 +20,7 @@ Tests cover:
 - **Provisioning**: `setup`/`ensure`/`teardown`, orphan-name cleanup, and `clear` idempotency
 - **Event bus**: envelope codec, ring replay, subscriber fan-out
 - **Assistant**: mode machine, command readiness/timeouts, tool schema validation, LLM client/history trimming, audit log
+- **Triage & brief**: Laya lane, questions-file validation, signal normalization, the fail-open `substantive_update` gate, single-outstanding `brief-update` coalescing, and the durable brief file
 - **Speakers**: prototype matching, mint/confirm/adapt guards, daemon wiring, assistant roster and naming, offline pre-enrollment (`tests/test_speaker_enroll.py`)
 - **TUI**: pure view-state reducer and control-socket client
 
@@ -55,10 +56,11 @@ src/stt_daemon/
 │   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
 │   ├── tools.py         - Tool registry + schema validation + get_datetime
 │   ├── llm.py           - llama.cpp client + chat session (block trimming)
-│   ├── inference.py     - Single-lane LLM queue (chat/command/triage-extract)
-│   ├── triage.py        - Triage analyzer (normalize, flag, extraction parse)
+│   ├── inference.py     - Single-lane LLM queue (chat/command/brief-update)
+│   ├── triage.py        - Triage analyzer (normalize, flag, brief prompt/parse/gate)
 │   ├── laya_lane.py     - In-process Laya lane (loader + single-flight worker)
 │   ├── triage_dump.py   - Opt-in per-triage I/O dump (`dump_io`)
+│   ├── brief_file.py    - Opt-in durable brief snapshot writer (`[brief]`)
 │   ├── audit.py         - Daily action audit log
 │   ├── runner.py        - `stt-assistant` process
 │   └── __main__.py      - Entry point for `python -m stt_daemon.assistant`
@@ -67,7 +69,7 @@ src/stt_daemon/
     ├── client.py        - Control-socket client
     └── app.py           - Textual app + `stt-tui` entry point
 
-tests/                    - 733 tests, no audio hardware or live LLM required
+tests/                    - 838 tests, no audio hardware or live LLM required
 
 scripts/
 ├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`

+ 143 - 63
docs/TRIAGE.md

@@ -2,10 +2,10 @@
 
 The triage panel is a display-only signal readout in the assistant TUI. On each settled
 turn (no chat mode required) the assistant classifies the most recent transcribed
-utterances with a local Laya **multilingual** model running in-process, and then always
-asks the existing `[llm]` lane for one structured JSON extraction. Both results land in a
-right-hand pane. Nothing here triggers a mode, a command, or a tool: the panel only shows
-what the model inferred.
+utterances with a local Laya **multilingual** model running in-process, then folds the turn
+into a living plain-text brief on the existing `[llm]` lane through a dedicated brief
+conversation. Signals and brief land in a right-hand pane. Nothing here triggers a mode, a
+command, or a tool: the panel only shows what the model inferred.
 
 The feature is off by default (`[triage].enabled = false`), and the extra it needs is
 opt-in, so a plain `uv sync` and the existing test suite behave exactly as before.
@@ -16,13 +16,13 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
   `false` there is no widget at all.
 - **`signals`** after a settled turn: one line per question, rendered from the normalized
   answer.
-  - `choice`: the selected label plus the gating confidence, for example `category: sales (0.91)`.
-  - `noul`: a ten-cell bar filled from `p`, the probability, for example `███████   churn_risk: 0.72`.
+  - `choice`: the selected label plus the gating confidence, for example `decision_status: made (0.91)`.
+  - `noul`: a ten-cell bar filled from `p`, the probability, for example `███████   blocker: 0.72`.
   - `score`: a ten-cell bar filled from the clamped integer level plus the level label.
   - A `⚠` mark follows any item the analyzer flagged.
-- **`content`** after the LLM extraction: the parsed JSON is merged over the signals as a
-  `content` key, and the panel appends the `summary` plus up to three entries each from
-  `opportunities`, `risks`, and `action_items`.
+- **`content`** after a brief update: the plain-text brief arrives as a `content` stage and
+  the panel appends it below the signals. The brief is written by the model, so the panel
+  shows it as-is (truncated only when it exceeds `brief_max_chars`).
 - **A reason line** while the lane is not `ok`, for example when the model is still loading
   or the `questions.json` file is missing. The panel never renders blank.
 - **A last-triage clock** as the first line whenever a real triage is shown: the server-side
@@ -40,18 +40,18 @@ map, ordered score levels, or the `true`/`false` descriptions of a `noul` questi
 questions come from the assistant's validated payload (the same dict Laya uses), not from
 disk, and it is a no-op when `[triage].enabled = false`.
 
-## How the cascade works
+## How the brief works
 
 ```
 settled turn (any mode)
     -> newest transcribed-utterance window (window entries, condensed under state_char_budget)
     -> Laya multilingual classification (in-process lane)
-    -> signals (emitted; flagged per should_escalate) ----------+
-    -> extract_enabled and an extract function present?         |
-         -> one triage-extract job on the LLM InferenceQueue    |
-              (all normalized signals in the prompt; on a Laya  |
-               error the prompt carries transcript state only)  |
-              -> parsed JSON merged as content ------------------+
+    -> signals (emitted; flagged per should_escalate)
+    -> extract_enabled and the fail-open gate pass?
+         -> one brief-update job on the LLM InferenceQueue
+              (pending speaker-labeled deltas + Focus signal words;
+               a Laya error still runs with the deltas only)
+              -> plain-text brief merged as content
 ```
 
 1. **Trigger.** After the chat dispatch block in the assistant tick, triage runs on any
@@ -74,22 +74,49 @@ settled turn (any mode)
    - `score`: `expected >= (levels - 1) * 0.5`, where `levels` is the number of score
      criteria
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
-   it no longer gates extraction.
-5. **Extraction.** After every Laya pass, when extraction is enabled and an extract
-   function is configured, the service submits **one** `triage-extract` job to the existing
-   LLM `InferenceQueue`. The extraction prompt carries the condensed transcript state plus
-   **all** normalized signals in order (rendered as words under a `Signals:` header); when
-   there are no signal lines the signals block is omitted and the prompt is state plus the
-   JSON-only instruction. On a Laya error (no payload) the LLM still runs, with transcript
-   state only and no `signals` event emitted. If the queue is full the job is refused with
-   `submit_if_room`: no queued chat job is ever evicted, and the signals simply stand. The
-   worker re-checks the generation before and after the blocking LLM call, parses the reply
-   (strict JSON, fences stripped, lists capped), and merges it as `content` over the
-   signals. A parse failure keeps the signals and emits no `content`.
-6. **Reset.** `clear` bumps the triage generation, clears the stored signals and the
-   ambient buffer, drops any pending lane request and any queued `triage-extract` job, and
-   emits the `cleared` stage. An in-flight result that lands after the bump is suppressed.
-   Leaving chat mode does not reset the panel or its buffer.
+   it drives neither the gate nor the brief.
+5. **Brief update.** `extract_enabled` is the brief engine's master switch: with it false
+   the panel still classifies and shows signals, but no brief is requested. When it is true
+   and a brief function is wired, the fail-open gate decides whether the turn updates the
+   brief. The gate reads the `substantive_update` `noul` arm of the signals: the update runs
+   when `p >= brief_gate_threshold` (default `0.5`), and any missing or non-numeric signal
+   passes. The service queues **one** `brief-update` job on the existing LLM lane. The job
+   carries the speaker-labeled pending deltas (the transcripts that arrived since the last
+   accepted update) plus the current signal words under a `Focus:` header; a Laya error
+   still runs with the deltas only. `[llm].triage_system_prompt` drives a dedicated brief
+   conversation that keeps its own history; the reply is a plain-text brief, not JSON
+   (fences stripped, then truncated to `brief_max_chars`, never dropped). The accepted brief
+   is emitted as the `content` stage, whose payload key is `brief` (the event carries the
+   merged signals plus `brief`), and, when `[brief].write = true`, snapshotted to disk.
+6. **Compaction.** The brief conversation is its own `ConversationEngine`, so it compacts
+   its history independently, using `[llm].brief_compaction_system_prompt` and the `[llm]`
+   compaction settings. When it fires, the summary is injected as a second `system` message,
+   which **resets that engine's prompt prefix**: the server's prefix cache is invalidated
+   once for it, and the summarizer call can hold the shared LLM lane for up to
+   `compaction_timeout_s`.
+7. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient
+   buffer, drops any pending lane request and any queued `brief-update` job, truncates the
+   optional durable brief file, and emits the `cleared` stage. An in-flight result that
+   lands after the bump is suppressed, so a stale brief writes neither the pane nor the
+   file. Leaving chat mode does not reset the panel or its buffer.
+
+## Single-outstanding job and coalescing
+
+Only one `brief-update` job may exist at a time. The service tracks a token and an
+outstanding flag under its lock:
+
+- If a job is already outstanding, the new turn is refused: its signals still show in the
+  panel, but no second job is stacked and the turn's deltas stay in the pending buffer.
+- The job is submitted with `submit_if_room`, so a full queue never evicts a queued chat
+  turn just to run a brief.
+- On refusal, an empty `""` reply, or an exception, the deltas are re-queued at the front
+  of the buffer and the outstanding flag is cleared, so the next settled turn can retry.
+- On success, if deltas accumulated while the job was in flight, a coalesced flush runs
+  immediately and dispatches the next job with the latest signals.
+
+The buffer is bounded by `brief_pending_char_budget` (default 3000 characters): once full,
+the oldest deltas are dropped, so a long quiet stretch can't grow it without limit. A
+`clear` mid-flight makes the in-flight result stale; it is discarded and writes nothing.
 
 ## Lanes: nothing is parallelized
 
@@ -99,7 +126,7 @@ them:
 | lane | what runs on it |
 |---|---|
 | transcription | one worker; faster-whisper is not thread-safe |
-| LLM `InferenceQueue` | one worker; all chat, command, and `triage-extract` calls |
+| LLM `InferenceQueue` | one worker; all chat, command, and `brief-update` calls |
 | Laya | one worker plus one loader thread; one `predict` at a time |
 
 The triage feature adds no process, no UNIX socket, and no supervisor child. The Laya part
@@ -108,6 +135,11 @@ throwaway warmup predict) and a single-flight worker. That keeps the 0.1 s tick
 lane unblocked, which multiplexing Laya onto the `InferenceQueue` would not. The supervisor
 still spawns 2 children headless and 3 with the TUI.
 
+The brief engine runs on the shared LLM lane, so its work is visible in the TUI: the triage
+pane border shows `[inf N]` with the queue depth and `· busy` while a job is running, the
+same counter the chat/command pane shows. Brief jobs are non-evictable, so a full queue
+refuses the brief instead of dropping a queued chat turn.
+
 ## Questions file
 
 Triage reads a bare Laya `questions` dict: a JSON object whose keys are question ids
@@ -160,11 +192,24 @@ A ready-made file ships in the repo:
 cp questions.example.json questions.json
 ```
 
-The example mixes choice and noul questions (category, urgency, frustration, churn_risk,
-needs_action, needs_clarification, contains_idea, business_opportunity,
-positive_sentiment). `questions.json` is gitignored user state; edit it in place. The file
-path is relative to the assistant's working directory (`questions_file`, default
-`questions.json`).
+The example is the eight-question brief set, in file order:
+
+| qid | type | brief mapping |
+|---|---|---|
+| `substantive_update` | `noul` | the fail-open gate; not a brief section itself |
+| `opportunity` | `noul` | Opportunities |
+| `blocker` | `noul` | Blockers/Risks |
+| `decision_status` | `choice` | Decisions |
+| `action_item` | `noul` | Action Items |
+| `responsibility_clarity` | `choice` | Responsibilities |
+| `deadline` | `noul` | Deadlines |
+| `open_question` | `noul` | Open Questions |
+
+`substantive_update` is the gate: when the model returns a `p` below `brief_gate_threshold`,
+the turn is still classified and shown, but the brief isn't asked to update. The gate is
+fail-open, so a missing or non-numeric answer passes. `questions.json` is gitignored user
+state; edit it in place. The file path is relative to the assistant's working directory
+(`questions_file`, default `questions.json`).
 
 ## Install and enable
 
@@ -198,8 +243,8 @@ Because the multilingual checkpoint is pinned, every classification uses it. Mul
 runs at temperature 1.0 upstream, so its confidence scores are **uncalibrated out of the
 box**. The default thresholds (`escalate_threshold = 0.6`, `choice_min_confidence = 0.85`)
 are starting points, not measured values. Measure on your own transcripts before trusting
-the `⚠` flagging (the thresholds no longer gate extraction), and lower or raise the thresholds to
-match what you observe.
+the `⚠` flagging (the thresholds drive neither the gate nor the brief), and lower or raise the
+thresholds to match what you observe.
 
 The measurement lives in `tests/test_laya_lane.py::test_multilingual_pin_lang_and_resources`,
 gated by `pytest.importorskip("laya")`, so the default suite skips it green and the extra
@@ -230,27 +275,39 @@ file.
 | `confidence_field` | `"answer_confidence"` | Which Laya confidence feeds the per-item `flagged`/`⚠` marker: `answer_confidence` (calibrated `max(p)`) or `confidence` (normalized entropy). |
 | `escalate_threshold` | `0.6` | A `noul` answer at or above this `p` is flagged (`⚠` marker only). |
 | `choice_min_confidence` | `0.85` | A `choice` answer below this confidence is flagged (`⚠` marker only). |
-| `extract_enabled` | `true` | Master switch for the LLM JSON extraction, which runs after every Laya pass. |
-| `extract_max_tokens` | `512` | Token cap for the extraction reply. |
-| `extract_timeout_s` | `30.0` | Timeout for the extraction request. |
+| `extract_enabled` | `true` | Master switch for the brief engine: with it false the panel classifies but no brief is requested. |
+| `extract_max_tokens` | `1536` | Token cap for the brief reply; the brief is then truncated to `brief_max_chars`. |
+| `extract_timeout_s` | `30.0` | Timeout for the brief request. |
+| `brief_slot_id` | unset | Optional llama.cpp `id_slot` for the brief engine; when unset the `id_slot` field is omitted (needs a server started with `-np 2`). |
+| `brief_gate_enabled` | `true` | Fail-open gate: update the brief only when the `substantive_update` `p` is at least `brief_gate_threshold`. |
+| `brief_gate_threshold` | `0.5` | Gate threshold for `substantive_update`; the update runs at or above this `p`. |
+| `brief_max_chars` | `4000` | Display cap for the brief; over this it is truncated, never dropped. |
+| `brief_pending_char_budget` | `3000` | Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |
 | `device` | `""` | Empty string means auto, which maps to `None` so Laya picks the checkpoint's default device. Any other value is passed through. |
 | `preload` | `["multilingual"]` | Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. |
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
-| `dump_io` | `false` | Opt-in I/O dump: one JSON file per triage under `triage/YYYY-MM-DD/HHMMSS-mmm.json` (see below). |
+| `dump_io` | `false` | Opt-in I/O dump under `triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). |
+
+`[llm].triage_system_prompt` drives the brief conversation: given the current brief (its
+previous reply, if any) and the new segment, it must return the updated brief as plain text
+only, with no markdown fences and no JSON. The reply is bounded by `extract_max_tokens` and
+`extract_timeout_s`, then truncated to `brief_max_chars`.
+`[llm].brief_compaction_system_prompt` is the summarizer used when the brief conversation's
+own history is compacted.
 
-`[llm].triage_system_prompt` is the strict JSON-only instruction used by the extraction
-cascade. It must return `{summary, opportunities[], doubts[], action_items[{what, why}],
-risks[]}` and nothing else.
+`[brief]` is a separate section that controls durability of the living brief. `write`
+(default `false`) gates disk persistence only: with it false the brief stays in the TUI, and
+with `write = true` each accepted brief is snapshotted to `dir/filename` (default
+`brief/brief.md`, cwd-relative and gitignored) and truncated on `clear`.
 
 ## Opt-in I/O dump (`dump_io`)
 
-With `[triage].dump_io = true` (default `false`) each triage writes exactly one
-self-contained JSON file under `triage/YYYY-MM-DD/HHMMSS-mmm.json` — local time with
-millisecond precision, the same naming scheme as the audio files
-(`audio/YYYY-MM-DD/HHMMSS-mmm-<reason>.wav`). The file holds all four I/O pieces of
-that triage: the Laya request, the Laya response, the LLM request (endpoint URL plus
-the full chat-completion body), and the LLM response (raw envelope), keyed by the
-same `ts` carried on the triage events:
+With `[triage].dump_io = true` (default `false`) each triage writes a self-contained JSON
+file under `triage/YYYY-MM-DD/HHMMSS-mmm.json` — local time with millisecond precision, the
+same naming scheme as the audio files (`audio/YYYY-MM-DD/HHMMSS-mmm-<reason>.wav`). A file
+holds the Laya request, the Laya response, the LLM request (endpoint URL plus the full
+chat-completion body), and the LLM response (raw envelope), keyed by the `ts` carried on the
+triage events:
 
 ```json
 {
@@ -261,15 +318,38 @@ same `ts` carried on the triage events:
 }
 ```
 
-When extraction does not run the file still gets written with only the `laya` half
-(`llm` is `null`); on a Laya error the file records `laya.response = null` with
-`laya.error = "predict failed"` plus the state-only LLM half. The directory is fixed
-as `triage/` (cwd-relative, like `transcripts/`/`audio/`/`audit/`) — there is no
-second config key for it. Files contain conversation text, so they are gitignored
-private debug output. Only the triage Laya predict and the triage extraction call are
-dumped (never the loader warmup predict or non-triage LLM calls), and dumping is
-best-effort: a dump failure is logged and swallowed, never disturbing triage
-behavior or events.
+A turn whose brief is coalesced (the previous `brief-update` was still in flight, so the turn
+was refused and its deltas were flushed later) produces **two** files, not one. The turn's
+own record carries only the `laya` half (`llm` is `null`); the later flush dispatch writes a
+separate record carrying only the `llm` half (`laya` is `null`), keyed by its own `ts`.
+Same-millisecond records get a `-N` suffix on the second and later files
+(`HHMMSS-mmm-1.json`), so the first record keeps the exact `HHMMSS-mmm.json` shape and no
+record overwrites another.
+
+When the brief does not run (the master switch is off, the gate fails, or no brief function
+is wired) the file still gets written with only the `laya` half (`llm` is `null`); on a Laya
+error the file records `laya.response = null` with `laya.error = "predict failed"`. The
+directory is fixed as `triage/` (cwd-relative, like `transcripts/`/`audio/`/`audit/`) —
+there is no second config key for it. Files contain conversation text, so they are
+gitignored private debug output. Only the triage Laya predict and the triage brief call are
+dumped (never the loader warmup predict or non-triage LLM calls), and dumping is best-effort:
+a dump failure is logged and swallowed, never disturbing triage behavior or events.
+
+## Upgrading from the JSON extraction
+
+Earlier versions of this panel asked the LLM for a strict JSON extraction
+(`{summary, opportunities[], doubts[], action_items[{what, why}], risks[]}`) and rendered
+the parsed fields in the panel. That is replaced by the plain-text brief described above.
+
+- `[triage].extract_enabled` still exists and is still the master switch, but it now gates
+  the brief update, not a JSON extraction.
+- `[triage].extract_max_tokens` (default raised to `1536`) is the brief reply cap, and
+  `extract_timeout_s` is the brief request timeout.
+- `[llm].triage_system_prompt` is now a plain-text brief instruction; the shipped default no
+  longer asks for JSON.
+- The panel's `content` stage now carries a `brief` string instead of merged JSON.
+- `[brief]` (new section) optionally persists the brief to disk; `write` is `false` by
+  default, so nothing changes on disk unless you opt in.
 
 ## Upgrading from a v5-draft config
 

+ 52 - 29
questions.example.json

@@ -1,15 +1,26 @@
 {
-  "topic": {
-    "type": "choice",
-    "instructions": "What is the main theme of the discussion?",
+  "substantive_update": {
+    "type": "noul",
+    "instructions": "This recent conversation segment adds something a running brief should record.",
+    "criteria": {
+      "true": "a new issue, decision, owner, deadline, opportunity, risk, or question appears",
+      "false": "filler, repetition, or nothing new worth recording"
+    }
+  },
+  "opportunity": {
+    "type": "noul",
+    "instructions": "A commercial, product, or process opportunity or growth idea was raised.",
+    "criteria": {
+      "true": "an opportunity or idea worth pursuing is mentioned",
+      "false": "no opportunity or growth idea is mentioned"
+    }
+  },
+  "blocker": {
+    "type": "noul",
+    "instructions": "An obstacle, dependency, objection, or risk that could block or delay the work was raised.",
     "criteria": {
-      "planning": "roadmap, priorities, scope, or scheduling",
-      "status": "progress updates or reporting",
-      "problem": "a blocker, incident, defect, or risk",
-      "decision": "a decision being made or requested",
-      "commercial": "budget, cost, pricing, or revenue",
-      "people": "hiring, staffing, roles, or performance",
-      "other": "none of the themes above"
+      "true": "something could stop or delay the work",
+      "false": "nothing blocking or risky is raised"
     }
   },
   "decision_status": {
@@ -19,31 +30,43 @@
       "none": "no decision is being discussed",
       "proposed": "a decision was proposed but is not settled",
       "made": "a decision was reached",
-      "reopened": "a previous decision is being challenged or revisited"
+      "reopened": "a previous decision is being challenged or revisited",
+      "other": "none of the above"
     }
   },
-  "action_item": { "type": "noul", "instructions": "Someone committed to a concrete next step." },
-  "owner_named": { "type": "noul", "instructions": "A specific person or team was named as responsible for the work." },
-  "due_date": { "type": "noul", "instructions": "A date, deadline, or timeframe was mentioned for the work." },
-  "blocker": {
+  "action_item": {
     "type": "noul",
-    "instructions": "A blocker, dependency, or risk is holding the work back.",
-    "criteria": { "true": "an obstacle, dependency, or risk was raised", "false": "no obstacle was raised" }
+    "instructions": "Someone committed to a concrete next step.",
+    "criteria": {
+      "true": "a specific next action or commitment was made",
+      "false": "no commitment to a next step is made"
+    }
+  },
+  "responsibility_clarity": {
+    "type": "choice",
+    "instructions": "How clear is who is responsible for the work being discussed?",
+    "criteria": {
+      "unclear": "no owner or responsibility is identified",
+      "implied": "a role or team is implied but not named",
+      "explicit": "a specific person or team is named as responsible",
+      "disputed": "ownership is contested or being reassigned",
+      "other": "none of the above"
+    }
+  },
+  "deadline": {
+    "type": "noul",
+    "instructions": "A date, deadline, or timeframe was mentioned for the work.",
+    "criteria": {
+      "true": "a date, deadline, or timeframe is stated",
+      "false": "no date or timeframe is mentioned"
+    }
   },
   "open_question": {
     "type": "noul",
     "instructions": "Something important is still ambiguous and needs a follow-up question.",
-    "criteria": { "true": "unclear or unresolved; a follow-up is needed", "false": "clear enough to proceed" }
-  },
-  "business_opportunity": { "type": "noul", "instructions": "A commercial opportunity or growth idea was mentioned." },
-  "sentiment": {
-    "type": "score",
-    "instructions": "How positive or negative is the overall tone of the discussion?",
-    "criteria": ["very negative", "negative", "neutral", "positive", "very positive"]
-  },
-  "urgency": {
-    "type": "choice",
-    "instructions": "How urgent is the discussion?",
-    "criteria": ["low", "medium", "high"]
+    "criteria": {
+      "true": "unclear or unresolved; a follow-up is needed",
+      "false": "clear enough to proceed"
+    }
   }
 }

+ 63 - 0
src/stt_daemon/assistant/brief_file.py

@@ -0,0 +1,63 @@
+"""Best-effort atomic writer for the durable brief markdown snapshot.
+
+Enabled via `[brief].write = true` (off by default). When enabled, each brief
+update replaces `[brief].dir`/`[brief].filename` (default `brief/brief.md`)
+with the exact text supplied by the assistant, using the same
+`tempfile.mkstemp` + `os.replace` atomic pattern as the rest of the codebase.
+
+The `brief/` directory is cwd-relative user state and gitignored. Best-effort
+by design: any `OSError` is logged and swallowed so the brief writer can never
+disturb the assistant flow; `clear()` truncates to empty and likewise never
+raises.
+"""
+
+import logging
+import os
+import tempfile
+
+logger = logging.getLogger(__name__)
+
+
+class BriefFile:
+    """Best-effort, atomic, opt-in writer for the durable brief document."""
+
+    def __init__(self, write_enabled: bool, directory: str, filename: str = "brief.md") -> None:
+        self._write_enabled = bool(write_enabled)
+        self._directory = str(directory)
+        self._filename = str(filename)
+
+    @property
+    def write_enabled(self) -> bool:
+        return self._write_enabled
+
+    def write(self, text: str) -> None:
+        """Atomically replace the brief file with `text` (no-op when disabled)."""
+        if not self._write_enabled:
+            return
+        path = os.path.join(self._directory, self._filename)
+        try:
+            os.makedirs(self._directory, exist_ok=True)
+            fd, tmp = tempfile.mkstemp(dir=self._directory, suffix=".tmp")
+            try:
+                with os.fdopen(fd, "w", encoding="utf-8") as f:
+                    f.write(text)
+                os.replace(tmp, path)
+            except OSError:
+                try:
+                    os.unlink(tmp)
+                except OSError:
+                    pass
+                raise
+        except OSError as e:  # noqa: BLE001 - brief write is best-effort, never raises
+            logger.warning("brief file write failed: %s", e)
+
+    def clear(self) -> None:
+        """Truncate the brief file to empty (no-op when disabled; never raises)."""
+        if not self._write_enabled:
+            return
+        path = os.path.join(self._directory, self._filename)
+        try:
+            with open(path, "w", encoding="utf-8"):
+                pass
+        except OSError as e:  # noqa: BLE001 - brief clear is best-effort, never raises
+            logger.warning("brief file clear failed: %s", e)

+ 8 - 2
src/stt_daemon/assistant/executor.py

@@ -38,17 +38,20 @@ class NullExecutor:
 class LlmExecutor:
     """Runs a command through the model's tool-calling loop."""
 
-    def __init__(self, cfg, client, registry):
+    def __init__(self, cfg, client, registry, slot_id: int | None = None):
         """Create the executor.
 
         Args:
             cfg: An `LlmConfig`.
             client: An `LlmClient`.
             registry: A `ToolRegistry`.
+            slot_id: Optional llama.cpp slot id for command calls; omitted
+                from the request when None.
         """
         self.cfg = cfg
         self.client = client
         self.registry = registry
+        self.slot_id = slot_id
 
     def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
         """Ask the model, validate/execute any tool calls, and return the answer."""
@@ -57,12 +60,15 @@ class LlmExecutor:
             {"role": "user", "content": command_text},
         ]
         tools = self.registry.specs()
+        chat_kwargs: dict = {}
+        if self.slot_id is not None:
+            chat_kwargs["slot_id"] = self.slot_id
         last_tool: str | None = None
         last_args: dict = {}
 
         for _ in range(max(1, self.cfg.max_tool_iterations)):
             try:
-                response = self.client.chat(messages, tools=tools)
+                response = self.client.chat(messages, tools=tools, **chat_kwargs)
             except LlmError as e:
                 return ExecutionResult(ok=False, result=f"llm error: {e}")
 

+ 10 - 5
src/stt_daemon/assistant/inference.py

@@ -17,9 +17,10 @@ logger = logging.getLogger(__name__)
 class InferenceJob:
     """A unit of work for the inference queue."""
 
-    name: str  # "chat" | "command" | "summary"
+    name: str  # "chat" | "command" | "brief-update" | ...
     run: Callable[[], None]  # the work to do
     priority: int = 0  # lower values run first; commands use -1
+    evictable: bool = True  # False: never dropped by submit() overflow
 
 
 class InferenceQueue:
@@ -61,8 +62,9 @@ class InferenceQueue:
         """Queue a job for execution.
 
         Returns:
-            False only when a priority job (priority < 0) is rejected due to overflow.
-            Normal jobs drop the oldest normal job on overflow and return True.
+            False when the queue is full and the job cannot be admitted: either it
+            is a priority job (priority < 0), or there is no evictable normal job to
+            drop. Otherwise an evictable normal job is dropped and True is returned.
         """
         with self._cond:
             if self._stop.is_set():
@@ -132,12 +134,15 @@ class InferenceQueue:
             return self._in_flight is not None
 
     def _drop_oldest_normal(self) -> bool:
-        """Remove the oldest normal (priority >= 0) job; False if none exists.
+        """Remove the oldest evictable normal (priority >= 0) job; False if none exists.
+
+        Non-evictable jobs are skipped, so a queue of only non-evictable jobs
+        refuses the overflow rather than dropping one.
 
         Assumes the lock is held.
         """
         for i, job in enumerate(self._queue):
-            if job.priority >= 0:
+            if job.priority >= 0 and job.evictable:
                 del self._queue[i]
                 return True
         return False

+ 100 - 15
src/stt_daemon/assistant/llm.py

@@ -82,6 +82,8 @@ class LlmClient:
         max_tokens: int | None = None,
         timeout_s: float | None = None,
         on_io: Callable[[dict], None] | None = None,
+        *,
+        slot_id: int | None = None,
     ) -> LlmResponse:
         """Send one completion request and parse the reply.
 
@@ -92,6 +94,7 @@ class LlmClient:
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
             on_io: Optional hook receiving `{"phase": "request", ...}` before
                 the call and `{"phase": "response", ...}` after it.
+            slot_id: Optional llama.cpp slot id (`id_slot`); omitted when None.
         """
         effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
         effective_timeout   = self.cfg.timeout_s  if timeout_s is None else timeout_s
@@ -103,6 +106,8 @@ class LlmClient:
             "cache_prompt": self.cfg.cache_prompt,
             "stream": False,
         }
+        if slot_id is not None:
+            body["id_slot"] = slot_id
         if tools:
             body["tools"] = tools
             body["tool_choice"] = "auto"
@@ -150,7 +155,7 @@ class LlmClient:
         )
 
 
-class ChatSession:
+class ConversationEngine:
     """Conversation history with rolling-summary compaction and block-trim fallback.
 
     State:
@@ -158,19 +163,33 @@ class ChatSession:
         history  — verbatim recent window (append-only between compactions).
 
     Prompt assembly:
-        [system: system_prompt_chat]
+        [system: system_prompt]
         [system: "Earlier conversation summary:\\n{summary}"]  # only when summary is set
         *history
 
     Lock discipline: self._lock (RLock) guards history/summary mutations and reads.
-    It is NEVER held across an HTTP call.
+    It is NEVER held across an HTTP call or a finalize callback.
     """
 
-    def __init__(self, cfg, client: LlmClient, notify: Callable[..., None] | None = None):
-        self.cfg    = cfg
-        self.client = client
-        self.system = cfg.system_prompt_chat
-        self.notify = notify
+    def __init__(
+        self,
+        cfg,
+        client,
+        system_prompt: str,
+        compaction_system_prompt: str | None = None,
+        max_tokens: int | None = None,
+        timeout_s: float | None = None,
+        notify: Callable[..., None] | None = None,
+        slot_id: int | None = None,
+    ):
+        self.cfg                      = cfg
+        self.client                   = client
+        self.system                   = system_prompt
+        self._compaction_system_prompt = compaction_system_prompt
+        self._max_tokens              = max_tokens
+        self._timeout_s               = timeout_s
+        self.notify                   = notify
+        self._slot_id                 = slot_id
 
         self.history: list[dict] = []
         self.summary: str        = ""
@@ -184,10 +203,34 @@ class ChatSession:
             self.summary     = ""
             self._generation += 1
 
-    def send(self, user_text: str) -> str:
-        """Append a user turn, ask the model, append and return the reply."""
+    def last_reply(self) -> str:
+        """Return the most recent assistant turn, or "" when there is none."""
         with self._lock:
-            generation = self._generation
+            for msg in reversed(self.history):
+                if msg["role"] == "assistant":
+                    return msg["content"]
+        return ""
+
+    def send(
+        self,
+        user_text: str,
+        on_io: Callable[[dict], None] | None = None,
+        finalize: Callable[[str, str], str | None] | None = None,
+    ) -> str:
+        """Append a user turn, ask the model, append and return the reply.
+
+        Args:
+            user_text: The user turn to append.
+            on_io: Optional per-call IO hook, forwarded to the client only when set.
+            finalize: Optional gate called as `finalize(reply, finish_reason)` after
+                the staleness check. Returning None rejects the turn: history and
+                summary are restored from the pre-call snapshot and "" is returned.
+                Returning a string appends THAT string as the assistant turn.
+        """
+        with self._lock:
+            generation       = self._generation
+            snapshot_history = list(self.history)
+            snapshot_summary = self.summary
             self.history.append({"role": "user", "content": user_text})
 
         self._maybe_compact()
@@ -200,16 +243,48 @@ class ChatSession:
                 )
             messages += self.history
 
-        # HTTP call — no lock held.
-        response = self.client.chat(messages)
+        # HTTP call — no lock held. Optional kwargs are included only when set so
+        # that old fakes (which accept neither) keep working.
+        chat_kwargs: dict[str, Any] = {}
+        if on_io is not None:
+            chat_kwargs["on_io"] = on_io
+        if self._slot_id is not None:
+            chat_kwargs["slot_id"] = self._slot_id
+        response = self.client.chat(
+            messages,
+            max_tokens=self._max_tokens,
+            timeout_s=self._timeout_s,
+            **chat_kwargs,
+        )
         reply    = response.content
-        truncated = response.finish_reason == "length"
+        finish_reason = response.finish_reason
+        truncated = finish_reason == "length"
 
         with self._lock:
             if generation != self._generation:
                 # The session was reset while the model was thinking: the reply belongs
                 # to a conversation that no longer exists, so drop it.
                 return ""
+
+        # Finalize callback — no lock held, so a reset() inside it is not undone.
+        if finalize is not None:
+            final = finalize(reply, finish_reason)
+            with self._lock:
+                if generation != self._generation:
+                    # A reset() intervened (possibly inside finalize itself); the session
+                    # is already clean — restoring would undo the reset.
+                    return ""
+                if final is None:
+                    self.history = snapshot_history
+                    self.summary = snapshot_summary
+                    return ""
+                if final is not None:
+                    self.history.append({"role": "assistant", "content": final})
+                return final
+
+        with self._lock:
+            if generation != self._generation:
+                return ""
             if reply:
                 self.history.append({"role": "assistant", "content": reply})
 
@@ -342,12 +417,15 @@ class ChatSession:
             lines.append(f"{msg['role']}: {msg['content']}")
         user_text = "\n".join(lines)
 
+        compaction_prompt = self._compaction_system_prompt or cfg.compaction_system_prompt
         messages = [
-            {"role": "system", "content": cfg.compaction_system_prompt},
+            {"role": "system", "content": compaction_prompt},
             {"role": "user",   "content": user_text},
         ]
 
         try:
+            # Never pin the summarizer to a slot: compaction must not evict the
+            # engine's own conversation slot.
             response = self.client.chat(
                 messages,
                 max_tokens=cfg.compaction_max_tokens,
@@ -367,3 +445,10 @@ class ChatSession:
             return None
 
         return response.content
+
+
+class ChatSession(ConversationEngine):
+    """Back-compat chat session: a ConversationEngine on the chat system prompt."""
+
+    def __init__(self, cfg, client, notify: Callable[..., None] | None = None):
+        super().__init__(cfg, client, system_prompt=cfg.system_prompt_chat, notify=notify)

+ 38 - 26
src/stt_daemon/assistant/runner.py

@@ -1,6 +1,7 @@
 """Run the assistant process: subscribe to the STT bus and serve the control socket."""
 
 import argparse
+import functools
 import logging
 import signal
 import threading
@@ -8,17 +9,18 @@ import threading
 from ..bus import EventSubscriber
 from ..config import load_config
 from .audit import AuditLog
+from .brief_file import BriefFile
 from .control import ControlServer
 from .executor import LlmExecutor, NullExecutor
-from .llm import ChatSession, LlmClient
+from .llm import ConversationEngine, LlmClient
 from .service import AssistantService
 from .tools import default_registry
 
 
 def build_triage(cfg, client, on_result=None):
-    """Build (analyzer, lane, extract_fn); (None, None, None) when disabled."""
+    """Build (analyzer, lane, brief_fn, brief_reset); (None, None, None, None) when disabled."""
     if not cfg.triage.enabled:
-        return (None, None, None)
+        return (None, None, None, None)
     from .laya_lane import LayaLane
     from .triage import TriageAnalyzer
 
@@ -28,26 +30,23 @@ def build_triage(cfg, client, on_result=None):
         on_state=lambda state, reason: analyzer.refresh(state, reason),
         on_result=on_result,
     )
-    extract = None
+    brief_fn = None
+    brief_reset = None
     if client is not None:
-        def _extract(prompt, on_io=None):
-            kwargs = {}
-            if on_io is not None:
-                kwargs["on_io"] = on_io
-            resp = client.chat(
-                [
-                    {"role": "system", "content": cfg.llm.triage_system_prompt},
-                    {"role": "user", "content": prompt},
-                ],
-                max_tokens=cfg.triage.extract_max_tokens,
-                timeout_s=cfg.triage.extract_timeout_s,
-                **kwargs,
-            )
-            return resp.content
-
-        extract = _extract
-
-    return (analyzer, lane, extract)
+        engine = ConversationEngine(
+            cfg.llm,
+            client,
+            system_prompt=cfg.llm.triage_system_prompt,
+            compaction_system_prompt=cfg.llm.brief_compaction_system_prompt,
+            max_tokens=cfg.triage.extract_max_tokens,
+            timeout_s=cfg.triage.extract_timeout_s,
+            slot_id=cfg.triage.brief_slot_id,
+            notify=None,
+        )
+        brief_fn = functools.partial(engine.send, finalize=analyzer.finalize_brief)
+        brief_reset = engine.reset
+
+    return (analyzer, lane, brief_fn, brief_reset)
 
 
 def main() -> int:
@@ -75,17 +74,28 @@ def main() -> int:
     client = None
     if cfg.llm.enabled:
         client = LlmClient(cfg.llm)
-        session = ChatSession(cfg.llm, client)
-        executor = LlmExecutor(cfg.llm, client, default_registry())
+        session = ConversationEngine(
+            cfg.llm,
+            client,
+            system_prompt=cfg.llm.system_prompt_chat,
+            slot_id=cfg.llm.chat_slot_id,
+        )
+        executor = LlmExecutor(
+            cfg.llm,
+            client,
+            default_registry(),
+            slot_id=cfg.llm.command_slot_id,
+        )
         chat_fn = session.send
         chat_reset = session.reset
 
     holder = {}
-    triage, lane, extract_fn = build_triage(
+    triage, lane, brief_fn, brief_reset = build_triage(
         cfg,
         client if cfg.llm.enabled else None,
         on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
     )
+    brief_file = BriefFile(cfg.brief.write, cfg.brief.dir, cfg.brief.filename)
     service = AssistantService(
         cfg,
         subscriber,
@@ -95,7 +105,9 @@ def main() -> int:
         chat_reset=chat_reset,
         triage=triage,
         laya_lane=lane,
-        triage_extract_fn=extract_fn,
+        brief_fn=brief_fn,
+        brief_reset=brief_reset,
+        brief_file=brief_file,
     )
     holder["svc"] = service
     if session is not None:

+ 217 - 89
src/stt_daemon/assistant/service.py

@@ -64,7 +64,9 @@ class AssistantService:
         chat_reset: Callable[[], None] | None = None,
         triage: Any = None,
         laya_lane: Any = None,
-        triage_extract_fn: Callable[..., str] | None = None,
+        brief_fn: Callable[..., str] | None = None,
+        brief_reset: Callable[[], None] | None = None,
+        brief_file: Any = None,
     ):
         """Create the service.
 
@@ -80,7 +82,9 @@ class AssistantService:
             chat_reset: Callable clearing the chat session on exit.
             triage: Triage analyzer (duck-typed); None disables triage.
             laya_lane: Laya inference lane (duck-typed); None disables triage.
-            triage_extract_fn: Callable turning an extraction prompt into raw text.
+            brief_fn: Callable turning a brief prompt into plain-text brief.
+            brief_reset: Callable clearing the brief engine session on clear.
+            brief_file: Durable brief writer (duck-typed `write`/`clear`).
         """
         self.cfg = cfg
         self._subscriber = subscriber
@@ -112,7 +116,27 @@ class AssistantService:
 
         self._triage = triage
         self._laya_lane = laya_lane
-        self._triage_extract_fn = triage_extract_fn
+        self._brief_fn = brief_fn
+        self._brief_reset = brief_reset
+        self._brief_file = brief_file
+        self._pending_brief: list[str] = []
+        # Three independent in-flight guards, one owner per engine. The chat and
+        # triage resets never cross: `_exit_chat`/`toggle_chat` touch only the
+        # chat buffers and `chat_reset`, while `_reset_triage` (lock-held, called
+        # by `clear()`) owns the triage state below.
+        # - `ConversationEngine._generation` (llm.py) drops the engine's own
+        #   stale reply when the engine is reset.
+        # - `_triage_generation` is bumped by `_reset_triage`; `_on_triage_result`
+        #   re-checks it under the lock before storing signals and before
+        #   dispatching, so stale Laya results and emits are dropped.
+        # - `_brief_token` is the brief owner token: only the job whose token
+        #   still matches may clear `_brief_outstanding`. A stale in-flight
+        #   `_run_brief_update` therefore cannot clear a newer dispatch's flag,
+        #   even though its generation is already stale.
+        self._brief_outstanding = False
+        self._brief_token = 0
+        self._last_brief_signals: dict | None = None
+        self._chat_queue_full = False
         self._last_triage_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
         self._last_triage_window: tuple = ()
@@ -377,6 +401,7 @@ class AssistantService:
                 return False
             self.chat_history = []
             self._pending_chat = []
+            self._chat_queue_full = False
             self._last_chat_activity = self._now()
         self._emit_mode()
         return True
@@ -396,11 +421,19 @@ class AssistantService:
         """Clear the chat history and emit a cleared event (always returns True).
 
         Does not change mode. Drops queued chat inference jobs and calls chat_reset
-        if available. Emits a cleared event to notify viewers.
+        if available. Emits a cleared event to notify viewers. When triage is
+        enabled, also drops the pending Laya request and queued `brief-update`
+        jobs, calls brief_reset, and emits a triage `cleared` event.
         """
         with self._lock:
             self.chat_history = []
             self._pending_chat = []
+            self._chat_queue_full = False
+            self._pending_brief = []
+            self._reset_triage()
+            self._triage_dump.clear()
+            if self._brief_file is not None:
+                self._brief_file.clear()
         self._inference.drop(lambda j: j.name == "chat")
         if self.chat_reset is not None:
             try:
@@ -408,7 +441,16 @@ class AssistantService:
             except Exception as e:  # noqa: BLE001 - clearing chat must not fail the operation
                 logger.warning("chat reset failed: %s", e)
         self._emit(CLEARED_EVENT)
-        self._reset_triage()
+        if self._triage is not None or self._laya_lane is not None:
+            if self._laya_lane is not None:
+                self._laya_lane.drop_pending()
+            self._inference.drop(lambda j: j.name == "brief-update")
+            if self._brief_reset is not None:
+                try:
+                    self._brief_reset()
+                except Exception as e:  # noqa: BLE001 - clearing brief must not fail
+                    logger.warning("brief reset failed: %s", e)
+            self._emit(TRIAGE_EVENT, stage="cleared", triage=None)
         return True
 
     def speaker_list(self) -> list[dict]:
@@ -566,6 +608,22 @@ class AssistantService:
         self.awaited_seg_id = None
         self._last_activity_ts = now
 
+    def _cap_pending_brief_locked(self) -> None:
+        """Drop oldest buffered brief deltas beyond the char budget (lock held)."""
+        try:
+            budget = int(getattr(self.cfg.triage, "brief_pending_char_budget", 3000))
+        except (TypeError, ValueError):
+            budget = 3000
+        kept: list[str] = []
+        total = 0
+        for delta in reversed(self._pending_brief):
+            if kept and total + len(delta) > budget:
+                break
+            kept.append(delta)
+            total += len(delta)
+        kept.reverse()
+        self._pending_brief[:] = kept
+
     def _on_transcript(self, event: dict, now: float) -> None:
         text = (event.get("text") or "").strip()
         lang = event.get("lang")
@@ -576,6 +634,10 @@ class AssistantService:
         with self._lock:
             self.triage_history.append(text)
             del self.triage_history[:-_TRIAGE_HISTORY_MAX]
+        if self._triage is not None:
+            with self._lock:
+                self._pending_brief.append(f"{self._speaker_display(event)}: {text}")
+                self._cap_pending_brief_locked()
 
         if self.modes.mode is Mode.COMMAND_ARMED:
             ts = float(event.get("ts", now))
@@ -630,6 +692,7 @@ class AssistantService:
                 return False
             self.chat_history = []
             self._pending_chat = []
+            self._chat_queue_full = False
         self._inference.drop(lambda j: j.name == "chat")
         if self.chat_reset is not None:
             try:
@@ -647,11 +710,20 @@ class AssistantService:
             self._pending_chat = []
         if not text:
             return
-        self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
         job = InferenceJob(name="chat", run=lambda: self._run_chat(text))
-        self._inference.submit(job)
+        if self._inference.submit(job):
+            with self._lock:
+                self._chat_queue_full = False
+            return
+        with self._lock:
+            was_full = self._chat_queue_full
+            self._chat_queue_full = True
+            self._pending_chat.insert(0, text)
+        if not was_full:
+            self._emit(ERROR_EVENT, code="queue_full", message="chat queue full")
 
     def _run_chat(self, text: str) -> None:
+        self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
         chat_fn = self.chat_fn
         if chat_fn is None:
             return
@@ -703,7 +775,6 @@ class AssistantService:
         with self._lock:
             if generation != self._triage_generation:
                 return
-            window = self._last_triage_window
         ts = self._now()
         if self._triage_dump.enabled:
             self._triage_dump.begin(
@@ -714,111 +785,168 @@ class AssistantService:
                     "error": None if payload is not None else "predict failed",
                 },
             )
-        signals = None
-        if payload is not None:
-            signals = self._triage.normalize(payload)
-            if not signals:
-                self._triage_dump.finalize(ts)
+        if payload is None:
+            self._triage_dump.finalize(ts)
+            return
+        signals = self._triage.normalize(payload)
+        if not signals:
+            self._triage_dump.finalize(ts)
+            return
+        with self._lock:
+            if generation != self._triage_generation:
+                self._triage_dump.discard(ts)
                 return
-            with self._lock:
-                if generation != self._triage_generation:
-                    self._triage_dump.discard(ts)
-                    return
-                self.last_triage = signals
+            self.last_triage = signals
+            self._last_brief_signals = signals
             self._emit(TRIAGE_EVENT, stage="signals", triage=signals, ts=ts)
-        will_extract = self._triage_extract_fn is not None and getattr(
-            self.cfg.triage, "extract_enabled", True
+        cfg = self.cfg.triage
+        gated_in = not getattr(cfg, "brief_gate_enabled", True) or (
+            self._triage.should_update_brief(signals)
         )
-        if will_extract:
-            job = InferenceJob(
-                name="triage-extract",
-                run=lambda: self._run_triage_extract(generation, window, signals, ts),
-            )
-            if not self._inference.submit_if_room(job):
-                self._triage_dump.finalize(ts)
+        if (
+            self._brief_fn is not None
+            and getattr(cfg, "extract_enabled", True)
+            and gated_in
+        ):
+            self._dispatch_brief(signals, ts, generation)
         else:
             self._triage_dump.finalize(ts)
 
-    def _run_triage_extract(self, generation, window, signals, ts=None) -> None:
-        if self._triage is None or self._triage_extract_fn is None:
+    def _dispatch_brief(self, signals, ts, generation) -> bool:
+        """Queue one token-guarded `brief-update` job; single finalize owner."""
+        with self._lock:
+            if generation != self._triage_generation:
+                refusal: str | None = "stale"
+                job = None
+                token = None
+            elif self._brief_outstanding:
+                refusal = "outstanding"
+                job = None
+                token = None
+            else:
+                refusal = None
+                self._brief_token += 1
+                token = self._brief_token
+                self._brief_outstanding = True
+                job = InferenceJob(
+                    name="brief-update",
+                    evictable=False,
+                    run=lambda: self._run_brief_update(generation, signals, ts, token),
+                )
+        if refusal is not None:
+            self._triage_dump.finalize(ts)
+            return False
+        assert job is not None and token is not None
+        if not self._inference.submit_if_room(job):
+            with self._lock:
+                if self._brief_token == token:
+                    self._brief_outstanding = False
+            self._triage_dump.finalize(ts)
+            return False
+        return True
+
+    def _run_brief_update(self, generation, signals, ts=None, token=None) -> None:
+        if self._triage is None or self._brief_fn is None:
+            if ts is not None:
+                self._triage_dump.discard(ts)
             return
         dump = self._triage_dump.enabled
         if ts is None:
             ts = self._now()
-        with self._lock:
-            if generation != self._triage_generation:
-                if dump:
-                    self._triage_dump.discard(ts)
-                return
-        io_hook: Callable[[dict], None] | None = None
-        if dump:
-            def _record_io(part: dict) -> None:
-                if part.get("phase") == "request":
-                    self._triage_dump.record(
-                        ts,
-                        llm={
-                            "request": {
-                                "url": part.get("url"),
-                                "body": part.get("body"),
-                            },
-                            "response": None,
-                            "error": None,
-                        },
-                    )
-                else:
-                    self._triage_dump.record(
-                        ts,
-                        llm={
-                            "request": None,
-                            "response": part.get("payload"),
-                            "error": part.get("error"),
-                        },
-                    )
-            io_hook = _record_io
+        requeued = False
+        deltas: list[str] = []
         try:
-            prompt = self._triage.build_extraction_prompt(window, signals)
-            try:
-                if dump:
-                    text = self._triage_extract_fn(prompt, on_io=io_hook)
-                else:
-                    text = self._triage_extract_fn(prompt)
-            except Exception as e:  # noqa: BLE001 - an extract failure must not kill the worker
-                logger.error("Triage extract error: %s", e)
-                return
             with self._lock:
                 if generation != self._triage_generation:
                     return
-            parsed = self._triage.parse_extraction(text)
-            if parsed is None:
+                deltas = list(self._pending_brief)
+                if not deltas:
+                    return
+                del self._pending_brief[: len(deltas)]
+            io_hook: Callable[[dict], None] | None = None
+            if dump:
+                def _record_io(part: dict) -> None:
+                    if part.get("phase") == "request":
+                        self._triage_dump.record(
+                            ts,
+                            llm={
+                                "request": {
+                                    "url": part.get("url"),
+                                    "body": part.get("body"),
+                                },
+                                "response": None,
+                                "error": None,
+                            },
+                        )
+                    else:
+                        self._triage_dump.record(
+                            ts,
+                            llm={
+                                "request": None,
+                                "response": part.get("payload"),
+                                "error": part.get("error"),
+                            },
+                        )
+                io_hook = _record_io
+            prompt = self._triage.build_brief_prompt(deltas, signals)
+            if dump:
+                text = self._brief_fn(prompt, on_io=io_hook)
+            else:
+                text = self._brief_fn(prompt)
+            with self._lock:
+                if generation != self._triage_generation:
+                    return
+            if not text:
+                requeued = True
                 return
             with self._lock:
                 if generation != self._triage_generation:
                     return
-                merged = {**(signals or {}), "content": parsed}
+                merged = {**(signals or {}), "brief": text}
                 self.last_triage = merged
-            self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+                if self._brief_file is not None:
+                    self._brief_file.write(text)
+                self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+        except Exception as e:  # noqa: BLE001 - a brief failure must not kill the worker
+            logger.error("Triage brief error: %s", e)
+            requeued = True
         finally:
-            if dump:
-                with self._lock:
-                    stale = generation != self._triage_generation
-                if stale:
-                    self._triage_dump.discard(ts)
-                else:
-                    self._triage_dump.finalize(ts)
+            with self._lock:
+                stale = generation != self._triage_generation
+                if token is not None and self._brief_token == token:
+                    self._brief_outstanding = False
+                if requeued and not stale:
+                    self._pending_brief[0:0] = deltas
+                    self._cap_pending_brief_locked()
+                do_flush = (
+                    not requeued
+                    and not stale
+                    and bool(self._pending_brief)
+                    and self._last_brief_signals is not None
+                )
+                flush_signals = self._last_brief_signals
+            if stale:
+                self._triage_dump.discard(ts)
+            else:
+                self._triage_dump.finalize(ts)
+            if do_flush:
+                self._dispatch_brief(flush_signals, self._now(), generation)
 
     def _reset_triage(self) -> None:
+        """Lock-HELD helper: guarded in-lock triage resets only (never emits).
+
+        Callers must hold `self._lock` (an RLock); `clear()` owns the dumper,
+        the durable brief file, the lane/job drops, and every emit.
+        """
         if self._triage is None and self._laya_lane is None:
             return
-        with self._lock:
-            self._triage_generation += 1
-            self.last_triage = None
-            self._last_triage_window = ()
-            self.triage_history = []
-        self._triage_dump.clear()
-        if self._laya_lane is not None:
-            self._laya_lane.drop_pending()
-        self._inference.drop(lambda j: j.name == "triage-extract")
-        self._emit(TRIAGE_EVENT, stage="cleared", triage=None)
+        self._triage_generation += 1
+        self.last_triage = None
+        self._last_triage_window = ()
+        self.triage_history = []
+        self._brief_token += 1
+        self._brief_outstanding = False
+        self._last_brief_signals = None
 
     def _clear_command(self) -> None:
         self.arm_ts = None

+ 67 - 67
src/stt_daemon/assistant/triage.py

@@ -3,8 +3,8 @@
 Stdlib only. No ML imports here (not even lazily): the dedicated lane module
 owns the model import, background thread, and inference. This module only
 validates the questions file, condenses transcript windows, normalizes raw
-answers into display items, decides escalation, and builds/parses the optional
-structured extraction. The only I/O is the one-time questions file read in the
+answers into display items, decides escalation, and builds/parses/finalizes
+the plain-text brief. The only I/O is the one-time questions file read in the
 constructor.
 
 Public API (consumed by the assistant service layer):
@@ -21,16 +21,19 @@ Public API (consumed by the assistant service layer):
         def build_state(self, window) -> str
         def normalize(self, payload) -> dict
         def should_escalate(self, signals) -> bool
-        def build_extraction_prompt(self, window, signals) -> str
-        def parse_extraction(self, text) -> dict | None
+        def build_brief_prompt(self, deltas, signals) -> str
+        def parse_brief(self, text) -> str | None
+        def should_update_brief(self, signals) -> bool
+        def finalize_brief(self, text, finish_reason) -> str | None
         def state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
 
 ``cfg`` is duck-typed (attribute access only, never imported): it provides
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``confidence_field`` (default ``"answer_confidence"``),
-``escalate_threshold`` (default 0.6) and ``choice_min_confidence``
-(default 0.85).
+``escalate_threshold`` (default 0.6), ``choice_min_confidence``
+(default 0.85), ``brief_max_chars`` (default 4000) and
+``brief_gate_threshold`` (default 0.5).
 """
 
 import json
@@ -42,16 +45,6 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
 
 _VALID_TYPES = ("choice", "score", "noul")
 
-_MAX_EXTRACT_LIST = 5
-_MAX_EXTRACT_STR = 500
-_MAX_EXTRACT_INPUT = 20000
-
-_EXTRACTION_INSTRUCTION = (
-    "Reply with strict JSON only, no prose and no markdown outside the JSON object, "
-    'using exactly these keys: {"summary": str, "opportunities": [str], '
-    '"doubts": [str], "action_items": [{"what": str, "why": str}], "risks": [str]}.'
-)
-
 
 def _invalid(path, qid, why):
     logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
@@ -339,12 +332,21 @@ class TriageAnalyzer:
             return "%s: likelihood %.2f" % (qid, item.get("p"))
         return "%s: flagged" % (qid,)
 
-    def build_extraction_prompt(self, window, signals) -> str:
-        """Render condensed window + all signals as words + JSON-only instruction."""
+    def _brief_max_chars(self) -> int:
         try:
-            state_text = self.build_state(window)
-        except Exception:
-            state_text = ""
+            value = int(getattr(self._cfg, "brief_max_chars", 4000))
+        except (TypeError, ValueError):
+            return 4000
+        return value if value > 0 else 4000
+
+    def _brief_gate_threshold(self) -> float:
+        try:
+            return float(getattr(self._cfg, "brief_gate_threshold", 0.5))
+        except (TypeError, ValueError):
+            return 0.5
+
+    def _signal_lines(self, signals) -> list[str]:
+        """Render each ordered signal as a "- " words line; never raises."""
         lines = []
         try:
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
@@ -362,17 +364,27 @@ class TriageAnalyzer:
                         continue
         except Exception:
             lines = []
-        base = "Recent user utterances:\n" + state_text + "\n\n"
-        if lines:
-            return base + "Signals:\n" + "\n".join(lines) + "\n\n" + _EXTRACTION_INSTRUCTION
-        return base + _EXTRACTION_INSTRUCTION
+        return lines
 
-    def parse_extraction(self, text) -> dict | None:
-        """Parse fenced-or-bare JSON extraction; cap lists/strings; None on failure."""
+    def build_brief_prompt(self, deltas, signals) -> str:
+        """Render new speaker-labeled utterances + focus words; no JSON keys."""
+        try:
+            body = "\n".join("- " + delta for delta in (deltas or []))
+        except Exception:
+            body = ""
+        parts = ["New utterances:\n" + body]
+        focus = self._signal_lines(signals)
+        if focus:
+            parts.append("Focus:\n" + "\n".join(focus))
+        parts.append("Return only the updated brief as plain text.")
+        return "\n\n".join(parts)
+
+    def parse_brief(self, text) -> str | None:
+        """Strip a code fence; None when empty; truncate (not reject) overlong text."""
         if not isinstance(text, str):
             return None
         cleaned = text.strip()
-        if not cleaned or len(cleaned) > _MAX_EXTRACT_INPUT:
+        if not cleaned:
             return None
         if cleaned.startswith("```"):
             _, _, rest = cleaned.partition("\n")
@@ -381,46 +393,34 @@ class TriageAnalyzer:
             if end != -1:
                 cleaned = cleaned[:end]
             cleaned = cleaned.strip()
-        try:
-            data = json.loads(cleaned)
-        except (json.JSONDecodeError, ValueError):
-            return None
-        if not isinstance(data, dict):
-            return None
-        try:
-            summary = data["summary"]
-            opportunities = data["opportunities"]
-            doubts = data["doubts"]
-            action_items = data["action_items"]
-            risks = data["risks"]
-            if not isinstance(summary, str):
-                return None
-            for group in (opportunities, doubts, risks):
-                if not isinstance(group, list):
-                    return None
-                if not all(isinstance(entry, str) for entry in group):
-                    return None
-            if not isinstance(action_items, list):
-                return None
-            for entry in action_items:
-                if not isinstance(entry, dict):
-                    return None
-                if not isinstance(entry.get("what"), str) or not isinstance(entry.get("why"), str):
-                    return None
-        except (KeyError, TypeError, AttributeError):
+        if not cleaned:
             return None
-        return {
-            "summary": summary[:_MAX_EXTRACT_STR],
-            "opportunities": [
-                entry[:_MAX_EXTRACT_STR] for entry in opportunities[:_MAX_EXTRACT_LIST]
-            ],
-            "doubts": [entry[:_MAX_EXTRACT_STR] for entry in doubts[:_MAX_EXTRACT_LIST]],
-            "action_items": [
-                {"what": entry["what"][:_MAX_EXTRACT_STR], "why": entry["why"][:_MAX_EXTRACT_STR]}
-                for entry in action_items[:_MAX_EXTRACT_LIST]
-            ],
-            "risks": [entry[:_MAX_EXTRACT_STR] for entry in risks[:_MAX_EXTRACT_LIST]],
-        }
+        limit = self._brief_max_chars()
+        if len(cleaned) > limit:
+            logger.warning("truncating brief from %d to %d chars", len(cleaned), limit)
+            cleaned = cleaned[:limit]
+        return cleaned
+
+    def should_update_brief(self, signals) -> bool:
+        """Fail-open gate on the substantive_update noul arm."""
+        if not isinstance(signals, dict):
+            return True
+        items = signals.get("items", {})
+        if not isinstance(items, dict):
+            return True
+        item = items.get("substantive_update")
+        if not isinstance(item, dict):
+            return True
+        prob = _number(item.get("p"))
+        if prob is None:
+            return True
+        return prob >= self._brief_gate_threshold()
+
+    def finalize_brief(self, text, finish_reason) -> str | None:
+        """Accept any parseable brief; a length cutoff truncates, never rejects."""
+        if finish_reason == "length":
+            logger.warning("brief reply hit the length limit; keeping the truncated text")
+        return self.parse_brief(text)
 
     def state(self) -> tuple[str, str]:
         """Return the cached (state, reason) pair; performs no I/O."""

+ 26 - 2
src/stt_daemon/assistant/triage_dump.py

@@ -39,6 +39,13 @@ class TriageDumper:
         self._directory = str(directory)
         self._lock = threading.Lock()
         self._pending: dict[float, dict] = {}
+        # Monotonic tie-breaker for same-millisecond collisions: the first
+        # record for a millisecond keeps the exact `HHMMSS-mmm.json` shape,
+        # later records in the same millisecond get a `-N` suffix so they
+        # never overwrite the earlier file.
+        self._name_lock = threading.Lock()
+        self._last_stamp: str | None = None
+        self._stamp_seq = 0
 
     @property
     def enabled(self) -> bool:
@@ -99,6 +106,23 @@ class TriageDumper:
             else:
                 record[key] = value
 
+    def _reserve_path(self, day_dir: Path, stamp: str) -> Path:
+        """Pick a collision-free path for `stamp` (monotonic tie-breaker)."""
+        key = f"{day_dir.name} {stamp}"
+        with self._name_lock:
+            if key == self._last_stamp:
+                self._stamp_seq += 1
+            else:
+                self._last_stamp = key
+                self._stamp_seq = 0
+            seq = self._stamp_seq
+            path = day_dir / (f"{stamp}.json" if seq == 0 else f"{stamp}-{seq}.json")
+            while path.exists():
+                seq += 1
+                path = day_dir / f"{stamp}-{seq}.json"
+            self._stamp_seq = seq
+            return path
+
     def _write(self, record: dict) -> None:
         try:
             ts_raw = record.get("ts")
@@ -108,8 +132,8 @@ class TriageDumper:
             dt = datetime.fromtimestamp(ts)
             day_dir = Path(self._directory) / dt.strftime("%Y-%m-%d")
             day_dir.mkdir(parents=True, exist_ok=True)
-            filename = dt.strftime("%H%M%S") + "-" + dt.strftime("%f")[:3] + ".json"
-            final_path = day_dir / filename
+            stamp = dt.strftime("%H%M%S") + "-" + dt.strftime("%f")[:3]
+            final_path = self._reserve_path(day_dir, stamp)
             payload = {
                 "ts": ts,
                 "iso": dt.astimezone().isoformat(),

+ 72 - 6
src/stt_daemon/config.py

@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
 import logging
 import tomllib
 from pathlib import Path
+from typing import Any
 
 logger = logging.getLogger(__name__)
 
@@ -20,6 +21,7 @@ KNOWN_SECTIONS = {
     "llm",
     "triage",
     "speaker",
+    "brief",
 }
 
 
@@ -122,6 +124,8 @@ class LlmConfig:
     max_tokens: int = 1024
     timeout_s: float = 120.0
     cache_prompt: bool = True
+    chat_slot_id: int | None = None
+    command_slot_id: int | None = None
     max_context_tokens: int = 131072
     compaction_safety_margin: int = 256
     compaction_enabled: bool = True
@@ -134,12 +138,19 @@ class LlmConfig:
         "You compress a conversation. Preserve names, numbers, decisions, user "
         "preferences, and unresolved questions. Omit pleasantries. Output only the summary."
     )
+    brief_compaction_system_prompt: str = (
+        "You compress a triage briefing conversation. Preserve every opportunity, blocker, "
+        "owner, responsibility, decision, deadline, and open question; drop pleasantries. "
+        "Output only the summary."
+    )
     triage_system_prompt: str = (
-        "You extract structured signals from a spoken conversation. Output only a JSON "
-        "object with exactly these keys: "
-        '{"summary": str, "opportunities": [str], "doubts": [str], '
-        '"action_items": [{"what": str, "why": str}], "risks": [str]}. '
-        "Output no prose and no markdown fences. Use empty lists when a category is empty."
+        "You maintain a living brief of a business conversation. Given the current brief "
+        "(your previous reply, if any) and a new transcript segment, return the UPDATED "
+        "brief as plain text only, with no markdown fences and no JSON. Preserve prior "
+        "content unless the new segment contradicts it; merge duplicates; prune stale "
+        "items; keep owners, responsibilities, and deadlines explicit. Use short sections: "
+        "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
+        "Deadlines, Open Questions."
     )
     inference_queue_size: int = 8
     trim_block_turns: int = 4
@@ -169,8 +180,13 @@ class TriageConfig:
     escalate_threshold: float = 0.6
     choice_min_confidence: float = 0.85
     extract_enabled: bool = True
-    extract_max_tokens: int = 512
+    extract_max_tokens: int = 1536
     extract_timeout_s: float = 30.0
+    brief_slot_id: int | None = None
+    brief_gate_enabled: bool = True
+    brief_gate_threshold: float = 0.5
+    brief_max_chars: int = 4000
+    brief_pending_char_budget: int = 3000
     device: str = ""
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     default_checkpoint: str = "multilingual"
@@ -196,6 +212,14 @@ class SpeakerConfig:
     join_timeout_s: float = 2.0
 
 
+@dataclass
+class BriefConfig:
+    """Durable brief document configuration (markdown snapshot written to disk)."""
+    write: bool = False
+    dir: str = "brief"
+    filename: str = "brief.md"
+
+
 @dataclass
 class SinkConfig:
     """Virtual audio sink configuration."""
@@ -220,6 +244,21 @@ class Config:
     llm: LlmConfig = field(default_factory=LlmConfig)
     triage: TriageConfig = field(default_factory=TriageConfig)
     speaker: SpeakerConfig = field(default_factory=SpeakerConfig)
+    brief: BriefConfig = field(default_factory=BriefConfig)
+
+
+def _optional_int(value: Any) -> int | None:
+    """Coerce an optional TOML value to int, degrading to None on bad input.
+
+    TOML has no null, so an absent key is "unset" and reaches this helper as
+    ``None``. ``0`` is a valid slot id and must never be mistaken for unset.
+    """
+    if value is None:
+        return None
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
 
 
 def load_config(path: str | None = None) -> Config:
@@ -419,6 +458,8 @@ def load_config(path: str | None = None) -> Config:
                 max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
                 timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
                 cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
+                chat_slot_id=_optional_int(llm_data.get("chat_slot_id")),
+                command_slot_id=_optional_int(llm_data.get("command_slot_id")),
                 max_context_tokens=llm_data.get(
                     "max_context_tokens", cfg.llm.max_context_tokens
                 ),
@@ -446,6 +487,9 @@ def load_config(path: str | None = None) -> Config:
                 compaction_system_prompt=llm_data.get(
                     "compaction_system_prompt", cfg.llm.compaction_system_prompt
                 ),
+                brief_compaction_system_prompt=llm_data.get(
+                    "brief_compaction_system_prompt", cfg.llm.brief_compaction_system_prompt
+                ),
                 triage_system_prompt=llm_data.get(
                     "triage_system_prompt", cfg.llm.triage_system_prompt
                 ),
@@ -497,6 +541,19 @@ def load_config(path: str | None = None) -> Config:
                 extract_timeout_s=triage_data.get(
                     "extract_timeout_s", cfg.triage.extract_timeout_s
                 ),
+                brief_slot_id=_optional_int(triage_data.get("brief_slot_id")),
+                brief_gate_enabled=triage_data.get(
+                    "brief_gate_enabled", cfg.triage.brief_gate_enabled
+                ),
+                brief_gate_threshold=triage_data.get(
+                    "brief_gate_threshold", cfg.triage.brief_gate_threshold
+                ),
+                brief_max_chars=triage_data.get(
+                    "brief_max_chars", cfg.triage.brief_max_chars
+                ),
+                brief_pending_char_budget=triage_data.get(
+                    "brief_pending_char_budget", cfg.triage.brief_pending_char_budget
+                ),
                 device=triage_data.get("device", cfg.triage.device),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 default_checkpoint=triage_data.get(
@@ -536,4 +593,13 @@ def load_config(path: str | None = None) -> Config:
                 join_timeout_s=speaker_data.get("join_timeout_s", cfg.speaker.join_timeout_s),
             )
 
+        # Brief section
+        if "brief" in data:
+            brief_data = data["brief"]
+            cfg.brief = BriefConfig(
+                write=brief_data.get("write", cfg.brief.write),
+                dir=brief_data.get("dir", cfg.brief.dir),
+                filename=brief_data.get("filename", cfg.brief.filename),
+            )
+
     return cfg

+ 9 - 2
src/stt_daemon/tui/app.py

@@ -74,6 +74,7 @@ class AssistantTUI(App):
         self._timer = None
         self._last_seg_title = ""
         self._last_inf_title = ""
+        self._last_triage_title = ""
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
@@ -89,8 +90,6 @@ class AssistantTUI(App):
 
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
-        if self.triage_enabled:
-            self.query_one("#triage", RichLog).border_title = self._triage_title()
         self._update_titles(self.state)
 
     def _triage_title(self) -> str:
@@ -108,6 +107,14 @@ class AssistantTUI(App):
             self.query_one("#chat", RichLog).border_title = inf_title
             self._last_inf_title = inf_title
 
+        if self.triage_enabled:
+            triage_title = self._triage_title()
+            if not self._show_questions:
+                triage_title += f"  [inf {state.inference_depth}{inf_suffix}]"
+            if triage_title != self._last_triage_title:
+                self.query_one("#triage", RichLog).border_title = triage_title
+                self._last_triage_title = triage_title
+
     def on_mount(self) -> None:
         self._set_titles()
         try:

+ 4 - 13
src/stt_daemon/tui/state.py

@@ -320,19 +320,10 @@ class ViewState:
                     line = f"{qid}{mark}" if flagged else str(qid)
                 lines.append(line)
 
-        content = self.triage.get("content")
-        if isinstance(content, dict):
-            if lines:
-                lines.append("")
-            summary = content.get("summary")
-            if summary:
-                lines.append(f"summary: {summary}")
-            for key in ("opportunities", "risks", "action_items"):
-                values = content.get(key)
-                if not isinstance(values, list):
-                    continue
-                for value in values[:3]:
-                    lines.append(f"{key[:-1]}: {value}")
+        brief = self.triage.get("brief")
+        if isinstance(brief, str) and brief:
+            lines.append("")
+            lines.extend(brief.splitlines())
 
         if not lines:
             return ["triage: no signals"]

+ 581 - 13
tests/test_assistant_integration.py

@@ -13,6 +13,9 @@ from types import SimpleNamespace
 import pytest
 
 from stt_daemon import events
+from stt_daemon.assistant.brief_file import BriefFile
+from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
+from stt_daemon.assistant.llm import ConversationEngine
 from stt_daemon.assistant.runner import build_triage
 from stt_daemon.assistant.service import AssistantService
 from stt_daemon.bus import EventPublisher, EventSubscriber
@@ -110,23 +113,24 @@ class _FakeChatClient:
     def __init__(self):
         self.calls = []
 
-    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None):
+    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, slot_id=None):
         self.calls.append(
             {
                 "messages": messages,
                 "tools": tools,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
+                "slot_id": slot_id,
             }
         )
-        return SimpleNamespace(content="EXTRACTED")
+        return SimpleNamespace(content="EXTRACTED", finish_reason="stop")
 
 
 def test_build_triage_disabled_returns_nones():
     cfg = Config()
     assert cfg.triage.enabled is False
-    assert build_triage(cfg, None) == (None, None, None)
-    assert build_triage(cfg, _FakeChatClient()) == (None, None, None)
+    assert build_triage(cfg, None) == (None, None, None, None)
+    assert build_triage(cfg, _FakeChatClient()) == (None, None, None, None)
     assert "laya" not in sys.modules
     service = AssistantService(cfg, subscriber=object())
     assert service.status()["triage_state"] == "disabled"
@@ -135,10 +139,11 @@ def test_build_triage_disabled_returns_nones():
 def test_build_triage_enabled_constructs_without_client(tmp_path):
     cfg = _enabled_triage_cfg(tmp_path)
     before = len(threading.enumerate())
-    analyzer, lane, extract_fn = build_triage(cfg, None)
+    analyzer, lane, brief_fn, brief_reset = build_triage(cfg, None)
     assert analyzer is not None
     assert lane is not None
-    assert extract_fn is None
+    assert brief_fn is None
+    assert brief_reset is None
     assert analyzer._cfg is cfg.triage
     assert lane._cfg is cfg.triage
     assert analyzer.state() == ("loading", "")
@@ -146,12 +151,15 @@ def test_build_triage_enabled_constructs_without_client(tmp_path):
     assert "laya" not in sys.modules
 
 
-def test_build_triage_extract_fn_calls_client(tmp_path):
+def test_build_triage_brief_fn_calls_client(tmp_path):
     cfg = _enabled_triage_cfg(tmp_path)
     client = _FakeChatClient()
-    _, _, extract_fn = build_triage(cfg, client)
-    assert extract_fn is not None
-    assert extract_fn("PROMPT") == "EXTRACTED"
+    _, _, brief_fn, brief_reset = build_triage(cfg, client)
+    assert brief_fn is not None
+    assert brief_reset is not None
+    assert callable(brief_fn)
+    assert callable(brief_reset)
+    assert brief_fn("PROMPT") == "EXTRACTED"
     assert len(client.calls) == 1
     call = client.calls[0]
     assert call["messages"] == [
@@ -160,6 +168,17 @@ def test_build_triage_extract_fn_calls_client(tmp_path):
     ]
     assert call["max_tokens"] == cfg.triage.extract_max_tokens
     assert call["timeout_s"] == cfg.triage.extract_timeout_s
+    assert call["slot_id"] is None
+
+
+def test_build_triage_brief_fn_slot_only_when_configured(tmp_path):
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.triage.brief_slot_id = 2
+    client = _FakeChatClient()
+    _, _, brief_fn, _ = build_triage(cfg, client)
+    assert brief_fn is not None
+    brief_fn("PROMPT")
+    assert client.calls[0]["slot_id"] == 2
 
 
 def test_build_triage_on_state_refreshes_analyzer(tmp_path):
@@ -167,7 +186,7 @@ def test_build_triage_on_state_refreshes_analyzer(tmp_path):
 
     cfg = _enabled_triage_cfg(tmp_path)
     sentinel = object()
-    analyzer, lane, _ = build_triage(cfg, None, on_result=sentinel)
+    analyzer, lane, _, _ = build_triage(cfg, None, on_result=sentinel)
     assert analyzer is not None
     assert lane is not None
     assert lane._on_result is sentinel
@@ -271,7 +290,8 @@ def test_runner_main_shutdown_order_lane_before_control(tmp_path, monkeypatch):
     assert made_lanes[0].cfg is cfg.triage
     assert made_services[0].kwargs["laya_lane"] is made_lanes[0]
     assert made_services[0].kwargs["triage"] is not None
-    assert made_services[0].kwargs["triage_extract_fn"] is None
+    assert made_services[0].kwargs["brief_fn"] is None
+    assert made_services[0].kwargs["brief_reset"] is None
     assert calls.index("control.start") < calls.index("service.start")
     assert calls.index("service.start") < calls.index("lane.start")
     assert calls.index("service.stop") < calls.index("lane.stop")
@@ -293,5 +313,553 @@ def test_runner_main_disabled_builds_no_lane(monkeypatch):
     assert "lane.stop" not in calls
     assert made_services[0].kwargs["triage"] is None
     assert made_services[0].kwargs["laya_lane"] is None
-    assert made_services[0].kwargs["triage_extract_fn"] is None
+    assert made_services[0].kwargs["brief_fn"] is None
+    assert made_services[0].kwargs["brief_reset"] is None
     assert not [t for t in threading.enumerate() if t.name.startswith("laya-")]
+
+
+def _patch_llm_client(monkeypatch, calls):
+    from stt_daemon.assistant import runner as runner_mod
+
+    class FakeLlmClient:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, **kwargs):
+            calls.append({"messages": messages, "kwargs": kwargs})
+            return SimpleNamespace(content="hi there", finish_reason="stop")
+
+    monkeypatch.setattr(runner_mod, "LlmClient", FakeLlmClient)
+
+
+def test_runner_wires_chat_engine_and_command_slot(monkeypatch):
+    """Runner builds the chat conversation from ConversationEngine with the chat
+    slot, and gives the command executor the configured command slot."""
+    cfg = Config()
+    cfg.audit.enabled = False
+    cfg.llm.enabled = True
+    cfg.llm.chat_slot_id = 7
+    cfg.llm.command_slot_id = 3
+
+    calls: list[dict] = []
+    _patch_llm_client(monkeypatch, calls)
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    kwargs = made_services[0].kwargs
+    engine = kwargs["chat_fn"].__self__
+    assert engine.system == cfg.llm.system_prompt_chat
+    assert engine._slot_id == 7
+    assert kwargs["executor"].slot_id == 3
+    assert kwargs["chat_fn"]("hello") == "hi there"
+    assert calls[0]["kwargs"] == {"slot_id": 7}
+
+
+def test_runner_wires_slotless_engine_when_slots_unset(monkeypatch):
+    """Unset slot config leaves both lanes slot-free (getattr fallback)."""
+    cfg = Config()
+    cfg.audit.enabled = False
+    cfg.llm.enabled = True
+    assert cfg.llm.chat_slot_id is None
+    assert cfg.llm.command_slot_id is None
+
+    calls: list[dict] = []
+    _patch_llm_client(monkeypatch, calls)
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    kwargs = made_services[0].kwargs
+    assert kwargs["executor"].slot_id is None
+    assert kwargs["chat_fn"].__self__._slot_id is None
+    assert kwargs["chat_fn"]("hello") == "hi there"
+    assert calls[0]["kwargs"] == {}
+
+
+def test_runner_wires_brief_engine_slot_when_configured(monkeypatch, tmp_path):
+    """The triage brief engine sends `id_slot` only when `brief_slot_id` is set."""
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.llm.enabled = True
+    cfg.triage.brief_slot_id = 5
+
+    calls: list[dict] = []
+    _patch_llm_client(monkeypatch, calls)
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    brief_fn = made_services[0].kwargs["brief_fn"]
+    assert brief_fn is not None
+    assert brief_fn("BRIEF-PROMPT") == "hi there"
+    assert calls[0]["kwargs"] == {"slot_id": 5}
+
+
+def test_runner_wires_slotless_brief_engine_when_slot_unset(monkeypatch, tmp_path):
+    """An unset `brief_slot_id` leaves the brief engine slot-free (`id_slot` absent)."""
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.llm.enabled = True
+    assert cfg.triage.brief_slot_id is None
+
+    calls: list[dict] = []
+    _patch_llm_client(monkeypatch, calls)
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    brief_fn = made_services[0].kwargs["brief_fn"]
+    assert brief_fn is not None
+    assert brief_fn("BRIEF-PROMPT") == "hi there"
+    assert calls[0]["kwargs"] == {}
+
+
+def test_runner_brief_file_writes_when_enabled(monkeypatch, tmp_path):
+    """`[brief].write=true` makes the runner pass a live, durable writer."""
+    from stt_daemon.assistant.brief_file import BriefFile
+
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.brief.write = True
+    cfg.brief.dir = str(tmp_path / "brief")
+    cfg.brief.filename = "b.md"
+
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    brief_file = made_services[0].kwargs["brief_file"]
+    assert isinstance(brief_file, BriefFile)
+    assert brief_file.write_enabled is True
+    brief_file.write("latest brief")
+    path = os.path.join(cfg.brief.dir, "b.md")
+    with open(path, encoding="utf-8") as handle:
+        assert handle.read() == "latest brief"
+
+
+def test_runner_brief_file_disabled_writes_nothing(monkeypatch, tmp_path):
+    """The default (`[brief].write=false`) writer is present but creates no file."""
+    from stt_daemon.assistant.brief_file import BriefFile
+
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.brief.write = False
+    cfg.brief.dir = str(tmp_path / "brief")
+
+    rc, _, _, made_services = _run_runner_main(monkeypatch, cfg)
+
+    assert rc == 0
+    brief_file = made_services[0].kwargs["brief_file"]
+    assert isinstance(brief_file, BriefFile)
+    assert brief_file.write_enabled is False
+    brief_file.write("nope")
+    assert not os.path.exists(os.path.join(cfg.brief.dir, cfg.brief.filename))
+
+
+# ---------------------------------------------------------------------------
+# End-to-end dual-engine coverage (chat + triage on ONE inference queue).
+#
+# These tests build the REAL `AssistantService` plus the real `build_triage`
+# wiring (real `TriageAnalyzer`, real `ConversationEngine` brief engine, real
+# `InferenceQueue`, real `BriefFile`) and only fake the LLM client and the bus.
+# Laya results are delivered as fake payloads through the service's documented
+# `_on_triage_result` seam; the queued jobs are then driven for real via
+# `job.run()` (the queue's worker body).
+# ---------------------------------------------------------------------------
+
+
+class _Clock:
+    """Deterministic clock for the integration harness."""
+
+    def __init__(self, start: float = 1000.0) -> None:
+        self.t = float(start)
+
+    def __call__(self) -> float:
+        return self.t
+
+    def advance(self, dt: float) -> None:
+        self.t += float(dt)
+
+
+class _SilentSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self) -> None:
+        pass
+
+
+class _EngineClient:
+    """Recording LLM client: brief replies differ from chat replies.
+
+    Accepts `slot_id` like the real client and always returns a `finish_reason`
+    (the brief engine reads it). `brief_error` makes the brief call raise, which
+    the real `_run_brief_update` must absorb.
+    """
+
+    def __init__(
+        self, *, brief_reply: str = "BRIEF", brief_error: bool = False,
+        chat_reply: str = "chat reply",
+    ) -> None:
+        self.calls: list[dict] = []
+        self.brief_reply = brief_reply
+        self.brief_error = brief_error
+        self.chat_reply = chat_reply
+        self.brief_system: str | None = None
+
+    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, slot_id=None):
+        system = messages[0].get("content") if messages else ""
+        is_brief = self.brief_system is not None and system == self.brief_system
+        self.calls.append(
+            {
+                "is_brief": is_brief,
+                "system": system,
+                "user": messages[-1].get("content") if messages else "",
+                "slot_id": slot_id,
+                "max_tokens": max_tokens,
+                "timeout_s": timeout_s,
+            }
+        )
+        if is_brief and self.brief_error:
+            raise RuntimeError("llm down")
+        content = self.brief_reply if is_brief else self.chat_reply
+        return SimpleNamespace(content=content, finish_reason="stop")
+
+    def brief_calls(self) -> list[dict]:
+        return [call for call in self.calls if call["is_brief"]]
+
+    def chat_calls(self) -> list[dict]:
+        return [call for call in self.calls if not call["is_brief"]]
+
+
+def _laya_payload() -> dict:
+    """A realistic fake Laya result for the `urgent` noul question."""
+    return {"answers": {"urgent": {"noul": 0.9, "confidence": 0.9, "answer_confidence": 0.9}}}
+
+
+def _engine_service(
+    tmp_path,
+    client,
+    *,
+    chat_slot=None,
+    brief_slot=None,
+    brief_write: bool = False,
+):
+    """Build the real service + `build_triage` wiring over a fake client."""
+    cfg = _enabled_triage_cfg(tmp_path)
+    cfg.llm.enabled = True
+    cfg.assistant.turn_gap_s = 0.5
+    cfg.triage.brief_gate_enabled = False  # deterministic: dispatch every turn
+    cfg.triage.brief_slot_id = brief_slot
+    cfg.llm.chat_slot_id = chat_slot
+    cfg.brief.write = brief_write
+    cfg.brief.dir = str(tmp_path / "brief")
+    cfg.brief.filename = "brief.md"
+    client.brief_system = cfg.llm.triage_system_prompt
+
+    analyzer, lane, brief_fn, brief_reset = build_triage(cfg, client)
+    chat_engine = ConversationEngine(
+        cfg.llm,
+        client,
+        system_prompt=cfg.llm.system_prompt_chat,
+        slot_id=chat_slot,
+    )
+    emitted: list[dict] = []
+    clock = _Clock()
+    brief_file = BriefFile(cfg.brief.write, cfg.brief.dir, cfg.brief.filename)
+    service = AssistantService(
+        cfg,
+        _SilentSubscriber(),
+        emit=lambda type_, **fields: emitted.append({"type": type_, **fields}),
+        time_fn=clock,
+        triage=analyzer,
+        laya_lane=lane,
+        brief_fn=brief_fn,
+        brief_reset=brief_reset,
+        brief_file=brief_file,
+        chat_fn=chat_engine.send,
+        chat_reset=chat_engine.reset,
+    )
+    return SimpleNamespace(
+        service=service,
+        emitted=emitted,
+        clock=clock,
+        analyzer=analyzer,
+        lane=lane,
+        chat_engine=chat_engine,
+        brief_file=brief_file,
+        cfg=cfg,
+        client=client,
+    )
+
+
+def _transcript(clock, text):
+    return {"v": 1, "type": events.TRANSCRIPT, "seq": 1, "ts": clock(), "text": text}
+
+
+def _triage_events(emitted):
+    return [event for event in emitted if event["type"] == "triage"]
+
+
+def _stages(emitted):
+    return [event.get("stage") for event in _triage_events(emitted)]
+
+
+def _brief_jobs(service):
+    return [job for job in service._inference._queue if job.name == "brief-update"]
+
+
+def _chat_jobs(service):
+    return [job for job in service._inference._queue if job.name == "chat"]
+
+
+def _run_queued(service, job):
+    """Pop a queued job (mirroring the worker) and run it."""
+    service._inference._queue.remove(job)
+    job.run()
+
+
+def _settle(service, clock, text):
+    service.handle_event(_transcript(clock, text))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+
+
+def _deliver(service, payload):
+    service._on_triage_result(service._triage_generation, payload)
+
+
+def test_two_engines_emit_signals_then_brief_and_chat_works(tmp_path):
+    """(a) signals then content.brief; (b) chat works while triage is active;
+    (c) each engine carries id_slot only when set."""
+    client = _EngineClient(brief_reply="E2E BRIEF")
+    h = _engine_service(tmp_path, client, chat_slot=7, brief_slot=5)
+
+    assert h.service.toggle_chat() is True
+    _settle(h.service, h.clock, "first thought")
+    h.service.tick()  # dispatches the chat turn through the real queue
+
+    _deliver(h.service, _laya_payload())
+    assert _stages(h.emitted) == ["signals"]
+    assert h.service.last_triage is not None
+    assert "brief" not in h.service.last_triage
+
+    chat_jobs = _chat_jobs(h.service)
+    assert len(chat_jobs) == 1
+    _run_queued(h.service, chat_jobs[0])  # chat works while triage is active
+    assert h.service.last_triage is not None  # triage still owns its state
+    assert any(
+        event["type"] == "chat_message"
+        and event.get("role") == "assistant"
+        and event.get("text") == "chat reply"
+        for event in h.emitted
+    )
+
+    brief_jobs = _brief_jobs(h.service)
+    assert len(brief_jobs) == 1
+    _run_queued(h.service, brief_jobs[0])
+
+    assert _stages(h.emitted) == ["signals", "content"]
+    contents = [event for event in _triage_events(h.emitted) if event.get("stage") == "content"]
+    assert contents[0]["triage"]["brief"] == "E2E BRIEF"
+    assert h.service.last_triage["brief"] == "E2E BRIEF"
+
+    assert [call["slot_id"] for call in client.chat_calls()] == [7]
+    assert [call["slot_id"] for call in client.brief_calls()] == [5]
+    assert client.brief_calls()[0]["user"].startswith("New utterances:")
+
+
+def test_slotless_engines_omit_id_slot(tmp_path):
+    """(c) unset slots leave `id_slot` off both engines' calls."""
+    client = _EngineClient(brief_reply="BRIEF")
+    h = _engine_service(tmp_path, client, chat_slot=None, brief_slot=None)
+
+    assert h.service.toggle_chat() is True
+    _settle(h.service, h.clock, "hello")
+    h.service.tick()
+    _deliver(h.service, _laya_payload())
+    _run_queued(h.service, _chat_jobs(h.service)[0])
+    _run_queued(h.service, _brief_jobs(h.service)[0])
+
+    assert [call["slot_id"] for call in client.chat_calls()] == [None]
+    assert [call["slot_id"] for call in client.brief_calls()] == [None]
+
+
+def test_refused_brief_keeps_deltas_and_next_dispatch_delivers(tmp_path):
+    """(d) a full queue refuses the brief without losing pending deltas, and the
+    next dispatch delivers them all."""
+    client = _EngineClient(brief_reply="BRIEF")
+    h = _engine_service(tmp_path, client)
+    service = h.service
+    service._inference = InferenceQueue(size=2)
+    service._inference.submit(InferenceJob(name="blocker", run=lambda: None, evictable=False))
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+
+    # Refused: no brief job admitted, but the delta is buffered and the flag clear.
+    assert _brief_jobs(service) == []
+    assert service._pending_brief == ["Unknown: first thought"]
+    assert service._brief_outstanding is False
+    assert _stages(h.emitted) == ["signals"]
+
+    # Free room; the next settled turn re-dispatches and consumes both deltas.
+    service._inference._queue.clear()
+    _settle(service, h.clock, "second thought")
+    _deliver(service, _laya_payload())
+    jobs = _brief_jobs(service)
+    assert len(jobs) == 1
+    assert service._pending_brief == ["Unknown: first thought", "Unknown: second thought"]
+
+    _run_queued(service, jobs[0])
+
+    assert _stages(h.emitted) == ["signals", "signals", "content"]
+    prompt = client.brief_calls()[-1]["user"]
+    assert "- Unknown: first thought" in prompt
+    assert "- Unknown: second thought" in prompt
+    assert service._pending_brief == []
+
+
+def test_empty_brief_requeues_deltas_and_clears_flag(tmp_path):
+    """(e) a `""` brief re-queues the deltas and clears the outstanding flag."""
+    client = _EngineClient(brief_reply="")
+    h = _engine_service(tmp_path, client)
+    service = h.service
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+    _run_queued(service, _brief_jobs(service)[0])
+
+    assert _stages(h.emitted) == ["signals"]  # no content emitted
+    assert service._pending_brief == ["Unknown: first thought"]
+    assert service._brief_outstanding is False
+    assert _brief_jobs(service) == []  # requeued, not flushed
+
+
+def test_raising_brief_requeues_deltas_and_clears_flag(tmp_path):
+    """(e) a raising brief leaves no stranded flag and re-buffers the deltas."""
+    client = _EngineClient(brief_error=True)
+    h = _engine_service(tmp_path, client)
+    service = h.service
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+    _run_queued(service, _brief_jobs(service)[0])
+
+    assert _stages(h.emitted) == ["signals"]
+    assert service._pending_brief == ["Unknown: first thought"]
+    assert service._brief_outstanding is False
+    assert _brief_jobs(service) == []
+
+
+def test_none_laya_payload_does_not_dispatch(tmp_path):
+    """(f) a None Laya payload stores nothing and never dispatches the brief."""
+    client = _EngineClient()
+    h = _engine_service(tmp_path, client)
+    service = h.service
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, None)
+
+    assert _triage_events(h.emitted) == []
+    assert service.last_triage is None
+    assert _brief_jobs(service) == []
+    assert service._pending_brief == ["Unknown: first thought"]
+    assert client.calls == []
+
+
+def test_clear_resets_both_engines_and_stale_job_cannot_clobber(tmp_path):
+    """(g) clear resets chat + triage, suppresses stale emits, and a stale job
+    cannot clear a newer dispatch's flag."""
+    client = _EngineClient()
+    h = _engine_service(tmp_path, client)
+    service = h.service
+
+    assert service.toggle_chat() is True
+    _settle(service, h.clock, "hello")
+    service.tick()
+    _run_queued(service, _chat_jobs(service)[0])
+    assert h.chat_engine.history != []
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+    stale_job = _brief_jobs(service)[0]
+    assert service._brief_outstanding is True
+
+    service.clear()
+
+    # Both engines reset.
+    assert h.chat_engine.history == []
+    assert service.chat_history == []
+    assert service._triage_generation == 1
+    assert service.last_triage is None
+    assert service.triage_history == []
+    assert service._pending_brief == []
+    assert service._brief_outstanding is False
+    assert service._last_brief_signals is None
+    assert _brief_jobs(service) == []
+    assert "cleared" in _stages(h.emitted)
+    assert {"type": "cleared"} in h.emitted
+
+    h.emitted.clear()
+    stale_job.run()  # clear() already dropped it from the queue
+    assert _triage_events(h.emitted) == []
+    assert service._pending_brief == []
+    assert service.last_triage is None
+
+    # A newer dispatch after clear owns the flag; the stale job cannot clear it.
+    _settle(service, h.clock, "fresh")
+    _deliver(service, _laya_payload())
+    newer_job = _brief_jobs(service)[0]
+    newer_token = service._brief_token
+    pending_after = list(service._pending_brief)
+    triage_after = service.last_triage
+    assert service._brief_outstanding is True
+
+    h.emitted.clear()
+    stale_job.run()
+    assert _triage_events(h.emitted) == []
+    assert service._brief_outstanding is True
+    assert service._brief_token == newer_token
+    assert service._pending_brief == pending_after
+    assert service.last_triage is triage_after
+
+    _run_queued(service, newer_job)
+    assert _stages(h.emitted) == ["content"]
+    assert service._brief_outstanding is False
+
+
+def test_durable_brief_file_holds_latest_and_clears(tmp_path):
+    """(h) with `[brief].write=true` the file holds the exact latest brief and
+    is empty after `clear`."""
+    client = _EngineClient(brief_reply="FIRST BRIEF")
+    h = _engine_service(tmp_path, client, brief_write=True)
+    service = h.service
+    path = os.path.join(h.cfg.brief.dir, h.cfg.brief.filename)
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+    _run_queued(service, _brief_jobs(service)[0])
+    with open(path, encoding="utf-8") as handle:
+        assert handle.read() == "FIRST BRIEF"
+
+    client.brief_reply = "LATEST BRIEF"
+    _settle(service, h.clock, "second thought")
+    _deliver(service, _laya_payload())
+    _run_queued(service, _brief_jobs(service)[0])
+    with open(path, encoding="utf-8") as handle:
+        assert handle.read() == "LATEST BRIEF"
+    assert service.last_triage["brief"] == "LATEST BRIEF"
+
+    service.clear()
+    assert os.path.exists(path)
+    with open(path, encoding="utf-8") as handle:
+        assert handle.read() == ""
+
+
+def test_disabled_brief_file_absent_but_panel_updates(tmp_path):
+    """(h) with `[brief].write=false` no file appears while the panel brief still
+    updates."""
+    client = _EngineClient(brief_reply="PANEL BRIEF")
+    h = _engine_service(tmp_path, client, brief_write=False)
+    service = h.service
+    path = os.path.join(h.cfg.brief.dir, h.cfg.brief.filename)
+
+    _settle(service, h.clock, "first thought")
+    _deliver(service, _laya_payload())
+    _run_queued(service, _brief_jobs(service)[0])
+
+    assert h.brief_file.write_enabled is False
+    assert not os.path.exists(path)
+    assert service.last_triage["brief"] == "PANEL BRIEF"

Plik diff jest za duży
+ 551 - 119
tests/test_assistant_service.py


+ 67 - 0
tests/test_brief_file.py

@@ -0,0 +1,67 @@
+"""Tests for the atomic best-effort durable brief writer (BriefFile)."""
+
+import os
+
+from stt_daemon.assistant.brief_file import BriefFile
+
+
+def _path(tmp_path):
+    return tmp_path / "brief.md"
+
+
+def _tmps(tmp_path):
+    return [p.name for p in tmp_path.iterdir() if p.suffix == ".tmp"]
+
+
+def test_write_enabled_property(tmp_path):
+    assert BriefFile(True, str(tmp_path)).write_enabled is True
+    assert BriefFile(False, str(tmp_path)).write_enabled is False
+
+
+def test_disabled_write_and_clear_create_no_file(tmp_path):
+    # misleading-success probe: disabled means the path must never appear.
+    brief = BriefFile(False, str(tmp_path))
+    brief.write("hello")
+    assert not _path(tmp_path).exists()
+    brief.clear()
+    assert not _path(tmp_path).exists()
+    assert not any(tmp_path.iterdir())
+
+
+def test_enabled_write_writes_exact_text(tmp_path):
+    brief = BriefFile(True, str(tmp_path))
+    brief.write("hello")
+    assert _path(tmp_path).read_text(encoding="utf-8") == "hello"
+    assert _path(tmp_path).read_bytes() == b"hello"
+    assert _tmps(tmp_path) == []
+
+
+def test_second_write_replaces_atomically_no_tmp(tmp_path):
+    # stale-state probe: only the newest text survives, no .tmp leaks.
+    brief = BriefFile(True, str(tmp_path))
+    brief.write("first")
+    brief.write("second")
+    assert _path(tmp_path).read_text(encoding="utf-8") == "second"
+    assert _tmps(tmp_path) == []
+
+
+def test_clear_empties_file(tmp_path):
+    brief = BriefFile(True, str(tmp_path))
+    brief.write("content")
+    brief.clear()
+    assert _path(tmp_path).read_text(encoding="utf-8") == ""
+    assert _path(tmp_path).exists()
+
+
+def test_write_failure_path_swallowed_no_raise(tmp_path):
+    # failure probe: parent is a regular file, so makedirs/open fail -> swallow.
+    blocker = tmp_path / "blocker"
+    blocker.write_text("x", encoding="utf-8")
+    brief = BriefFile(True, str(blocker / "nested"))
+    brief.write("hello")  # must not raise
+    assert not os.path.exists(str(blocker / "nested"))
+
+
+def test_clear_failure_path_swallowed_no_raise(tmp_path):
+    brief = BriefFile(True, str(tmp_path / "missing"))
+    brief.clear()  # missing dir -> open fails -> swallowed, no raise

+ 170 - 1
tests/test_config.py

@@ -531,8 +531,13 @@ def test_triage_defaults():
     assert cfg.triage.escalate_threshold == 0.6
     assert cfg.triage.choice_min_confidence == 0.85
     assert cfg.triage.extract_enabled is True
-    assert cfg.triage.extract_max_tokens == 512
+    assert cfg.triage.extract_max_tokens == 1536
     assert cfg.triage.extract_timeout_s == 30.0
+    assert cfg.triage.brief_slot_id is None
+    assert cfg.triage.brief_gate_enabled is True
+    assert cfg.triage.brief_gate_threshold == 0.5
+    assert cfg.triage.brief_max_chars == 4000
+    assert cfg.triage.brief_pending_char_budget == 3000
     assert cfg.triage.device == ""
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.default_checkpoint == "multilingual"
@@ -579,6 +584,170 @@ default_checkpoint = "english"
     assert cfg.triage.default_checkpoint == "english"
 
 
+def test_llm_slot_and_brief_prompt_defaults():
+    """Slots default to None; the brief prompts match the plan verbatim."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.llm.chat_slot_id is None
+    assert cfg.llm.command_slot_id is None
+    assert cfg.llm.triage_system_prompt == (
+        "You maintain a living brief of a business conversation. Given the current brief "
+        "(your previous reply, if any) and a new transcript segment, return the UPDATED "
+        "brief as plain text only, with no markdown fences and no JSON. Preserve prior "
+        "content unless the new segment contradicts it; merge duplicates; prune stale "
+        "items; keep owners, responsibilities, and deadlines explicit. Use short sections: "
+        "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
+        "Deadlines, Open Questions."
+    )
+    assert cfg.llm.brief_compaction_system_prompt == (
+        "You compress a triage briefing conversation. Preserve every opportunity, blocker, "
+        "owner, responsibility, decision, deadline, and open question; drop pleasantries. "
+        "Output only the summary."
+    )
+
+
+def test_absent_slot_ids_stay_none():
+    """Present sections with the slot keys absent leave every slot id None."""
+    config_toml = """
+[llm]
+temperature = 0.5
+
+[triage]
+window = 12
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.llm.chat_slot_id is None
+    assert cfg.llm.command_slot_id is None
+    assert cfg.triage.brief_slot_id is None
+
+
+def test_slot_id_zero_is_preserved():
+    """A slot id of 0 is valid and must load as 0, not None."""
+    config_toml = """
+[llm]
+chat_slot_id = 0
+command_slot_id = 0
+
+[triage]
+brief_slot_id = 1
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.llm.chat_slot_id == 0
+    assert cfg.llm.command_slot_id == 0
+    assert cfg.triage.brief_slot_id == 1
+
+
+def test_non_numeric_slot_degrades_to_none():
+    """A non-numeric slot value degrades to None without raising."""
+    config_toml = """
+[llm]
+chat_slot_id = "abc"
+
+[triage]
+brief_slot_id = "nope"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.llm.chat_slot_id is None
+    assert cfg.triage.brief_slot_id is None
+
+
+def test_triage_brief_keys_are_parsed():
+    """Every new [triage] brief key is applied from TOML."""
+    config_toml = """
+[triage]
+brief_slot_id = 7
+brief_gate_enabled = false
+brief_gate_threshold = 0.8
+brief_max_chars = 1234
+brief_pending_char_budget = 777
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.triage.brief_slot_id == 7
+    assert cfg.triage.brief_gate_enabled is False
+    assert cfg.triage.brief_gate_threshold == 0.8
+    assert cfg.triage.brief_max_chars == 1234
+    assert cfg.triage.brief_pending_char_budget == 777
+
+
+def test_brief_defaults():
+    """The [brief] section defaults to no on-disk writing."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.brief.write is False
+    assert cfg.brief.dir == "brief"
+    assert cfg.brief.filename == "brief.md"
+
+
+def test_brief_config_fields_are_parsed():
+    """Every [brief] key is applied from TOML."""
+    config_toml = """
+[brief]
+write = true
+dir = "briefing"
+filename = "live.md"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.brief.write is True
+    assert cfg.brief.dir == "briefing"
+    assert cfg.brief.filename == "live.md"
+
+
+def test_brief_write_false_is_parsed(caplog):
+    """An explicit [brief] write=false loads as False and is not an unknown section."""
+    config_toml = """
+[brief]
+write = false
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        with caplog.at_level("WARNING", logger="stt_daemon.config"):
+            cfg = load_config(str(config_path))
+
+    assert cfg.brief.write is False
+    assert "brief" not in caplog.text
+
+
 def test_triage_dump_io_defaults_to_false():
     """[triage].dump_io is opt-in: False unless explicitly enabled."""
     with tempfile.TemporaryDirectory() as tmpdir:

+ 34 - 0
tests/test_executor.py

@@ -111,3 +111,37 @@ def test_truncated_partial_answer_reports_partial():
     assert result.ok is False
     assert result.result == "par"
     assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
+
+
+class RecordingClient:
+    """Fake that accepts (and records) every extra keyword passed to `chat`."""
+
+    def __init__(self, responses):
+        self.responses = list(responses)
+        self.calls: list[dict] = []
+
+    def chat(self, messages, tools=None, **kwargs):
+        self.calls.append({"messages": messages, "tools": tools, **kwargs})
+        return self.responses.pop(0)
+
+
+def test_executor_forwards_configured_slot():
+    cfg = LlmConfig(max_tool_iterations=3)
+    client = RecordingClient([LlmResponse(content="answer")])
+    executor = LlmExecutor(cfg, client, default_registry(), slot_id=4)
+    result = executor.run("hello", lambda *a, **k: None)
+
+    assert result.ok is True
+    assert result.result == "answer"
+    assert client.calls[0]["slot_id"] == 4
+
+
+def test_executor_omits_slot_when_none_for_old_fake():
+    """Misleading-success probe: an old fake with no `slot_id` parameter is called
+    fine, proving the kwarg is omitted (passing it would raise TypeError)."""
+    executor, client = _executor([LlmResponse(content="answer")])
+    result = executor.run("hello", lambda *a, **k: None)
+
+    assert result.ok is True
+    assert result.result == "answer"
+    assert len(client.calls) == 1

+ 60 - 5
tests/test_inference.py

@@ -357,7 +357,7 @@ def test_submit_if_room_refuses_when_full_without_eviction():
         before = [id(job) for job in queue._queue]
         assert len(before) == 3
 
-        assert queue.submit_if_room(InferenceJob(name="triage-extract", run=lambda: None)) is False
+        assert queue.submit_if_room(InferenceJob(name="extract", run=lambda: None)) is False
 
         after = [id(job) for job in queue._queue]
         assert after == before
@@ -374,7 +374,7 @@ def test_submit_if_room_admits_and_runs_when_room():
 
     queue.start()
     try:
-        job = InferenceJob(name="triage-extract", run=ran.set)
+        job = InferenceJob(name="extract", run=ran.set)
         assert queue.submit_if_room(job) is True
         assert ran.wait(timeout=1.0) is True
     finally:
@@ -389,7 +389,7 @@ def test_submit_if_room_after_stop_refuses_and_enqueues_nothing():
     queue.start()
     queue.stop()
 
-    job = InferenceJob(name="triage-extract", run=lambda: None)
+    job = InferenceJob(name="extract", run=lambda: None)
     assert queue.submit_if_room(job) is False
     assert queue.depth() == 0
 
@@ -423,7 +423,7 @@ def test_submit_still_evicts_oldest_normal_while_submit_if_room_refuses():
         assert queue.submit(job3) is True
 
         # 1 in-flight + 2 queued == size; reserve=1 caps admission at 2.
-        refused = InferenceJob(name="triage-extract", run=lambda: None)
+        refused = InferenceJob(name="extract", run=lambda: None)
         assert queue.submit_if_room(refused) is False
         assert all(job is not refused for job in queue._queue)
 
@@ -496,7 +496,62 @@ def test_stop_during_in_flight_is_bounded_then_refuses_admission():
     queue.stop()
     elapsed = time.monotonic() - begin
     assert elapsed < 1.5
-    assert queue.submit_if_room(InferenceJob(name="triage-extract", run=lambda: None)) is False
+    assert queue.submit_if_room(InferenceJob(name="extract", run=lambda: None)) is False
     assert queue.depth() == 0
 
     release.set()
+
+
+def test_non_evictable_normal_job_survives_overflow():
+    """Only non-evictable normal jobs queued: overflow is refused, nothing removed."""
+    queue = InferenceQueue(size=2)
+    runner = _JobRunner()
+    unblock = threading.Event()
+
+    queue.start()
+    try:
+        blocker = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
+        assert queue.submit(blocker) is True
+        _wait_in_flight(runner)
+
+        keep = InferenceJob(name="brief", run=lambda: None, priority=0, evictable=False)
+        assert queue.submit(keep) is True
+
+        # 1 in-flight + 1 non-evictable queued == size; no evictable job exists.
+        refused = InferenceJob(name="chat", run=lambda: None, priority=0, evictable=True)
+        assert queue.submit(refused) is False
+
+        assert [job.name for job in queue._queue] == ["brief"]
+        assert all(job is not refused for job in queue._queue)
+    finally:
+        unblock.set()
+        queue.stop()
+
+
+def test_overflow_evicts_oldest_evictable_not_non_evictable():
+    """Mixed queue: overflow drops the oldest evictable, keeps the non-evictable and newest."""
+    queue = InferenceQueue(size=3)
+    runner = _JobRunner()
+    unblock = threading.Event()
+
+    queue.start()
+    try:
+        blocker = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
+        assert queue.submit(blocker) is True
+        _wait_in_flight(runner)
+
+        keep = InferenceJob(name="brief", run=lambda: None, priority=0, evictable=False)
+        old = InferenceJob(name="old-chat", run=lambda: None, priority=0, evictable=True)
+        assert queue.submit(keep) is True
+        assert queue.submit(old) is True
+
+        # 1 in-flight + 2 queued == size. The new normal job must evict `old`,
+        # skip the non-evictable `keep`, and stay queued itself.
+        new = InferenceJob(name="new-chat", run=lambda: None, priority=0, evictable=True)
+        assert queue.submit(new) is True
+
+        assert [job.name for job in queue._queue] == ["brief", "new-chat"]
+        assert all(job is not old for job in queue._queue)
+    finally:
+        unblock.set()
+        queue.stop()

+ 317 - 1
tests/test_llm.py

@@ -2,7 +2,13 @@
 
 import pytest
 
-from stt_daemon.assistant.llm import ChatSession, LlmClient, LlmError
+from stt_daemon.assistant.llm import (
+    ChatSession,
+    ConversationEngine,
+    LlmClient,
+    LlmError,
+    LlmResponse,
+)
 from stt_daemon.config import LlmConfig
 
 
@@ -57,6 +63,47 @@ def test_tools_sent_only_when_provided():
     assert calls[1]["body"]["tool_choice"] == "auto"
 
 
+def test_id_slot_sent_only_when_slot_id_provided():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+    client.chat([{"role": "user", "content": "hi"}])
+    assert "id_slot" not in calls[0]["body"]
+
+    client.chat([{"role": "user", "content": "hi"}], slot_id=1)
+    assert calls[1]["body"]["id_slot"] == 1
+
+
+def test_slot_and_on_io_never_passed_to_old_fake_when_none():
+    seen: list[dict] = []
+
+    class _OldFake:
+        def chat(self, messages, tools=None, max_tokens=None, timeout_s=None):
+            seen.append(
+                {
+                    "messages": messages,
+                    "tools": tools,
+                    "max_tokens": max_tokens,
+                    "timeout_s": timeout_s,
+                }
+            )
+            return {"ok": True}
+
+    fake = _OldFake()
+    slot_id = None
+    on_io = None
+    kwargs: dict = {}
+    if slot_id is not None:
+        kwargs["slot_id"] = slot_id
+    if on_io is not None:
+        kwargs["on_io"] = on_io
+    fake.chat([{"role": "user", "content": "hi"}], **kwargs)
+    assert seen[0] == {
+        "messages": [{"role": "user", "content": "hi"}],
+        "tools": None,
+        "max_tokens": None,
+        "timeout_s": None,
+    }
+
+
 def test_tool_calls_are_parsed():
     client, _ = _make_client(
         lambda _p: _reply(
@@ -629,3 +676,272 @@ def test_raising_on_io_does_not_break_chat():
     client, _ = _make_client(lambda _p: _reply(content="ok"))
 
     assert client.chat([{"role": "user", "content": "hi"}], on_io=bad).content == "ok"
+
+
+# ---------------------------------------------------------------------------
+# ConversationEngine tests (Todo 2)
+# ---------------------------------------------------------------------------
+
+def _fake_engine_client(record=None, content="ok", finish="stop"):
+    """Old-style fake: accepts no slot_id/on_io keywords, returns LlmResponse."""
+
+    class _Fake:
+        def chat(self, messages, max_tokens=None, timeout_s=None):
+            if record is not None:
+                record.append(
+                    {
+                        "messages": messages,
+                        "max_tokens": max_tokens,
+                        "timeout_s": timeout_s,
+                    }
+                )
+            return LlmResponse(content=content, finish_reason=finish)
+
+    return _Fake()
+
+
+def test_engine_custom_system_prompt_is_first():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="CUSTOM")
+
+    assert engine.send("hi") == "ok"
+    assert calls[0]["body"]["messages"][0] == {
+        "role": "system",
+        "content": "CUSTOM",
+    }
+
+
+def test_engine_custom_compaction_prompt_is_used():
+    cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
+    seen_prompts: list[str] = []
+
+    def responder(payload):
+        msgs = payload["body"]["messages"]
+        if msgs[0]["content"] == "BRIEF-COMPACT":
+            seen_prompts.append(msgs[0]["content"])
+            return _reply(content="brief summary")
+        assert msgs[0]["content"] == "SYS2"
+        return _reply(content="ok")
+
+    client, _ = _make_client(responder, cfg)
+    engine = ConversationEngine(
+        cfg,
+        client,
+        system_prompt="SYS2",
+        compaction_system_prompt="BRIEF-COMPACT",
+    )
+
+    for _ in range(4):
+        engine.send("x" * 40)
+
+    assert seen_prompts, "summarizer never ran with the custom prompt"
+    assert engine.summary == "brief summary"
+
+
+def test_engine_slot_id_sent_only_when_set():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+    engine.send("hi")
+    assert "id_slot" not in calls[0]["body"]
+
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS", slot_id=3)
+    engine.send("hi")
+    assert calls[1]["body"]["id_slot"] == 3
+
+
+def test_engine_omits_slot_and_on_io_for_old_fake_when_none():
+    record: list[dict] = []
+    fake = _fake_engine_client(record)
+    engine = ConversationEngine(LlmConfig(), fake, system_prompt="SYS")
+
+    assert engine.send("hi") == "ok"
+    assert record[0]["messages"][0] == {"role": "system", "content": "SYS"}
+    assert record[0]["max_tokens"] is None
+    assert record[0]["timeout_s"] is None
+
+
+def test_engine_forwards_per_call_on_io():
+    client, _ = _make_client(lambda _p: _reply(content="ok"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+    seen: list[dict] = []
+
+    assert engine.send("hi", on_io=seen.append) == "ok"
+    assert [event["phase"] for event in seen] == ["request", "response"]
+
+
+def test_engine_forwards_slot_and_on_io_when_set():
+    record: list[dict] = []
+
+    class _NewFake:
+        def chat(self, messages, max_tokens=None, timeout_s=None, **kwargs):
+            record.append(kwargs)
+            return LlmResponse(content="ok", finish_reason="stop")
+
+    def hook(_event):
+        pass
+
+    engine = ConversationEngine(
+        LlmConfig(), _NewFake(), system_prompt="SYS", slot_id=7
+    )
+    assert engine.send("hi", on_io=hook) == "ok"
+    assert record[0]["slot_id"] == 7
+    assert record[0]["on_io"] is hook
+
+
+def test_engine_forwards_max_tokens_and_timeout():
+    record: list[dict] = []
+    fake = _fake_engine_client(record)
+    engine = ConversationEngine(
+        LlmConfig(), fake, system_prompt="SYS", max_tokens=11, timeout_s=2.5
+    )
+
+    engine.send("hi")
+    assert record[0]["max_tokens"] == 11
+    assert record[0]["timeout_s"] == 2.5
+
+
+def test_last_reply_returns_latest_assistant():
+    fake = _fake_engine_client(content="first")
+    engine = ConversationEngine(LlmConfig(), fake, system_prompt="SYS")
+
+    assert engine.last_reply() == ""
+    engine.send("hi")
+    assert engine.last_reply() == "first"
+    engine.history.append({"role": "user", "content": "again"})
+    engine.history.append({"role": "assistant", "content": "second"})
+    assert engine.last_reply() == "second"
+
+
+def test_finalize_none_restores_history_and_summary_copy():
+    notifications: list[tuple] = []
+
+    def notify(kind, **fields):
+        notifications.append((kind, fields))
+
+    client, _ = _make_client(lambda _p: _reply(content="raw reply"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS", notify=notify)
+    engine.history = [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+    ]
+    engine.summary = "prior summary"
+
+    assert engine.send("new?", finalize=lambda reply, finish: None) == ""
+    assert engine.history == [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+    ]
+    assert engine.summary == "prior summary"
+    assert notifications == []
+
+
+def test_finalize_transformed_string_is_appended():
+    client, _ = _make_client(lambda _p: _reply(content="raw"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+
+    seen_args: list[tuple] = []
+
+    def finalize(reply, finish):
+        seen_args.append((reply, finish))
+        return "EDITED: " + reply
+
+    assert engine.send("hi", finalize=finalize) == "EDITED: raw"
+    assert seen_args == [("raw", "stop")]
+    assert engine.history[-1] == {"role": "assistant", "content": "EDITED: raw"}
+    assert engine.last_reply() == "EDITED: raw"
+
+
+def test_finalize_empty_string_is_appended_not_restored():
+    client, _ = _make_client(lambda _p: _reply(content="raw"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+    engine.history = [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+    ]
+
+    assert engine.send("new?", finalize=lambda reply, finish: "") == ""
+    assert engine.history == [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+        {"role": "user", "content": "new?"},
+        {"role": "assistant", "content": ""},
+    ]
+    assert engine.history[-1] == {"role": "assistant", "content": ""}
+
+
+def test_finalize_reject_after_reset_mid_flight_stays_empty():
+    client, _ = _make_client(lambda _p: _reply(content="late"))
+
+    holder: list[ConversationEngine] = []
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+    holder.append(engine)
+    engine.summary = "prior summary"
+
+    def finalize(reply, finish):
+        holder[0].reset()
+        return None
+
+    assert engine.send("hi", finalize=finalize) == ""
+    assert engine.history == []
+    assert engine.summary == ""
+
+
+def test_finalize_snapshot_is_a_copy():
+    client, _ = _make_client(lambda _p: _reply(content="raw"))
+    engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
+    engine.history = [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+    ]
+
+    def finalize(reply, finish):
+        engine.history.append({"role": "assistant", "content": "POISON"})
+        engine.history.append({"role": "user", "content": "POISON"})
+        return None
+
+    assert engine.send("new?", finalize=finalize) == ""
+    assert engine.history == [
+        {"role": "user", "content": "u1"},
+        {"role": "assistant", "content": "a1"},
+    ]
+
+
+def test_summarizer_never_pinned_to_engine_slot():
+    cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
+    client, calls = _make_client(
+        lambda p: (
+            _reply(content="s")
+            if p["body"]["messages"][0]["content"] == "CUSTOM-COMPACT"
+            else _reply(content="ok")
+        ),
+        cfg,
+    )
+    engine = ConversationEngine(
+        cfg,
+        client,
+        system_prompt="SYS",
+        compaction_system_prompt="CUSTOM-COMPACT",
+        slot_id=5,
+    )
+
+    for _ in range(4):
+        engine.send("x" * 40)
+
+    chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
+    compact_calls = [
+        c for c in calls if c["body"]["messages"][0]["content"] == "CUSTOM-COMPACT"
+    ]
+    assert chat_calls and all(c["body"]["id_slot"] == 5 for c in chat_calls)
+    assert compact_calls and all("id_slot" not in c["body"] for c in compact_calls)
+
+
+def test_chat_session_is_engine_with_chat_prompt():
+    client, calls = _make_client(lambda _p: _reply(content="hello"))
+    session = ChatSession(LlmConfig(), client)
+
+    assert isinstance(session, ConversationEngine)
+    assert session.system == LlmConfig().system_prompt_chat
+    assert session.send("hi") == "hello"
+    assert calls[0]["body"]["messages"][0]["content"] == LlmConfig().system_prompt_chat
+    assert session.last_reply() == "hello"

+ 150 - 93
tests/test_triage.py

@@ -56,6 +56,32 @@ def test_load_example_order_preserved():
         assert definition["instructions"].strip()
 
 
+def test_example_questions_match_design():
+    questions = load_questions(EXAMPLE_PATH)
+    assert questions is not None
+    assert list(questions.keys()) == [
+        "substantive_update",
+        "opportunity",
+        "blocker",
+        "decision_status",
+        "action_item",
+        "responsibility_clarity",
+        "deadline",
+        "open_question",
+    ]
+    for qid, definition in questions.items():
+        assert isinstance(definition["instructions"], str)
+        assert definition["instructions"].strip()
+        if definition["type"] == "choice":
+            criteria = definition["criteria"]
+            options = list(criteria.keys()) if isinstance(criteria, dict) else list(criteria)
+            assert "other" in options
+            assert len(options) <= 7
+        if definition["type"] == "noul":
+            assert isinstance(definition.get("instructions"), str)
+            assert definition["instructions"].strip()
+
+
 def test_load_missing_file_returns_none_with_warning(caplog):
     with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
         assert load_questions("/nonexistent/questions.json") is None
@@ -352,79 +378,100 @@ def test_normalize_escalated_matches_should_escalate(tmp_path):
     assert result["escalated"] == analyzer.should_escalate(result) is False
 
 
-def test_build_extraction_prompt_words_not_dump(tmp_path):
+# --- todo 5: brief prompt / parse / gate / finalize --------------------------
+
+
+def test_build_brief_prompt_order_and_focus(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
-    prompt = analyzer.build_extraction_prompt(["please cancel my account"], _signals(noul_p=0.8))
-    assert "please cancel my account" in prompt
-    assert "churn_risk" in prompt  # flagged signal named in words
-    assert "category" in prompt  # unflagged signals included too
-    assert "severity" in prompt
-    assert "Signals:" in prompt
-    assert "Flagged signals:" not in prompt
-    assert "{'kind'" not in prompt and '{"kind"' not in prompt  # no raw dict dump
-    for key in ("summary", "opportunities", "doubts", "action_items", "risks"):
-        assert key in prompt
-
-
-def test_build_extraction_prompt_state_only_when_no_signals(tmp_path):
+    prompt = analyzer.build_brief_prompt(
+        ["Alice: let's ship Friday", "Bob: agreed"], _signals(noul_p=0.8)
+    )
+    assert prompt.startswith("New utterances:\n- Alice: let's ship Friday\n- Bob: agreed")
+    assert "Focus:" in prompt
+    assert "churn_risk" in prompt  # signal named in words
+    assert prompt.index("New utterances:") < prompt.index("Focus:")
+    assert prompt.index("Focus:") < prompt.index("Return only the updated brief")
+    # no JSON extraction keys or raw dict shapes leak into the brief prompt
+    for key in ("summary", "opportunities", "action_items", "risks", "doubts"):
+        assert key not in prompt
+    assert "{" not in prompt and "}" not in prompt
+
+
+def test_build_brief_prompt_no_focus_without_signals(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
     for signals in (None, {}, {"order": [], "items": {}}):
-        prompt = analyzer.build_extraction_prompt(["hello there"], signals)
-        assert "hello there" in prompt
-        assert "Signals:" not in prompt
-        assert "Flagged signals:" not in prompt
-        assert "summary" in prompt  # instruction keys still present
-        assert "opportunities" in prompt
-    prompt = analyzer.build_extraction_prompt(["hello there"], None)
-    assert "Signals:" not in prompt
-    assert "\nnone" not in prompt
-
-
-def _extraction_doc(**overrides):
-    doc = {
-        "summary": "user wants to cancel",
-        "opportunities": ["winback offer"],
-        "doubts": ["reason unclear"],
-        "action_items": [{"what": "call back", "why": "save account"}],
-        "risks": ["churn"],
-    }
-    doc.update(overrides)
-    return json.dumps(doc)
+        prompt = analyzer.build_brief_prompt(["solo utterance"], signals)
+        assert "solo utterance" in prompt
+        assert "Focus:" not in prompt
+        assert prompt.endswith("Return only the updated brief as plain text.")
 
 
-def test_parse_extraction_valid_fenced(tmp_path):
+def test_parse_brief_strips_fences_and_rejects_empty(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_brief("```\nhello brief\n```") == "hello brief"
+    assert analyzer.parse_brief("```markdown\nhello brief\n```") == "hello brief"
+    assert analyzer.parse_brief("plain brief") == "plain brief"
+    assert analyzer.parse_brief("  padded  ") == "padded"
+    assert analyzer.parse_brief("") is None
+    assert analyzer.parse_brief("   ") is None
+    assert analyzer.parse_brief(None) is None
+    assert analyzer.parse_brief(42) is None
+    assert analyzer.parse_brief("```") is None
+
+
+def test_parse_brief_truncates_not_rejects(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
-    text = "```json\n" + _extraction_doc() + "\n```"
-    parsed = analyzer.parse_extraction(text)
+    parsed = analyzer.parse_brief("x" * 5000)
     assert parsed is not None
-    assert parsed["summary"] == "user wants to cancel"
-    assert parsed["action_items"] == [{"what": "call back", "why": "save account"}]
+    assert parsed == "x" * 4000
+    assert len(parsed) == 4000
+
+
+def test_parse_brief_honors_configured_cap(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=10)
+    assert analyzer.parse_brief("x" * 50) == "x" * 10
+
 
+def _brief_signals(p=None):
+    item = {"kind": "noul"}
+    if p is not None:
+        item["p"] = p
+    return {"order": ["substantive_update"], "items": {"substantive_update": item}}
 
-def test_parse_extraction_invalid_returns_none(tmp_path):
+
+def test_should_update_brief_gate(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
-    assert analyzer.parse_extraction("not json at all {{{") is None
-    assert analyzer.parse_extraction(json.dumps({"summary": "only"})) is None  # bad shape
-    assert analyzer.parse_extraction(None) is None
-    assert analyzer.parse_extraction("") is None
-    assert analyzer.parse_extraction("x" * 30000) is None  # oversized
+    assert analyzer.should_update_brief(_brief_signals(0.9)) is True
+    assert analyzer.should_update_brief(_brief_signals(0.5)) is True  # boundary
+    assert analyzer.should_update_brief(_brief_signals(0.2)) is False
+    # absent update / no numeric p / not a dict -> fail open
+    assert analyzer.should_update_brief({"order": [], "items": {}}) is True
+    assert analyzer.should_update_brief(_brief_signals(None)) is True
+    assert analyzer.should_update_brief(None) is True
+    assert analyzer.should_update_brief({"items": {"substantive_update": "nope"}}) is True
+
 
+def test_should_update_brief_threshold_configured(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_gate_threshold=0.8)
+    assert analyzer.should_update_brief(_brief_signals(0.6)) is False
+    assert analyzer.should_update_brief(_brief_signals(0.8)) is True
 
-def test_parse_extraction_caps_lists_and_strings(tmp_path):
+
+def test_finalize_brief_length_truncates_not_rejects(tmp_path, caplog):
     analyzer = _analyzer(tmp_path, _score_questions())
-    parsed = analyzer.parse_extraction(
-        _extraction_doc(
-            opportunities=["o%d" % i for i in range(9)],
-            doubts=["d%d" % i for i in range(7)],
-            risks=["r%d" % i for i in range(6)],
-            summary="s" * 2000,
-        )
-    )
-    assert parsed is not None
-    assert len(parsed["opportunities"]) == 5
-    assert len(parsed["doubts"]) == 5
-    assert len(parsed["risks"]) == 5
-    assert len(parsed["summary"]) <= 500
+    with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
+        result = analyzer.finalize_brief("y" * 5000, "length")
+    assert result is not None
+    assert result == "y" * 4000
+    assert caplog.records  # warned about the length cutoff but still accepted
+
+
+def test_finalize_brief_stop_and_empty(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.finalize_brief("a brief", "stop") == "a brief"
+    assert analyzer.finalize_brief("", "stop") is None
+    assert analyzer.finalize_brief("```\nfenced\n```", "stop") == "fenced"
+    assert analyzer.finalize_brief(None, "length") is None
 
 
 # --- todo 11: cached state() ------------------------------------------------
@@ -554,29 +601,33 @@ class _FakeLane:
 
 
 def _answers(noul_p=0.1):
-    """A full laya-shaped answers payload for the example questions."""
+    """A full laya-shaped answers payload for the example questions.
+
+    `noul_p` drives every noul arm, including the `substantive_update` gate:
+    below `brief_gate_threshold` (0.5) the turn is gated OUT (no brief job),
+    at or above it the turn gates IN.
+    """
     answers = {
-        "category": {
+        "decision_status": {
             "type": "choice",
-            "choice": "sales",
+            "choice": "made",
             "confidence": 0.9,
             "answer_confidence": 0.9,
         },
-        "urgency": {
+        "responsibility_clarity": {
             "type": "choice",
-            "choice": "low",
+            "choice": "explicit",
             "confidence": 0.9,
             "answer_confidence": 0.9,
         },
     }
     for qid in (
-        "frustration",
-        "churn_risk",
-        "needs_action",
-        "needs_clarification",
-        "contains_idea",
-        "business_opportunity",
-        "positive_sentiment",
+        "substantive_update",
+        "opportunity",
+        "blocker",
+        "action_item",
+        "deadline",
+        "open_question",
     ):
         answers[qid] = {
             "type": "noul",
@@ -591,8 +642,11 @@ def test_end_to_end_triage_flow():
     """signals -> content -> cleared across service, analyzer, lane, and ViewState.
 
     The lane result is wired by calling `service._on_triage_result(gen, payload)`
-    directly (the runner does exactly this in production); the extraction job is
-    drained with `job.run()` so no worker thread is started.
+    directly (the runner does exactly this in production); the brief job is
+    drained with `job.run()` so no worker thread is started. The
+    single-outstanding guard plus the shared `_pending_brief` collapse settled
+    turns into ONE job consuming every buffered delta, and the
+    `substantive_update` gate can reduce that to ZERO jobs.
     """
     cfg = Config()
     cfg.assistant.turn_gap_s = 0.5
@@ -601,6 +655,7 @@ def test_end_to_end_triage_flow():
     clock = _Clock(1000.0)
     lane = _FakeLane()
     emitted: list[dict] = []
+    prompts: list[str] = []
 
     def emit(type_, **fields):
         emitted.append({"type": type_, **fields})
@@ -614,14 +669,14 @@ def test_end_to_end_triage_flow():
         chat_fn=lambda text: "reply",
         triage=analyzer,
         laya_lane=lane,
-        triage_extract_fn=lambda prompt: EXTRACT_JSON,
+        brief_fn=lambda prompt: prompts.append(prompt) or "BRIEF-TEXT",
     )
 
     def triage_events():
         return [e for e in emitted if e["type"] == "triage"]
 
-    def extract_jobs():
-        return [j for j in service._inference._queue if j.name == "triage-extract"]
+    def brief_jobs():
+        return [j for j in service._inference._queue if j.name == "brief-update"]
 
     # 1. settled turn -> exactly one lane submit, lang + generation recorded
     assert service.toggle_chat() is True
@@ -637,15 +692,16 @@ def test_end_to_end_triage_flow():
     assert lang == "es"
     assert gen1 == 0
 
-    # 2. non-escalating result -> signals plus one state+signals extraction job
+    # 2. low-p turn -> signals only: the gate (p=0.1 < 0.5) dispatches ZERO jobs
     service._on_triage_result(gen1, _answers(noul_p=0.1))
     assert [e["stage"] for e in triage_events()] == ["signals"]
     assert triage_events()[0]["triage"]["escalated"] is False
     assert service.last_triage is not None
     assert service.last_triage["escalated"] is False
-    assert len(extract_jobs()) == 1
+    assert brief_jobs() == []
+    assert prompts == []
 
-    # 3. escalating turn -> second submit, then a second extract job
+    # 3. high-p turn -> second submit, then ONE brief job (the only one outstanding)
     service.handle_event(
         {"type": events.TRANSCRIPT, "text": "quiero cancelar mi suscripcion", "lang": "es"}
     )
@@ -656,17 +712,20 @@ def test_end_to_end_triage_flow():
     service._on_triage_result(gen2, _answers(noul_p=0.9))
     assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
     assert triage_events()[1]["triage"]["escalated"] is True
-    jobs = extract_jobs()
-    assert len(jobs) == 2
-
-    # 4. run the extraction jobs directly (deterministic, no worker thread)
-    for job in jobs:
-        job.run()
+    jobs = brief_jobs()
+    assert len(jobs) == 1
+
+    # 4. run the brief job directly (deterministic, no worker thread): it consumes
+    # both buffered deltas and emits ONE content with the brief merged over signals
+    jobs[0].run()
+    assert len(prompts) == 1
+    assert "hola, necesito ayuda" in prompts[0]
+    assert "quiero cancelar mi suscripcion" in prompts[0]
     content_events = [e for e in triage_events() if e["stage"] == "content"]
-    assert len(content_events) == 2
-    merged = content_events[1]["triage"]
-    assert merged["content"]["summary"] == "s"
-    assert merged["escalated"] is True  # signals preserved under the content
+    assert len(content_events) == 1
+    merged = content_events[0]["triage"]
+    assert merged["brief"] == "BRIEF-TEXT"
+    assert merged["escalated"] is True  # signals preserved under the brief
     assert merged["order"] == triage_events()[1]["triage"]["order"]
 
     # 5. drive the emitted stream through a fresh ViewState
@@ -676,10 +735,9 @@ def test_end_to_end_triage_flow():
     analyzer.refresh("ok")  # the lane callback does this in production
     view.ingest({"type": "status", **service.status()})
     assert view.triage is not None
-    assert view.triage["content"]["summary"] == "s"
+    assert view.triage["brief"] == "BRIEF-TEXT"
     lines = view.triage_lines()
     assert lines
-    assert any("summary" in line and "s" in line for line in lines)
 
     # 6. clear -> cleared event empties the panel and bumps the epoch
     baseline_epoch = view.triage_epoch
@@ -696,7 +754,6 @@ def test_end_to_end_triage_flow():
         "signals",
         "signals",
         "content",
-        "content",
         "cleared",
     ]
     assert view.triage is None

+ 20 - 0
tests/test_triage_dump.py

@@ -44,6 +44,26 @@ def test_enabled_writes_one_shaped_file(tmp_path):
     }
 
 
+def test_same_millisecond_collision_keeps_first_shape(tmp_path):
+    """Same-millisecond records all land; only repeats get a `-N` suffix."""
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    for n in (1, 2, 3):
+        dumper.begin(TS, laya={"request": {}, "response": {"n": n}, "error": None})
+        dumper.finalize(TS)
+
+    dt = datetime.fromtimestamp(TS)
+    base = dt.strftime("%H%M%S") + "-" + dt.strftime("%f")[:3]
+    files = {path.name: path for path in tmp_path.rglob("*.json")}
+    assert set(files) == {f"{base}.json", f"{base}-1.json", f"{base}-2.json"}
+    assert re.fullmatch(r"\d{6}-\d{3}\.json", f"{base}.json")
+    first = json.loads(files[f"{base}.json"].read_text(encoding="utf-8"))
+    second = json.loads(files[f"{base}-1.json"].read_text(encoding="utf-8"))
+    third = json.loads(files[f"{base}-2.json"].read_text(encoding="utf-8"))
+    assert first["laya"]["response"] == {"n": 1}
+    assert second["laya"]["response"] == {"n": 2}
+    assert third["laya"]["response"] == {"n": 3}
+
+
 def test_disabled_writes_nothing(tmp_path):
     dumper = TriageDumper(False, directory=str(tmp_path))
     assert dumper.enabled is False

+ 108 - 3
tests/test_tui_app.py

@@ -653,7 +653,7 @@ def test_triage_enabled_composes_split(server):
             assert triage.parent is main
             assert chat.parent is main
             assert transcript.parent is not main
-            assert triage.border_title == "triage"
+            assert triage.border_title == "triage  \\[inf 0]"  # markup: '[' is escaped
             # triage sits LEFT of chat inside #main.
             assert main.children.index(triage) < main.children.index(chat)
             # Outside widgets stay outside #main.
@@ -1697,7 +1697,7 @@ def test_s_key_swaps_triage_pane_to_questions_view(server):
             observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
 
     asyncio.run(scenario())
-    assert observed["default_title"] == "triage"
+    assert observed["default_title"] == "triage  \\[inf 0]"  # markup: '[' is escaped
     assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
     assert observed["questions_title"] == "questions"
     rendered = "\n".join(observed["questions_lines"])
@@ -1738,12 +1738,117 @@ def test_s_key_again_restores_triage_view(server):
 
     asyncio.run(scenario())
     assert observed["mid_title"] == "questions"
-    assert observed["triage_title"] == "triage"
+    assert observed["triage_title"] == "triage  \\[inf 0]"  # markup: '[' is escaped
     rendered = "\n".join(observed["triage_lines"])
     assert "topic: planning (0.91)" in rendered
     assert "What is the main theme?" not in rendered
 
 
+def test_triage_brief_title_mirrors_inference_depth_and_busy(server):
+    """Brief view: #triage title carries [inf N] and the busy marker, exactly."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest(
+                {"type": "heartbeat", "inference_depth": 3, "inference_in_flight": False}
+            )
+            app._render()
+            await pilot.pause()
+            observed["idle"] = app.query_one("#triage", RichLog).border_title
+
+            app.state.ingest(
+                {"type": "heartbeat", "inference_depth": 7, "inference_in_flight": True}
+            )
+            app._render()
+            await pilot.pause()
+            observed["busy"] = app.query_one("#triage", RichLog).border_title
+
+    asyncio.run(scenario())
+    # border_title round-trips as console markup, so '[' reads back as '\['.
+    assert observed["idle"] == "triage  \\[inf 3]"
+    assert observed["busy"] == "triage  \\[inf 7 · busy]"
+
+
+def test_questions_view_title_has_no_inference_suffix(server):
+    """Questions view: title is exactly 'questions'; toggling back restores the suffix."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()
+            app._drain()
+            app.state.ingest(
+                {"type": "heartbeat", "inference_depth": 5, "inference_in_flight": True}
+            )
+            app._render()
+            await pilot.pause()
+            observed["brief"] = app.query_one("#triage", RichLog).border_title
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["questions"] = app.query_one("#triage", RichLog).border_title
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["restored"] = app.query_one("#triage", RichLog).border_title
+
+    asyncio.run(scenario())
+    assert observed["brief"] == "triage  \\[inf 5 · busy]"
+    assert observed["questions"] == "questions"
+    assert "inf" not in observed["questions"]
+    assert "busy" not in observed["questions"]
+    # No stale plain title: the brief view comes back fully suffixed.
+    assert observed["restored"] == "triage  \\[inf 5 · busy]"
+
+
+def test_triage_disabled_heartbeat_leaves_no_triage_widget(server):
+    """triage_enabled=False: depths update #chat only and never create a #triage pane."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()
+            app._drain()
+            app.state.ingest(
+                {"type": "heartbeat", "inference_depth": 9, "inference_in_flight": True}
+            )
+            app._render()
+            await pilot.pause()
+            observed["has_triage"] = bool(app.query("#triage"))
+            observed["chat"] = app.query_one("#chat", RichLog).border_title
+
+    asyncio.run(scenario())
+    assert observed["has_triage"] is False
+    assert observed["chat"] == "conversation / command output  \\[inf 9 · busy]"
+
+
 def test_s_key_is_noop_when_triage_disabled(server):
     """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
     pytest.importorskip("textual")

+ 48 - 22
tests/test_tui_state.py

@@ -471,14 +471,11 @@ def test_triage_signals_event_stores_and_bumps_epoch():
     assert state.triage_epoch == epoch + 1
 
 
-def test_triage_content_event_replaces_triage_and_bumps_epoch():
+def test_triage_brief_event_replaces_triage_and_bumps_epoch():
     state = ViewState()
     state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
     epoch = state.triage_epoch
-    merged = {
-        **_signals_triage(),
-        "content": {"summary": "s", "opportunities": [], "risks": [], "action_items": []},
-    }
+    merged = {**_signals_triage(), "brief": "one brief line"}
     state.ingest({"type": "triage", "stage": "content", "triage": merged})
     assert state.triage == merged
     assert state.triage_epoch == epoch + 1
@@ -527,26 +524,54 @@ def test_triage_lines_choice_noul_score_and_flag_marks():
     assert lines[3] == "q_unknown"
 
 
-def test_triage_lines_content_block_appended():
+def test_triage_lines_brief_appended_line_by_line():
     state = ViewState()
     state.triage = {
         "order": ["q1"],
         "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
-        "content": {
-            "summary": "user wants action",
-            "opportunities": ["opp1", "opp2", "opp3", "opp4"],
-            "risks": ["risk1"],
-            "action_items": [{"what": "call", "why": "urgent"}],
-        },
+        "brief": "First line.\nSecond line.\nThird line.",
     }
     state.triage_state = "ok"
-    lines = state.triage_lines()
-    assert "" in lines
-    assert "summary: user wants action" in lines
-    assert "opportunitie: opp1" in lines
-    assert "opp4" not in " ".join(lines)
-    assert "risk: risk1" in lines
-    assert "action_item: {'what': 'call', 'why': 'urgent'}" in lines
+    assert state.triage_lines() == [
+        "q1: no (0.90)",
+        "",
+        "First line.",
+        "Second line.",
+        "Third line.",
+    ]
+
+
+def test_triage_lines_missing_or_non_str_brief_degrades_to_signals_only():
+    state = ViewState()
+    base = {
+        "order": ["q1"],
+        "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.5}},
+    }
+    state.triage = base
+    state.triage_state = "ok"
+    assert state.triage_lines() == ["q1: yes (0.50)"]
+
+    for malformed in (None, 123, ["not", "a", "str"], {"brief": "nested"}):
+        state.triage = {**base, "brief": malformed}
+        assert state.triage_lines() == ["q1: yes (0.50)"]
+
+
+def test_triage_lines_brief_none_never_raises():
+    state = ViewState()
+    state.triage = {"brief": None}
+    state.triage_state = "ok"
+    assert state.triage_lines() == ["triage: no signals"]
+
+
+def test_triage_lines_empty_brief_renders_nothing_extra():
+    state = ViewState()
+    state.triage = {
+        "order": ["q1"],
+        "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.5}},
+        "brief": "",
+    }
+    state.triage_state = "ok"
+    assert state.triage_lines() == ["q1: yes (0.50)"]
 
 
 def test_triage_lines_non_ok_state_renders_reason():
@@ -629,7 +654,7 @@ def test_lone_signals_event_renders_without_status_snapshot():
     assert state.triage_lines() == ["category: question (0.91)"]
 
 
-def test_lone_content_event_renders_without_status_snapshot():
+def test_lone_brief_event_renders_without_status_snapshot():
     state = ViewState()
     state.ingest(
         {
@@ -640,14 +665,15 @@ def test_lone_content_event_renders_without_status_snapshot():
                 "items": {
                     "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
                 },
-                "content": {"summary": "wants action"},
+                "brief": "wants action",
             },
         }
     )
     assert state.triage_state == "ok"
     lines = state.triage_lines()
     assert lines[0] == "category: question (0.91)"
-    assert "summary: wants action" in lines
+    assert lines[1] == ""
+    assert lines[2] == "wants action"
 
 
 def test_cleared_event_after_lone_signals_leaves_state_usable():

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików