Răsfoiți Sursa

Improved triage

Efren Yevale Varela 1 lună în urmă
părinte
comite
5399275524

+ 1 - 0
.gitignore

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

+ 10 - 3
AGENTS.md

@@ -64,12 +64,19 @@ publish-only.
   `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface).
   `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
 - `[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
   `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
 - `[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`,
   `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`,
   `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`,
   `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
   `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`.
   `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.
 - Never treat the live `config.toml` as a source of defaults; it is gitignored user state.
 
 
 ## Transcription backends
 ## Transcription backends
@@ -83,7 +90,7 @@ publish-only.
 
 
 ## Testing
 ## 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
   touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake
   embedder; no ONNX in tests).
   embedder; no ONNX in tests).
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
@@ -102,7 +109,7 @@ publish-only.
 ## Conventions & gotchas
 ## Conventions & gotchas
 
 
 - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`,
 - 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
 - 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
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   non-read-only.
   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/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/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/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/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/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.
 - [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"
 dir     = "audit"
 
 
 [llm]
 [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]
 [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]
 [speaker]
 enabled           = false
 enabled           = false
@@ -123,3 +131,8 @@ max_prototypes    = 8
 provisional_ttl_h = 24.0
 provisional_ttl_h = 24.0
 store_dir         = "speakers"
 store_dir         = "speakers"
 join_timeout_s    = 2.0
 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
 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.
 `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
 ## 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
 ## 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
 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**
 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,
 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
 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
 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
 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
 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
 ## 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).
 `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
 **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
 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
 max_tokens               = 1024             # max output per request; reasoning models spend tokens before the answer, so keep generous
 timeout_s                = 120.0
 timeout_s                = 120.0
 cache_prompt             = true             # reuse the KV prefix (append-only history keeps it warm)
 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
 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_safety_margin = 256              # tokens reserved for the token estimate error + template/tool overhead
 compaction_enabled       = true             # summarize old turns instead of deleting them
 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_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_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."
 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
 inference_queue_size     = 8                # bounded single-lane inference queue
 trim_block_turns         = 4                # fallback block trimming, not per-turn, to limit re-prefill
 trim_block_turns         = 4                # fallback block trimming, not per-turn, to limit re-prefill
 max_tool_iterations      = 3
 max_tool_iterations      = 3
 system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
 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."
 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)
 [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
 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"
 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)
 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)
 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)
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
 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)
 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)
 store_dir         = "speakers"               # Store root (cwd-relative); gitignored (biometric artifacts)
 join_timeout_s    = 2.0                      # Embedding-lane join timeout before emitting the transcript
 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
 `[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
 `[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,
 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
 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
 `device = ""` means auto: it maps to `None`, so Laya picks the
 default device for the multilingual checkpoint. `preload = ["multilingual"]` is the fixed default
 default device for the multilingual checkpoint. `preload = ["multilingual"]` is the fixed default
 and `default_checkpoint = "multilingual"`; the multilingual checkpoint is pinned for every call
 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
 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.
 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`,
 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`
 `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
 - **Provisioning**: `setup`/`ensure`/`teardown`, orphan-name cleanup, and `clear` idempotency
 - **Event bus**: envelope codec, ring replay, subscriber fan-out
 - **Event bus**: envelope codec, ring replay, subscriber fan-out
 - **Assistant**: mode machine, command readiness/timeouts, tool schema validation, LLM client/history trimming, audit log
 - **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`)
 - **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
 - **TUI**: pure view-state reducer and control-socket client
 
 
@@ -55,10 +56,11 @@ src/stt_daemon/
 │   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
 │   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
 │   ├── tools.py         - Tool registry + schema validation + get_datetime
 │   ├── tools.py         - Tool registry + schema validation + get_datetime
 │   ├── llm.py           - llama.cpp client + chat session (block trimming)
 │   ├── 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)
 │   ├── laya_lane.py     - In-process Laya lane (loader + single-flight worker)
 │   ├── triage_dump.py   - Opt-in per-triage I/O dump (`dump_io`)
 │   ├── 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
 │   ├── audit.py         - Daily action audit log
 │   ├── runner.py        - `stt-assistant` process
 │   ├── runner.py        - `stt-assistant` process
 │   └── __main__.py      - Entry point for `python -m stt_daemon.assistant`
 │   └── __main__.py      - Entry point for `python -m stt_daemon.assistant`
@@ -67,7 +69,7 @@ src/stt_daemon/
     ├── client.py        - Control-socket client
     ├── client.py        - Control-socket client
     └── app.py           - Textual app + `stt-tui` entry point
     └── 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/
 scripts/
 ├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`
 ├── 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
 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
 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
 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.
 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.
   `false` there is no widget at all.
 - **`signals`** after a settled turn: one line per question, rendered from the normalized
 - **`signals`** after a settled turn: one line per question, rendered from the normalized
   answer.
   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.
   - `score`: a ten-cell bar filled from the clamped integer level plus the level label.
   - A `⚠` mark follows any item the analyzer flagged.
   - 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
 - **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.
   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
 - **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
 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`.
 disk, and it is a no-op when `[triage].enabled = false`.
 
 
-## How the cascade works
+## How the brief works
 
 
 ```
 ```
 settled turn (any mode)
 settled turn (any mode)
     -> newest transcribed-utterance window (window entries, condensed under state_char_budget)
     -> newest transcribed-utterance window (window entries, condensed under state_char_budget)
     -> Laya multilingual classification (in-process lane)
     -> 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
 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
    - `score`: `expected >= (levels - 1) * 0.5`, where `levels` is the number of score
      criteria
      criteria
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
    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
 ## Lanes: nothing is parallelized
 
 
@@ -99,7 +126,7 @@ them:
 | lane | what runs on it |
 | lane | what runs on it |
 |---|---|
 |---|---|
 | transcription | one worker; faster-whisper is not thread-safe |
 | 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 |
 | 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
 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
 lane unblocked, which multiplexing Laya onto the `InferenceQueue` would not. The supervisor
 still spawns 2 children headless and 3 with the TUI.
 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
 ## Questions file
 
 
 Triage reads a bare Laya `questions` dict: a JSON object whose keys are question ids
 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
 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
 ## 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
 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`)
 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
 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`,
 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
 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). |
 | `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). |
 | `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). |
 | `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. |
 | `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. |
 | `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. |
 | `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`)
 ## 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
 ```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
 ## 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": {
     "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": {
   "decision_status": {
@@ -19,31 +30,43 @@
       "none": "no decision is being discussed",
       "none": "no decision is being discussed",
       "proposed": "a decision was proposed but is not settled",
       "proposed": "a decision was proposed but is not settled",
       "made": "a decision was reached",
       "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",
     "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": {
   "open_question": {
     "type": "noul",
     "type": "noul",
     "instructions": "Something important is still ambiguous and needs a follow-up question.",
     "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:
 class LlmExecutor:
     """Runs a command through the model's tool-calling loop."""
     """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.
         """Create the executor.
 
 
         Args:
         Args:
             cfg: An `LlmConfig`.
             cfg: An `LlmConfig`.
             client: An `LlmClient`.
             client: An `LlmClient`.
             registry: A `ToolRegistry`.
             registry: A `ToolRegistry`.
+            slot_id: Optional llama.cpp slot id for command calls; omitted
+                from the request when None.
         """
         """
         self.cfg = cfg
         self.cfg = cfg
         self.client = client
         self.client = client
         self.registry = registry
         self.registry = registry
+        self.slot_id = slot_id
 
 
     def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
     def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
         """Ask the model, validate/execute any tool calls, and return the answer."""
         """Ask the model, validate/execute any tool calls, and return the answer."""
@@ -57,12 +60,15 @@ class LlmExecutor:
             {"role": "user", "content": command_text},
             {"role": "user", "content": command_text},
         ]
         ]
         tools = self.registry.specs()
         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_tool: str | None = None
         last_args: dict = {}
         last_args: dict = {}
 
 
         for _ in range(max(1, self.cfg.max_tool_iterations)):
         for _ in range(max(1, self.cfg.max_tool_iterations)):
             try:
             try:
-                response = self.client.chat(messages, tools=tools)
+                response = self.client.chat(messages, tools=tools, **chat_kwargs)
             except LlmError as e:
             except LlmError as e:
                 return ExecutionResult(ok=False, result=f"llm error: {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:
 class InferenceJob:
     """A unit of work for the inference queue."""
     """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
     run: Callable[[], None]  # the work to do
     priority: int = 0  # lower values run first; commands use -1
     priority: int = 0  # lower values run first; commands use -1
+    evictable: bool = True  # False: never dropped by submit() overflow
 
 
 
 
 class InferenceQueue:
 class InferenceQueue:
@@ -61,8 +62,9 @@ class InferenceQueue:
         """Queue a job for execution.
         """Queue a job for execution.
 
 
         Returns:
         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:
         with self._cond:
             if self._stop.is_set():
             if self._stop.is_set():
@@ -132,12 +134,15 @@ class InferenceQueue:
             return self._in_flight is not None
             return self._in_flight is not None
 
 
     def _drop_oldest_normal(self) -> bool:
     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.
         Assumes the lock is held.
         """
         """
         for i, job in enumerate(self._queue):
         for i, job in enumerate(self._queue):
-            if job.priority >= 0:
+            if job.priority >= 0 and job.evictable:
                 del self._queue[i]
                 del self._queue[i]
                 return True
                 return True
         return False
         return False

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

@@ -82,6 +82,8 @@ class LlmClient:
         max_tokens: int | None = None,
         max_tokens: int | None = None,
         timeout_s: float | None = None,
         timeout_s: float | None = None,
         on_io: Callable[[dict], None] | None = None,
         on_io: Callable[[dict], None] | None = None,
+        *,
+        slot_id: int | None = None,
     ) -> LlmResponse:
     ) -> LlmResponse:
         """Send one completion request and parse the reply.
         """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.
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
             on_io: Optional hook receiving `{"phase": "request", ...}` before
             on_io: Optional hook receiving `{"phase": "request", ...}` before
                 the call and `{"phase": "response", ...}` after it.
                 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_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
         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,
             "cache_prompt": self.cfg.cache_prompt,
             "stream": False,
             "stream": False,
         }
         }
+        if slot_id is not None:
+            body["id_slot"] = slot_id
         if tools:
         if tools:
             body["tools"] = tools
             body["tools"] = tools
             body["tool_choice"] = "auto"
             body["tool_choice"] = "auto"
@@ -150,7 +155,7 @@ class LlmClient:
         )
         )
 
 
 
 
-class ChatSession:
+class ConversationEngine:
     """Conversation history with rolling-summary compaction and block-trim fallback.
     """Conversation history with rolling-summary compaction and block-trim fallback.
 
 
     State:
     State:
@@ -158,19 +163,33 @@ class ChatSession:
         history  — verbatim recent window (append-only between compactions).
         history  — verbatim recent window (append-only between compactions).
 
 
     Prompt assembly:
     Prompt assembly:
-        [system: system_prompt_chat]
+        [system: system_prompt]
         [system: "Earlier conversation summary:\\n{summary}"]  # only when summary is set
         [system: "Earlier conversation summary:\\n{summary}"]  # only when summary is set
         *history
         *history
 
 
     Lock discipline: self._lock (RLock) guards history/summary mutations and reads.
     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.history: list[dict] = []
         self.summary: str        = ""
         self.summary: str        = ""
@@ -184,10 +203,34 @@ class ChatSession:
             self.summary     = ""
             self.summary     = ""
             self._generation += 1
             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:
         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.history.append({"role": "user", "content": user_text})
 
 
         self._maybe_compact()
         self._maybe_compact()
@@ -200,16 +243,48 @@ class ChatSession:
                 )
                 )
             messages += self.history
             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
         reply    = response.content
-        truncated = response.finish_reason == "length"
+        finish_reason = response.finish_reason
+        truncated = finish_reason == "length"
 
 
         with self._lock:
         with self._lock:
             if generation != self._generation:
             if generation != self._generation:
                 # The session was reset while the model was thinking: the reply belongs
                 # The session was reset while the model was thinking: the reply belongs
                 # to a conversation that no longer exists, so drop it.
                 # to a conversation that no longer exists, so drop it.
                 return ""
                 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:
             if reply:
                 self.history.append({"role": "assistant", "content": reply})
                 self.history.append({"role": "assistant", "content": reply})
 
 
@@ -342,12 +417,15 @@ class ChatSession:
             lines.append(f"{msg['role']}: {msg['content']}")
             lines.append(f"{msg['role']}: {msg['content']}")
         user_text = "\n".join(lines)
         user_text = "\n".join(lines)
 
 
+        compaction_prompt = self._compaction_system_prompt or cfg.compaction_system_prompt
         messages = [
         messages = [
-            {"role": "system", "content": cfg.compaction_system_prompt},
+            {"role": "system", "content": compaction_prompt},
             {"role": "user",   "content": user_text},
             {"role": "user",   "content": user_text},
         ]
         ]
 
 
         try:
         try:
+            # Never pin the summarizer to a slot: compaction must not evict the
+            # engine's own conversation slot.
             response = self.client.chat(
             response = self.client.chat(
                 messages,
                 messages,
                 max_tokens=cfg.compaction_max_tokens,
                 max_tokens=cfg.compaction_max_tokens,
@@ -367,3 +445,10 @@ class ChatSession:
             return None
             return None
 
 
         return response.content
         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."""
 """Run the assistant process: subscribe to the STT bus and serve the control socket."""
 
 
 import argparse
 import argparse
+import functools
 import logging
 import logging
 import signal
 import signal
 import threading
 import threading
@@ -8,17 +9,18 @@ import threading
 from ..bus import EventSubscriber
 from ..bus import EventSubscriber
 from ..config import load_config
 from ..config import load_config
 from .audit import AuditLog
 from .audit import AuditLog
+from .brief_file import BriefFile
 from .control import ControlServer
 from .control import ControlServer
 from .executor import LlmExecutor, NullExecutor
 from .executor import LlmExecutor, NullExecutor
-from .llm import ChatSession, LlmClient
+from .llm import ConversationEngine, LlmClient
 from .service import AssistantService
 from .service import AssistantService
 from .tools import default_registry
 from .tools import default_registry
 
 
 
 
 def build_triage(cfg, client, on_result=None):
 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:
     if not cfg.triage.enabled:
-        return (None, None, None)
+        return (None, None, None, None)
     from .laya_lane import LayaLane
     from .laya_lane import LayaLane
     from .triage import TriageAnalyzer
     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_state=lambda state, reason: analyzer.refresh(state, reason),
         on_result=on_result,
         on_result=on_result,
     )
     )
-    extract = None
+    brief_fn = None
+    brief_reset = None
     if client is not 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:
 def main() -> int:
@@ -75,17 +74,28 @@ def main() -> int:
     client = None
     client = None
     if cfg.llm.enabled:
     if cfg.llm.enabled:
         client = LlmClient(cfg.llm)
         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_fn = session.send
         chat_reset = session.reset
         chat_reset = session.reset
 
 
     holder = {}
     holder = {}
-    triage, lane, extract_fn = build_triage(
+    triage, lane, brief_fn, brief_reset = build_triage(
         cfg,
         cfg,
         client if cfg.llm.enabled else None,
         client if cfg.llm.enabled else None,
         on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
         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(
     service = AssistantService(
         cfg,
         cfg,
         subscriber,
         subscriber,
@@ -95,7 +105,9 @@ def main() -> int:
         chat_reset=chat_reset,
         chat_reset=chat_reset,
         triage=triage,
         triage=triage,
         laya_lane=lane,
         laya_lane=lane,
-        triage_extract_fn=extract_fn,
+        brief_fn=brief_fn,
+        brief_reset=brief_reset,
+        brief_file=brief_file,
     )
     )
     holder["svc"] = service
     holder["svc"] = service
     if session is not None:
     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,
         chat_reset: Callable[[], None] | None = None,
         triage: Any = None,
         triage: Any = None,
         laya_lane: 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.
         """Create the service.
 
 
@@ -80,7 +82,9 @@ class AssistantService:
             chat_reset: Callable clearing the chat session on exit.
             chat_reset: Callable clearing the chat session on exit.
             triage: Triage analyzer (duck-typed); None disables triage.
             triage: Triage analyzer (duck-typed); None disables triage.
             laya_lane: Laya inference lane (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.cfg = cfg
         self._subscriber = subscriber
         self._subscriber = subscriber
@@ -112,7 +116,27 @@ class AssistantService:
 
 
         self._triage = triage
         self._triage = triage
         self._laya_lane = laya_lane
         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_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
         self.last_triage: dict | None = None
         self._last_triage_window: tuple = ()
         self._last_triage_window: tuple = ()
@@ -377,6 +401,7 @@ class AssistantService:
                 return False
                 return False
             self.chat_history = []
             self.chat_history = []
             self._pending_chat = []
             self._pending_chat = []
+            self._chat_queue_full = False
             self._last_chat_activity = self._now()
             self._last_chat_activity = self._now()
         self._emit_mode()
         self._emit_mode()
         return True
         return True
@@ -396,11 +421,19 @@ class AssistantService:
         """Clear the chat history and emit a cleared event (always returns True).
         """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
         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:
         with self._lock:
             self.chat_history = []
             self.chat_history = []
             self._pending_chat = []
             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")
         self._inference.drop(lambda j: j.name == "chat")
         if self.chat_reset is not None:
         if self.chat_reset is not None:
             try:
             try:
@@ -408,7 +441,16 @@ class AssistantService:
             except Exception as e:  # noqa: BLE001 - clearing chat must not fail the operation
             except Exception as e:  # noqa: BLE001 - clearing chat must not fail the operation
                 logger.warning("chat reset failed: %s", e)
                 logger.warning("chat reset failed: %s", e)
         self._emit(CLEARED_EVENT)
         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
         return True
 
 
     def speaker_list(self) -> list[dict]:
     def speaker_list(self) -> list[dict]:
@@ -566,6 +608,22 @@ class AssistantService:
         self.awaited_seg_id = None
         self.awaited_seg_id = None
         self._last_activity_ts = now
         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:
     def _on_transcript(self, event: dict, now: float) -> None:
         text = (event.get("text") or "").strip()
         text = (event.get("text") or "").strip()
         lang = event.get("lang")
         lang = event.get("lang")
@@ -576,6 +634,10 @@ class AssistantService:
         with self._lock:
         with self._lock:
             self.triage_history.append(text)
             self.triage_history.append(text)
             del self.triage_history[:-_TRIAGE_HISTORY_MAX]
             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:
         if self.modes.mode is Mode.COMMAND_ARMED:
             ts = float(event.get("ts", now))
             ts = float(event.get("ts", now))
@@ -630,6 +692,7 @@ class AssistantService:
                 return False
                 return False
             self.chat_history = []
             self.chat_history = []
             self._pending_chat = []
             self._pending_chat = []
+            self._chat_queue_full = False
         self._inference.drop(lambda j: j.name == "chat")
         self._inference.drop(lambda j: j.name == "chat")
         if self.chat_reset is not None:
         if self.chat_reset is not None:
             try:
             try:
@@ -647,11 +710,20 @@ class AssistantService:
             self._pending_chat = []
             self._pending_chat = []
         if not text:
         if not text:
             return
             return
-        self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
         job = InferenceJob(name="chat", run=lambda: self._run_chat(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:
     def _run_chat(self, text: str) -> None:
+        self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
         chat_fn = self.chat_fn
         chat_fn = self.chat_fn
         if chat_fn is None:
         if chat_fn is None:
             return
             return
@@ -703,7 +775,6 @@ class AssistantService:
         with self._lock:
         with self._lock:
             if generation != self._triage_generation:
             if generation != self._triage_generation:
                 return
                 return
-            window = self._last_triage_window
         ts = self._now()
         ts = self._now()
         if self._triage_dump.enabled:
         if self._triage_dump.enabled:
             self._triage_dump.begin(
             self._triage_dump.begin(
@@ -714,111 +785,168 @@ class AssistantService:
                     "error": None if payload is not None else "predict failed",
                     "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
                 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)
             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:
         else:
             self._triage_dump.finalize(ts)
             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
             return
         dump = self._triage_dump.enabled
         dump = self._triage_dump.enabled
         if ts is None:
         if ts is None:
             ts = self._now()
             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:
         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:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     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
                 return
             with self._lock:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     return
-                merged = {**(signals or {}), "content": parsed}
+                merged = {**(signals or {}), "brief": text}
                 self.last_triage = merged
                 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:
         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:
     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:
         if self._triage is None and self._laya_lane is None:
             return
             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:
     def _clear_command(self) -> None:
         self.arm_ts = 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
 Stdlib only. No ML imports here (not even lazily): the dedicated lane module
 owns the model import, background thread, and inference. This module only
 owns the model import, background thread, and inference. This module only
 validates the questions file, condenses transcript windows, normalizes raw
 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.
 constructor.
 
 
 Public API (consumed by the assistant service layer):
 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 build_state(self, window) -> str
         def normalize(self, payload) -> dict
         def normalize(self, payload) -> dict
         def should_escalate(self, signals) -> bool
         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 state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
         def refresh(self, state: str, reason: str = "") -> None
 
 
 ``cfg`` is duck-typed (attribute access only, never imported): it provides
 ``cfg`` is duck-typed (attribute access only, never imported): it provides
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``confidence_field`` (default ``"answer_confidence"``),
 ``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
 import json
@@ -42,16 +45,6 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
 
 
 _VALID_TYPES = ("choice", "score", "noul")
 _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):
 def _invalid(path, qid, why):
     logger.warning("ignoring questions file %r: question %r: %s", 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: likelihood %.2f" % (qid, item.get("p"))
         return "%s: flagged" % (qid,)
         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:
         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 = []
         lines = []
         try:
         try:
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
@@ -362,17 +364,27 @@ class TriageAnalyzer:
                         continue
                         continue
         except Exception:
         except Exception:
             lines = []
             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):
         if not isinstance(text, str):
             return None
             return None
         cleaned = text.strip()
         cleaned = text.strip()
-        if not cleaned or len(cleaned) > _MAX_EXTRACT_INPUT:
+        if not cleaned:
             return None
             return None
         if cleaned.startswith("```"):
         if cleaned.startswith("```"):
             _, _, rest = cleaned.partition("\n")
             _, _, rest = cleaned.partition("\n")
