The triage panel is a display-only signal readout in the assistant TUI. On each settled
turn (no chat mode required) — and on each chat turn when the send to triage toggle is on —
the assistant classifies the most recent utterances (transcribed speech, plus labeled chat
turns when enabled) with a local Laya multilingual model running in-process, then folds the turn
into a living plain-text brief on the existing [llm] lane through a dedicated brief
conversation. Signals and brief land in a right-hand pane. Nothing here triggers a mode, a
command, or a tool: the panel only shows what the model inferred.
The feature is off by default ([triage].enabled = false), and the extra it needs is
opt-in, so a plain uv sync and the existing test suite behave exactly as before.
[triage].enabled = true. With the default
false there is no widget at all.signals after a settled turn: one line per question, rendered from the normalized
answer.
choice: the selected label plus the gating confidence, for example 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.⚠ mark follows any item the analyzer flagged.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).ok, for example when the model is still loading
or the questions.json file is missing. The panel never renders blank.HH:MM:SS format and bold cyan style.clear empties the pane. Leaving chat mode does not.The panel is a view over events (stage of signals, content, or cleared) plus the
cached triage_state / triage_reason / questions in status(). It never feeds anything
back into the assistant.
Pressing s in the TUI swaps the pane to a structured view of the loaded questions.json
and pressing it again swaps back. The view renders one block per qid in file order — the
qid, its type, the instructions, and the criteria (choice labels, a label-to-description
map, ordered score levels, or the true/false descriptions of a noul question). The
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.
settled turn (any mode), every periodic_s while speech keeps accumulating, or a chat turn
-> newest utterance window (window entries, condensed under state_char_budget;
transcribed speech, plus labeled chat turns when send-to-triage is on)
-> Laya multilingual classification (in-process lane)
-> 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
turn_ended(): speech stopped
plus quiet for turn_gap_s). Because continuous speech never produces a settled turn,
triage also re-runs every periodic_s seconds (default 30) while new utterances keep
arriving: the timer arms on the first transcript of a burst and re-arms on every accepted
submit, so a long monologue is classified roughly every periodic_s instead of only when
it stops. Set periodic_s = 0 to disable the periodic trigger and require a settled turn.
A repeated window is skipped, so an identical set of utterances never re-gates. When the
TUI's send to triage toggle is on (see ASSISTANT.md), each chat turn also
forces a pass through the same guards; the assistant reply is appended, plus the user's
message when send transcripts is off.window entries: transcribed utterances from the STT
stream, plus labeled chat turns (You:/Assistant:) when send to triage is on — never
the raw chat conversation history. build_state condenses it newest-first
under state_char_budget and restores chronological order, with - prefixed lines.predict at a time in the background. The raw answers come back on the lane
thread, and the analyzer normalizes them into signals ({order, items, escalated,
input_tokens}). Stale generations are dropped before the
service stores or emits anything.should_escalate is true when any one of three arms fires:
noul: p >= escalate_thresholdchoice: the gating confidence is < choice_min_confidencescore: expected >= (levels - 1) * 0.5, where levels is the number of score
criteria
Escalation only drives the per-item flagged flag and the ⚠ marker in the panel;
it drives neither the gate nor the brief.brief-update job on
the existing LLM lane. The job carries the speaker-labeled pending deltas (the transcripts,
plus labeled chat turns when send to triage is on, 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. Each Focus: line carries the
question instructions (clipped to ~160 chars), the measured value (p, selected
choice, or expected/level/label), the gating confidence, and flagged /
[escalated] markers — e.g. - blocker (p=0.91, flagged, gate=0.90): An obstacle ...
or - decision_status (choice): selected made (conf 0.90) — What is the state ...?
Options: a decision was reached. Low-value noul noise (p < 0.30) is omitted,
except substantive_update which is always kept for gate context; choice/score
items are always kept with their uncertainty flagged. The brief engine is stateless: it
sends exactly two messages per call — a static system prompt and ONE user turn containing
CURRENT LEDGER (the recorded facts), CURRENT BRIEF (the volatile narrative), and
NEW UTTERANCES (the deltas). No conversation history is replayed, so the prompt stays
flat instead of growing and the model never competes with stale copies of its own output.
The model returns exactly two sections: NEW FACTS: (only genuinely-new durable facts, one
per - line, or none) and BRIEF: (the volatile narrative: Summary, Opportunities,
Blockers/Risks, Decisions, Open Questions). extract_enabled is the brief engine's master
switch: with it false the panel still classifies and shows signals, but no brief is
requested. The rendered 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.FactLedger
appends the reply's NEW FACTS: with conservative dedupe (exact normalized match, or a
fuzzy near-match only when its numbers and entities are identical), evicts the oldest
facts past ledger_max_facts (logged), and truncates each fact past fact_max_chars. It
then renders the final brief as the narrative plus a Prior Details: section. A recorded
fact can therefore only disappear via a logged cap eviction — never because the model
dropped it, and never because it wrote prose instead of bullets. Resolved work is never
deleted: the prompt requires completed actions / answered questions to move to Decisions
as Done: <original> — resolved: <what happened> plus a matching NEW FACTS: bullet,
and restore_missing_bullets re-appends any - bullet the model still dropped as a
backstop. The guarantee covers
recorded facts: a fact the model never lists under NEW FACTS: was never recorded. The
ledger resets on clear and on a new session, alongside the rest of the triage state.NEW FACTS: is emitted before BRIEF: so a token-cap cutoff loses
narrative (regenerated every pass) rather than a durable fact; the final fact line is
discarded when the reply reports finish_reason == "length".ConversationEngine, so there is no
history to compact and [llm].brief_compaction_system_prompt is unused by the brief
path. [llm] compaction settings still govern the chat/command session.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. A new conversation also
resets the brief, which starts fresh in the new session folder, without clearing chat or
command state.Only one brief-update job may exist at a time. The service tracks a token and an
outstanding flag under its lock:
submit_if_room, so a full queue never evicts a queued chat
turn just to run a brief."" 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.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.
The assistant has exactly three single-worker lanes, and you should not parallelize any of them:
| lane | what runs on it |
|---|---|
| transcription | one worker; faster-whisper is not thread-safe |
LLM InferenceQueue |
one worker; all chat, command, and brief-update calls |
| Laya | one worker plus one loader thread; one predict at a time |
The triage feature adds no process, no UNIX socket, and no supervisor child. The Laya part
is a dedicated in-process lane with a background loader thread (lazy import, preload, one
throwaway warmup predict) and a single-flight worker. That keeps the 0.1 s tick and the LLM
lane unblocked, which multiplexing Laya onto the InferenceQueue would not. The supervisor
still spawns 2 children headless and 3 with the TUI.
The brief engine runs on the shared LLM lane, so its work is visible in the TUI: the triage
pane border shows [inf N] with the queue depth and · busy while a job is running, the
same counter the chat/command pane shows. Brief jobs are non-evictable, so a full queue
refuses the brief instead of dropping a queued chat turn.
Triage reads a bare Laya questions dict: a JSON object whose keys are question ids
(qid) and whose values are question definitions.
{
"category": {
"type": "choice",
"instructions": "What is the user mainly talking about?",
"criteria": {
"support": "the user needs help",
"sales": "the user asks about pricing",
"other": "anything else"
}
},
"urgency": {
"type": "choice",
"instructions": "How urgent does the user sound?",
"criteria": ["low", "medium", "high"]
},
"churn_risk": {
"type": "noul",
"instructions": "The user is thinking about leaving or cancelling."
}
}
Each definition needs type and instructions. criteria depends on the type:
| type | criteria |
rules |
|---|---|---|
choice |
dict or list, required | non-empty; a dict maps each label to a description |
score |
list, required | non-empty; must not contain null; entries are the level labels in order |
noul |
dict, optional | if present, keys must be only "true" and/or "false" (case-insensitive); omit it for a plain yes/no question |
Anything else fails validation. The whole file is rejected (and triage reports
no_questions) when the top level is not a non-empty object, when any definition is not an
object, when type is not one of choice / score / noul, when instructions is
missing, or when the criteria above are malformed. load_questions returns None
instead of raising, so a bad file never takes the assistant down.
A ready-made file ships in the repo:
cp questions.example.json 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).
The Laya dependency lives behind an optional extra, pinned to one exact revision:
# 1. Install the triage extra (adds laya)
uv sync --extra triage
# 2. Provide the questions file
cp questions.example.json questions.json
# 3. Turn it on
# [triage]
# enabled = true
The extra (triage = ["laya"]) resolves through [tool.uv.sources], which pins Laya to
revision 970dc8c5f63d7b886a68409493f37d569424f933 (version 0.3.20). This revision is
recorded in the plan and asserted by the measurement test below. The pin is exact on
purpose: Laya is an early project, and API drift between revisions is real.
The multilingual checkpoint is pinned for every call (default_checkpoint =
"multilingual", preload = ["multilingual"], and a per-request model="multilingual").
There is no English-only .en variant and none is selected. Run triage-enabled from the
normal supervised flow, uv run stt-daemon up --with-tui; no extra command is needed.
Because the multilingual checkpoint is pinned, every classification uses it. Multilingual
runs at temperature 1.0 upstream, so its confidence scores are uncalibrated out of the
box. The default thresholds (escalate_threshold = 0.6, choice_min_confidence = 0.85)
are starting points, not measured values. Measure on your own transcripts before trusting
the ⚠ flagging (the thresholds drive neither the gate nor the brief), and lower or raise the
thresholds to match what you observe.
The measurement lives in tests/test_laya_lane.py::test_multilingual_pin_lang_and_resources,
gated by pytest.importorskip("laya"), so the default suite skips it green and the extra
enables it. With the extra installed:
uv run pytest tests/test_laya_lane.py::test_multilingual_pin_lang_and_resources -v -s
That test builds a multilingual Router, runs one lang="es" predict, and records the wall
latency, router.loaded, and the process RSS delta, and it checks the installed Laya
version and pin. The numbers it prints are environment-specific (CPU, GPU, drivers, disk),
so they are not repeated here; read them from your own run. The test asserts only sanity
and the pin, never a benchmark target.
All keys live under [triage] unless noted. The defaults are what you get with no config
file.
| key | default | meaning |
|---|---|---|
enabled |
false |
Master gate. false means no lane, no panel, and behavior identical to before. |
questions_file |
"questions.json" |
Laya questions JSON in the working directory. Missing or invalid means no_questions. |
window |
500 |
Recent utterance entries available per classification (transcripts, plus labeled chat turns when send-to-triage is on); the char budget is the real limiter. |
state_char_budget |
3000 |
Character cap (~750 tokens) for the condensed state sent to Laya. |
periodic_s |
30.0 |
Accumulating-speech cadence in seconds: while new utterances keep arriving without a settled turn, triage re-runs every periodic_s; <= 0 disables the periodic trigger. |
predict_max_tokens |
1024 |
Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room). |
confidence_field |
"answer_confidence" |
Which Laya confidence feeds the per-item flagged/⚠ marker: answer_confidence (calibrated max(p)) or confidence (normalized entropy). |
escalate_threshold |
0.6 |
A noul answer at or above this p is flagged (⚠ marker only). |
choice_min_confidence |
0.85 |
A choice answer below this confidence is flagged (⚠ marker only). |
extract_enabled |
true |
Master switch for the brief engine: with it false the panel classifies but no brief is requested. |
extract_max_tokens |
3072 |
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. |
extract_temperature |
0.2 |
Temperature for the brief request (deterministic extraction; chat keeps [llm].temperature). |
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. |
ledger_max_facts |
200 |
Cap on code-owned durable facts; the oldest are evicted (logged) past this. |
fact_max_chars |
240 |
Per-fact character cap; over-long facts are truncated. |
device |
"" |
Empty string means auto, which maps to None so Laya picks the checkpoint's default device. Any other value is passed through. |
preload |
["multilingual"] |
Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. |
default_checkpoint |
"multilingual" |
Router default and the per-request model for every call. Multilingual is pinned. |
dump_io |
false |
Opt-in I/O dump under troubleshoot/triage/YYYY-MM-DD/HHMMSS-mmm.json; a coalesced update writes two files (see below). |
[llm].triage_system_prompt drives the brief engine: it is sent as the static system
message, and the user turn carries the code-owned fact ledger (CURRENT LEDGER), the volatile
narrative (CURRENT BRIEF), and the new segment. The model must return exactly two sections:
NEW FACTS: (only genuinely-new durable facts, one per - line, or none) and BRIEF: (the
volatile narrative). It never rewrites the ledger. Code appends new facts with conservative
dedupe, evicts the oldest past ledger_max_facts, truncates each past fact_max_chars, and
renders the final brief. The reply is bounded by extract_max_tokens, extract_timeout_s,
and extract_temperature (default 0.2), then the rendered brief is truncated to brief_max_chars. Because the brief engine keeps no
conversation history, [llm].brief_compaction_system_prompt and the [llm] compaction
settings do not apply to it; they remain in effect for the chat/command session.
[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 <session_dir>/<id>/brief.md inside
the active conversation folder (falling back to brief/brief.md, cwd-relative and gitignored,
when no session is active) and truncated on clear. A new conversation resets the brief, so
each session folder gets a fresh one.
dump_io)With [triage].dump_io = true (default false) each triage writes a self-contained JSON
file under troubleshoot/triage/YYYY-MM-DD/HHMMSS-mmm.json — local time with millisecond precision, the
same naming scheme as the audio files (<session_dir>/<id>/audio/HHMMSS-mmm-<reason>.wav). A file
holds the Laya request, the Laya response, the LLM request (endpoint URL plus the full
chat-completion body), the LLM response (raw envelope), and the LLM round-trip time, keyed by
the ts carried on the triage events:
{
"ts": 1759073916.19,
"iso": "2026-09-28T09:38:36.412000-06:00",
"laya": {"request": {...}, "response": {...}, "error": null},
"llm": {"request": {"url": "...", "body": {...}}, "response": {...}, "error": null, "duration_ms": 812.4}
}
llm.duration_ms is the wall-clock time spent in the LLM transport call (monotonic clock,
millisecond precision), recorded on both success and failure. It measures only the request
round-trip, not prompt building or reply parsing.
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 troubleshoot/triage/ (cwd-relative, like the session folders) —
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.
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 3072) is the brief reply cap,
extract_timeout_s is the brief request timeout, and extract_temperature (default 0.2)
is the brief request temperature.[llm].triage_system_prompt is now a plain-text brief instruction; the shipped default no
longer asks for JSON.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.An earlier draft of this feature used an external service with its own transaction keys.
Those keys are not [triage] keys and are silently ignored by load_config because it
reads each field with a per-field .get():
| stale key | status |
|---|---|
socket |
no successor |
model |
no successor; the checkpoint is pinned to default_checkpoint |
predict_timeout_s |
no successor |
checkpoint |
superseded by default_checkpoint |
subfolder |
no successor |
max_len |
no successor; the 8192-token long-document capability is out of scope |
Remove them from config.toml to avoid confusion, but they will not change behavior if left
in place. There is also no act_probability anywhere; the per-item flagged/⚠ marker uses the
gating confidence selected by confidence_field.