@@ -381,46 +393,34 @@ class TriageAnalyzer:
             if end != -1:
             if end != -1:
                 cleaned = cleaned[:end]
                 cleaned = cleaned[:end]
             cleaned = cleaned.strip()
             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 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]:
     def state(self) -> tuple[str, str]:
         """Return the cached (state, reason) pair; performs no I/O."""
         """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._directory = str(directory)
         self._lock = threading.Lock()
         self._lock = threading.Lock()
         self._pending: dict[float, dict] = {}
         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
     @property
     def enabled(self) -> bool:
     def enabled(self) -> bool:
@@ -99,6 +106,23 @@ class TriageDumper:
             else:
             else:
                 record[key] = value
                 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:
     def _write(self, record: dict) -> None:
         try:
         try:
             ts_raw = record.get("ts")
             ts_raw = record.get("ts")
@@ -108,8 +132,8 @@ class TriageDumper:
             dt = datetime.fromtimestamp(ts)
             dt = datetime.fromtimestamp(ts)
             day_dir = Path(self._directory) / dt.strftime("%Y-%m-%d")
             day_dir = Path(self._directory) / dt.strftime("%Y-%m-%d")
             day_dir.mkdir(parents=True, exist_ok=True)
             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 = {
             payload = {
                 "ts": ts,
                 "ts": ts,
                 "iso": dt.astimezone().isoformat(),
                 "iso": dt.astimezone().isoformat(),

+ 72 - 6
src/stt_daemon/config.py

@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
 import logging
 import logging
 import tomllib
 import tomllib
 from pathlib import Path
 from pathlib import Path
+from typing import Any
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -20,6 +21,7 @@ KNOWN_SECTIONS = {
     "llm",
     "llm",
     "triage",
     "triage",
     "speaker",
     "speaker",
+    "brief",
 }
 }
 
 
 
 
@@ -122,6 +124,8 @@ class LlmConfig:
     max_tokens: int = 1024
     max_tokens: int = 1024
     timeout_s: float = 120.0
     timeout_s: float = 120.0
     cache_prompt: bool = True
     cache_prompt: bool = True
+    chat_slot_id: int | None = None
+    command_slot_id: int | None = None
     max_context_tokens: int = 131072
     max_context_tokens: int = 131072
     compaction_safety_margin: int = 256
     compaction_safety_margin: int = 256
     compaction_enabled: bool = True
     compaction_enabled: bool = True
@@ -134,12 +138,19 @@ class LlmConfig:
         "You compress a conversation. Preserve names, numbers, decisions, user "
         "You compress a conversation. Preserve names, numbers, decisions, user "
         "preferences, and unresolved questions. Omit pleasantries. Output only the summary."
         "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 = (
     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
     inference_queue_size: int = 8
     trim_block_turns: int = 4
     trim_block_turns: int = 4
@@ -169,8 +180,13 @@ class TriageConfig:
     escalate_threshold: float = 0.6
     escalate_threshold: float = 0.6
     choice_min_confidence: float = 0.85
     choice_min_confidence: float = 0.85
     extract_enabled: bool = True
     extract_enabled: bool = True
-    extract_max_tokens: int = 512
+    extract_max_tokens: int = 1536
     extract_timeout_s: float = 30.0
     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 = ""
     device: str = ""
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     default_checkpoint: str = "multilingual"
     default_checkpoint: str = "multilingual"
@@ -196,6 +212,14 @@ class SpeakerConfig:
     join_timeout_s: float = 2.0
     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
 @dataclass
 class SinkConfig:
 class SinkConfig:
     """Virtual audio sink configuration."""
     """Virtual audio sink configuration."""
@@ -220,6 +244,21 @@ class Config:
     llm: LlmConfig = field(default_factory=LlmConfig)
     llm: LlmConfig = field(default_factory=LlmConfig)
     triage: TriageConfig = field(default_factory=TriageConfig)
     triage: TriageConfig = field(default_factory=TriageConfig)
     speaker: SpeakerConfig = field(default_factory=SpeakerConfig)
     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:
 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),
                 max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
                 timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
                 timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
                 cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
                 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=llm_data.get(
                     "max_context_tokens", cfg.llm.max_context_tokens
                     "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=llm_data.get(
                     "compaction_system_prompt", cfg.llm.compaction_system_prompt
                     "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=llm_data.get(
                     "triage_system_prompt", cfg.llm.triage_system_prompt
                     "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=triage_data.get(
                     "extract_timeout_s", cfg.triage.extract_timeout_s
                     "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),
                 device=triage_data.get("device", cfg.triage.device),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 default_checkpoint=triage_data.get(
                 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),
                 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
     return cfg

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

@@ -74,6 +74,7 @@ class AssistantTUI(App):
         self._timer = None
         self._timer = None
         self._last_seg_title = ""
         self._last_seg_title = ""
         self._last_inf_title = ""
         self._last_inf_title = ""
+        self._last_triage_title = ""
 
 
     def compose(self) -> ComposeResult:
     def compose(self) -> ComposeResult:
         yield Static(id="status")
         yield Static(id="status")
@@ -89,8 +90,6 @@ class AssistantTUI(App):
 
 
     def _set_titles(self) -> None:
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
         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)
         self._update_titles(self.state)
 
 
     def _triage_title(self) -> str:
     def _triage_title(self) -> str:
@@ -108,6 +107,14 @@ class AssistantTUI(App):
             self.query_one("#chat", RichLog).border_title = inf_title
             self.query_one("#chat", RichLog).border_title = inf_title
             self._last_inf_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:
     def on_mount(self) -> None:
         self._set_titles()
         self._set_titles()
         try:
         try:

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

@@ -320,19 +320,10 @@ class ViewState:
                     line = f"{qid}{mark}" if flagged else str(qid)
                     line = f"{qid}{mark}" if flagged else str(qid)
                 lines.append(line)
                 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:
         if not lines:
             return ["triage: no signals"]
             return ["triage: no signals"]

+ 581 - 13
tests/test_assistant_integration.py

@@ -13,6 +13,9 @@ from types import SimpleNamespace
 import pytest
 import pytest
 
 
 from stt_daemon import events
 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.runner import build_triage
 from stt_daemon.assistant.service import AssistantService
 from stt_daemon.assistant.service import AssistantService
 from stt_daemon.bus import EventPublisher, EventSubscriber
 from stt_daemon.bus import EventPublisher, EventSubscriber
@@ -110,23 +113,24 @@ class _FakeChatClient:
     def __init__(self):
     def __init__(self):
         self.calls = []
         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(
         self.calls.append(
             {
             {
                 "messages": messages,
                 "messages": messages,
                 "tools": tools,
                 "tools": tools,
                 "max_tokens": max_tokens,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
                 "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():
 def test_build_triage_disabled_returns_nones():
     cfg = Config()
     cfg = Config()
     assert cfg.triage.enabled is False
     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
     assert "laya" not in sys.modules
     service = AssistantService(cfg, subscriber=object())
     service = AssistantService(cfg, subscriber=object())
     assert service.status()["triage_state"] == "disabled"
     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):
 def test_build_triage_enabled_constructs_without_client(tmp_path):
     cfg = _enabled_triage_cfg(tmp_path)
     cfg = _enabled_triage_cfg(tmp_path)
     before = len(threading.enumerate())
     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 analyzer is not None
     assert lane 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 analyzer._cfg is cfg.triage
     assert lane._cfg is cfg.triage
     assert lane._cfg is cfg.triage
     assert analyzer.state() == ("loading", "")
     assert analyzer.state() == ("loading", "")
@@ -146,12 +151,15 @@ def test_build_triage_enabled_constructs_without_client(tmp_path):
     assert "laya" not in sys.modules
     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)
     cfg = _enabled_triage_cfg(tmp_path)
     client = _FakeChatClient()
     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
     assert len(client.calls) == 1
     call = client.calls[0]
     call = client.calls[0]
     assert call["messages"] == [
     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["max_tokens"] == cfg.triage.extract_max_tokens
     assert call["timeout_s"] == cfg.triage.extract_timeout_s
     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):
 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)
     cfg = _enabled_triage_cfg(tmp_path)
     sentinel = object()
     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 analyzer is not None
     assert lane is not None
     assert lane is not None
     assert lane._on_result is sentinel
     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_lanes[0].cfg is cfg.triage
     assert made_services[0].kwargs["laya_lane"] is made_lanes[0]
     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"] 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("control.start") < calls.index("service.start")
     assert calls.index("service.start") < calls.index("lane.start")
     assert calls.index("service.start") < calls.index("lane.start")
     assert calls.index("service.stop") < calls.index("lane.stop")
     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 "lane.stop" not in calls
     assert made_services[0].kwargs["triage"] is None
     assert made_services[0].kwargs["triage"] is None
     assert made_services[0].kwargs["laya_lane"] 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-")]
     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"

Fișier diff suprimat deoarece este prea mare
+ 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.escalate_threshold == 0.6
     assert cfg.triage.choice_min_confidence == 0.85
     assert cfg.triage.choice_min_confidence == 0.85
     assert cfg.triage.extract_enabled is True
     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.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.device == ""
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.default_checkpoint == "multilingual"
     assert cfg.triage.default_checkpoint == "multilingual"
@@ -579,6 +584,170 @@ default_checkpoint = "english"
     assert cfg.triage.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():
 def test_triage_dump_io_defaults_to_false():
     """[triage].dump_io is opt-in: False unless explicitly enabled."""
     """[triage].dump_io is opt-in: False unless explicitly enabled."""
     with tempfile.TemporaryDirectory() as tmpdir:
     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.ok is False
     assert result.result == "par"
     assert result.result == "par"
     assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
     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]
         before = [id(job) for job in queue._queue]
         assert len(before) == 3
         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]
         after = [id(job) for job in queue._queue]
         assert after == before
         assert after == before
@@ -374,7 +374,7 @@ def test_submit_if_room_admits_and_runs_when_room():
 
 
     queue.start()
     queue.start()
     try:
     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 queue.submit_if_room(job) is True
         assert ran.wait(timeout=1.0) is True
         assert ran.wait(timeout=1.0) is True
     finally:
     finally:
@@ -389,7 +389,7 @@ def test_submit_if_room_after_stop_refuses_and_enqueues_nothing():
     queue.start()
     queue.start()
     queue.stop()
     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.submit_if_room(job) is False
     assert queue.depth() == 0
     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
         assert queue.submit(job3) is True
 
 
         # 1 in-flight + 2 queued == size; reserve=1 caps admission at 2.
         # 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 queue.submit_if_room(refused) is False
         assert all(job is not refused for job in queue._queue)
         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()
     queue.stop()
     elapsed = time.monotonic() - begin
     elapsed = time.monotonic() - begin
     assert elapsed < 1.5
     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
     assert queue.depth() == 0
 
 
     release.set()
     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
 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
 from stt_daemon.config import LlmConfig
 
 
 
 
@@ -57,6 +63,47 @@ def test_tools_sent_only_when_provided():
     assert calls[1]["body"]["tool_choice"] == "auto"
     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():
 def test_tool_calls_are_parsed():
     client, _ = _make_client(
     client, _ = _make_client(
         lambda _p: _reply(
         lambda _p: _reply(
@@ -629,3 +676,272 @@ def test_raising_on_io_does_not_break_chat():
     client, _ = _make_client(lambda _p: _reply(content="ok"))
     client, _ = _make_client(lambda _p: _reply(content="ok"))
 
 
     assert client.chat([{"role": "user", "content": "hi"}], on_io=bad).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()
         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):
 def test_load_missing_file_returns_none_with_warning(caplog):
     with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
     with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
         assert load_questions("/nonexistent/questions.json") is None
         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
     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())
     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())
     analyzer = _analyzer(tmp_path, _score_questions())
     for signals in (None, {}, {"order": [], "items": {}}):
     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())
     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 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())
     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())
     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() ------------------------------------------------
 # --- todo 11: cached state() ------------------------------------------------
@@ -554,29 +601,33 @@ class _FakeLane:
 
 
 
 
 def _answers(noul_p=0.1):
 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 = {
     answers = {
-        "category": {
+        "decision_status": {
             "type": "choice",
             "type": "choice",
-            "choice": "sales",
+            "choice": "made",
             "confidence": 0.9,
             "confidence": 0.9,
             "answer_confidence": 0.9,
             "answer_confidence": 0.9,
         },
         },
-        "urgency": {
+        "responsibility_clarity": {
             "type": "choice",
             "type": "choice",
-            "choice": "low",
+            "choice": "explicit",
             "confidence": 0.9,
             "confidence": 0.9,
             "answer_confidence": 0.9,
             "answer_confidence": 0.9,
         },
         },
     }
     }
     for qid in (
     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] = {
         answers[qid] = {
             "type": "noul",
             "type": "noul",
@@ -591,8 +642,11 @@ def test_end_to_end_triage_flow():
     """signals -> content -> cleared across service, analyzer, lane, and ViewState.
     """signals -> content -> cleared across service, analyzer, lane, and ViewState.
 
 
     The lane result is wired by calling `service._on_triage_result(gen, payload)`
     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 = Config()
     cfg.assistant.turn_gap_s = 0.5
     cfg.assistant.turn_gap_s = 0.5
@@ -601,6 +655,7 @@ def test_end_to_end_triage_flow():
     clock = _Clock(1000.0)
     clock = _Clock(1000.0)
     lane = _FakeLane()
     lane = _FakeLane()
     emitted: list[dict] = []
     emitted: list[dict] = []
+    prompts: list[str] = []
 
 
     def emit(type_, **fields):
     def emit(type_, **fields):
         emitted.append({"type": type_, **fields})
         emitted.append({"type": type_, **fields})
@@ -614,14 +669,14 @@ def test_end_to_end_triage_flow():
         chat_fn=lambda text: "reply",
         chat_fn=lambda text: "reply",
         triage=analyzer,
         triage=analyzer,
         laya_lane=lane,
         laya_lane=lane,
-        triage_extract_fn=lambda prompt: EXTRACT_JSON,
+        brief_fn=lambda prompt: prompts.append(prompt) or "BRIEF-TEXT",
     )
     )
 
 
     def triage_events():
     def triage_events():
         return [e for e in emitted if e["type"] == "triage"]
         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
     # 1. settled turn -> exactly one lane submit, lang + generation recorded
     assert service.toggle_chat() is True
     assert service.toggle_chat() is True
@@ -637,15 +692,16 @@ def test_end_to_end_triage_flow():
     assert lang == "es"
     assert lang == "es"
     assert gen1 == 0
     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))
     service._on_triage_result(gen1, _answers(noul_p=0.1))
     assert [e["stage"] for e in triage_events()] == ["signals"]
     assert [e["stage"] for e in triage_events()] == ["signals"]
     assert triage_events()[0]["triage"]["escalated"] is False
     assert triage_events()[0]["triage"]["escalated"] is False
     assert service.last_triage is not None
     assert service.last_triage is not None
     assert service.last_triage["escalated"] is False
     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(
     service.handle_event(
         {"type": events.TRANSCRIPT, "text": "quiero cancelar mi suscripcion", "lang": "es"}
         {"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))
     service._on_triage_result(gen2, _answers(noul_p=0.9))
     assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
     assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
     assert triage_events()[1]["triage"]["escalated"] is True
     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"]
     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"]
     assert merged["order"] == triage_events()[1]["triage"]["order"]
 
 
     # 5. drive the emitted stream through a fresh ViewState
     # 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
     analyzer.refresh("ok")  # the lane callback does this in production
     view.ingest({"type": "status", **service.status()})
     view.ingest({"type": "status", **service.status()})
     assert view.triage is not None
     assert view.triage is not None
-    assert view.triage["content"]["summary"] == "s"
+    assert view.triage["brief"] == "BRIEF-TEXT"
     lines = view.triage_lines()
     lines = view.triage_lines()
     assert 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
     # 6. clear -> cleared event empties the panel and bumps the epoch
     baseline_epoch = view.triage_epoch
     baseline_epoch = view.triage_epoch
@@ -696,7 +754,6 @@ def test_end_to_end_triage_flow():
         "signals",
         "signals",
         "signals",
         "signals",
         "content",
         "content",
-        "content",
         "cleared",
         "cleared",
     ]
     ]
     assert view.triage is None
     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):
 def test_disabled_writes_nothing(tmp_path):
     dumper = TriageDumper(False, directory=str(tmp_path))
     dumper = TriageDumper(False, directory=str(tmp_path))
     assert dumper.enabled is False
     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 triage.parent is main
             assert chat.parent is main
             assert chat.parent is main
             assert transcript.parent is not 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.
             # triage sits LEFT of chat inside #main.
             assert main.children.index(triage) < main.children.index(chat)
             assert main.children.index(triage) < main.children.index(chat)
             # Outside widgets stay outside #main.
             # 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))
             observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
 
 
     asyncio.run(scenario())
     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 any("topic: planning (0.91)" in line for line in observed["default_lines"])
     assert observed["questions_title"] == "questions"
     assert observed["questions_title"] == "questions"
     rendered = "\n".join(observed["questions_lines"])
     rendered = "\n".join(observed["questions_lines"])
@@ -1738,12 +1738,117 @@ def test_s_key_again_restores_triage_view(server):
 
 
     asyncio.run(scenario())
     asyncio.run(scenario())
     assert observed["mid_title"] == "questions"
     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"])
     rendered = "\n".join(observed["triage_lines"])
     assert "topic: planning (0.91)" in rendered
     assert "topic: planning (0.91)" in rendered
     assert "What is the main theme?" not 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):
 def test_s_key_is_noop_when_triage_disabled(server):
     """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
     """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
     pytest.importorskip("textual")
     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
     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 = ViewState()
     state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
     state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
     epoch = state.triage_epoch
     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})
     state.ingest({"type": "triage", "stage": "content", "triage": merged})
     assert state.triage == merged
     assert state.triage == merged
     assert state.triage_epoch == epoch + 1
     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"
     assert lines[3] == "q_unknown"
 
 
 
 
-def test_triage_lines_content_block_appended():
+def test_triage_lines_brief_appended_line_by_line():
     state = ViewState()
     state = ViewState()
     state.triage = {
     state.triage = {
         "order": ["q1"],
         "order": ["q1"],
         "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
         "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"
     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():
 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)"]
     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 = ViewState()
     state.ingest(
     state.ingest(
         {
         {
@@ -640,14 +665,15 @@ def test_lone_content_event_renders_without_status_snapshot():
                 "items": {
                 "items": {
                     "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
                     "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
                 },
                 },
-                "content": {"summary": "wants action"},
+                "brief": "wants action",
             },
             },
         }
         }
     )
     )
     assert state.triage_state == "ok"
     assert state.triage_state == "ok"
     lines = state.triage_lines()
     lines = state.triage_lines()
     assert lines[0] == "category: question (0.91)"
     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():
 def test_cleared_event_after_lone_signals_leaves_state_usable():

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff