Efren Yevale Varela 1 mēnesi atpakaļ
vecāks
revīzija
aba1ff2d51

+ 1 - 0
.gitignore

@@ -11,6 +11,7 @@ models/
 .ruff_cache/
 .pytest_cache/
 config.toml
+questions.json
 *.egg-info/
 .eggs/
 dist/

+ 12 - 3
AGENTS.md

@@ -9,6 +9,7 @@ Always-on speech-to-text daemon plus a separate voice-assistant layer. Python 3.
 
 ```bash
 uv sync                                                  # install into .venv (see pywhispercpp below)
+uv sync --extra triage                                   # optional: in-process Laya triage lane
 uv run pytest -v                                         # full suite (hardware- and network-free)
 uv run pytest tests/test_segmenter.py::test_name -v      # single test
 uv run ruff check .                                      # only static check (no mypy/pyright)
@@ -33,9 +34,12 @@ Three processes wired by UNIX sockets under `$XDG_RUNTIME_DIR` (fallback `/tmp`)
 TUI startup is mandatory (no silent headless fallback: socket timeout or early child exit kills all),
 and the supervisor restores the tty's `ISIG` on exit so a killed TUI doesn't leave Ctrl+C dead.
 
-Stateful logic belongs in the assistant. Two single-worker-thread lanes exist by design — do not
-parallelize either: transcription (faster-whisper isn't thread-safe) and all LLM calls
-(`assistant/inference.py` `InferenceQueue`; commands submit with priority `-1` to preempt chat).
+Stateful logic belongs in the assistant. Three single-worker-thread lanes exist by design, and none
+may be parallelized: transcription (faster-whisper isn't thread-safe), all LLM calls
+(`assistant/inference.py` `InferenceQueue`; commands submit with priority `-1` to preempt chat), and
+the **Laya triage lane** (`assistant/laya_lane.py`: one background thread plus one loader, running
+in-process in the assistant). The Laya lane is **not** a process, **not** a socket, and does not
+change supervisor spawn counts (2 without the TUI, 3 with it).
 
 `stt-daemon` with no subcommand runs the daemon (legacy `--check` / `--list-sources` still work).
 
@@ -50,6 +54,9 @@ parallelize either: transcription (faster-whisper isn't thread-safe) and all LLM
   *inside* a known section are silently ignored (`dict.get` per field).
 - Env vars: `STT_DAEMON_STATE` (provisioning state; default `audio/audio.json` in the cwd),
   `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface).
+- `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its
+  `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like
+  `config.toml`.
 - Never treat the live `config.toml` as a source of defaults; it is gitignored user state.
 
 ## Transcription backends
@@ -70,6 +77,8 @@ parallelize either: transcription (faster-whisper isn't thread-safe) and all LLM
   `executor`, `audit`.
 - No `conftest.py` — per-file fakes are the convention. Tests are the behavioral spec; read them
   before changing behavior.
+- The default suite is green without the optional `triage` extra. Install it (`uv sync --extra
+  triage`) only to exercise the Laya measurement test, which otherwise skips.
 
 ## Conventions & gotchas
 

+ 15 - 0
README.md

@@ -68,8 +68,13 @@ uv sync
 
 # (Optional) If you want to use the sounddevice backend (requires portaudio):
 uv sync --extra portaudio
+
+# (Optional) Enable the triage panel (installs the pinned in-process Laya lane):
+uv sync --extra triage
 ```
 
+The `triage` extra is opt-in: the default sync does not install Laya, and the test suite stays green without it.
+
 ## Quick Start
 
 Copy the example config and edit as needed:
@@ -78,6 +83,15 @@ Copy the example config and edit as needed:
 cp config.example.toml config.toml
 ```
 
+To turn on the triage panel, also copy the questions file and enable the section:
+
+```bash
+cp questions.example.json questions.json
+# then set `enabled = true` under `[triage]` in config.toml
+```
+
+`questions.json` is private user state and is gitignored, like `config.toml`. See [docs/TRIAGE.md](docs/TRIAGE.md) for the panel and the questions format.
+
 Then launch the whole system with one command:
 
 ```bash
@@ -100,6 +114,7 @@ See [docs/CLI.md](docs/CLI.md) for every subcommand and [docs/CONFIGURATION.md](
 - [docs/AUDIO-SETUP.md](docs/AUDIO-SETUP.md) — virtual sink creation and audio routing options.
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
+- [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, escalation cascade, and questions file.
 - [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/DEVELOPMENT.md](docs/DEVELOPMENT.md) — code structure, testing, linting, and packaging.

+ 16 - 0
config.example.toml

@@ -89,3 +89,19 @@ 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."
+
+[triage]
+enabled              = false
+questions_file       = "questions.json"
+window               = 8
+state_char_budget    = 1200
+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"

+ 20 - 0
docs/ASSISTANT.md

@@ -118,6 +118,26 @@ llama.cpp server. Commands jump ahead of chat turns; when the queue is full an e
 with `error{code:"busy"}` and the oldest queued chat turn is dropped. Leaving chat mode discards
 queued chat turns.
 
+## 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)).
+
+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**
+checkpoint is pinned for every call. This is the third single-worker lane: transcription
+(faster-whisper, not thread-safe), the LLM `InferenceQueue`, and the Laya triage lane. None of the
+three may be parallelized.
+
+On each settled turn in any mode (idle, chat, or command; `turn_ended()`), independent of chat,
+the lane classifies the last `window` transcribed utterances from the STT stream (not the chat
+history). Results flow through a **`signals -> content`** cascade: the signals stage
+always arrives first (per-category confidences), and when they escalate the assistant 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. The panel renders both; it is display-only and triggers
+no modes, commands, or tools. Leaving chat mode does not clear the panel or its buffer; an explicit
+`clear` does.
+
 ## TUI keys
 
 `a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `c` clear · `q` quit

+ 37 - 0
docs/CONFIGURATION.md

@@ -113,6 +113,22 @@ trim_block_turns         = 4                # fallback block trimming, not per-t
 max_tool_iterations      = 3
 system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
+triage_system_prompt     = "You extract structured signals from a spoken conversation. Output only a JSON object with exactly these keys: {\"summary\": str, \"opportunities\": [str], \"doubts\": [str], \"action_items\": [{\"what\": str, \"why\": str}], \"risks\": [str]}. Output no prose and no markdown fences. Use empty lists when a category is empty."
+
+[triage]                                    # Display-only signal panel (Laya lane; no socket, no extra process)
+enabled              = false                # Master gate: false = no lane, no panel, behavior identical to today
+questions_file       = "questions.json"     # Laya questions JSON in the cwd; missing/invalid -> no_questions
+window               = 8                    # Recent transcribed utterances condensed for each classification
+state_char_budget    = 1200                 # Char cap for the condensed state sent to Laya
+confidence_field     = "answer_confidence"  # Gating confidence: "answer_confidence" (calibrated max(p)) | "confidence"
+escalate_threshold   = 0.6                  # noul answers at or above this p escalate to extraction
+choice_min_confidence = 0.85                # choice answers below this confidence escalate
+extract_enabled      = true                 # Cascade to the LLM JSON extraction when signals escalate
+extract_max_tokens   = 512                  # Extraction reply token cap
+extract_timeout_s    = 30.0                 # Extraction request timeout
+device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
+preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
+default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
 ```
 
 `backend` picks the transcription engine. `faster-whisper` is the default, so nothing changes
@@ -127,5 +143,26 @@ the ggml quantization is chosen by the model name (e.g. `small`, `small-q5_1`).
 `max_context_tokens - max_tokens - compaction_safety_margin`, so the window and the trim budget
 can never disagree. A legacy `max_prompt_tokens` key is ignored with a warning.
 
+`[triage]` adds a display-only signal panel. On each settled turn in any mode, independent of
+chat, the assistant classifies the last `window` transcribed utterances (from the STT stream,
+not the chat history) with a local Laya **multilingual** checkpoint on a dedicated
+in-process lane, then, when signals escalate, asks the `[llm]` lane for a structured JSON
+extraction (`extract_enabled`). `device = ""` means auto: it maps to `None`, so Laya picks the
+default device for the multilingual checkpoint. `preload = ["multilingual"]` is the fixed default
+and `default_checkpoint = "multilingual"`; the multilingual checkpoint is pinned for every call
+(there is no `.en` English-only variant). `confidence_field` selects which of Laya's two
+confidence values gates escalation, with `answer_confidence` (the calibrated `max(p)`) as the
+default. With `enabled = false` (the default) the lane is never built and the system behaves
+exactly as before.
+
+`[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.
+
+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`
+(per-field `.get()`). `checkpoint` is superseded by `default_checkpoint`; the others have no
+successor. The 8192-token long-document `max_len` capability is out of scope for this feature.
+
 For audio source selection, virtual sink creation, and audio routing, see
 [AUDIO-SETUP.md](AUDIO-SETUP.md).

+ 243 - 0
docs/TRIAGE.md

@@ -0,0 +1,243 @@
+# Triage Panel
+
+The triage panel is a display-only signal readout in the assistant TUI. On each settled
+turn (no chat mode required) the assistant classifies the most recent transcribed
+utterances with a local Laya **multilingual** model running in-process, and, when the
+signals look strong enough, 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.
+
+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.
+
+## What the panel shows
+
+- **A right-hand TUI pane**, composed only when `[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 `category: sales (0.91)`.
+  - `noul`: a ten-cell bar filled from `p`, the probability, for example `███████   churn_risk: 0.72`.
+  - `score`: a ten-cell bar filled from the clamped integer level plus the level label.
+  - A `⚠` mark follows any item the analyzer flagged.
+- **`content`** after the LLM extraction: the parsed JSON is merged over the signals as a
+  `content` key, and the panel appends the `summary` plus up to three entries each from
+  `opportunities`, `risks`, and `action_items`.
+- **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.
+- **Cleared on reset**: `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` in `status()`. It never feeds anything back into
+the assistant.
+
+## How the cascade works
+
+```
+settled turn (any mode)
+    -> newest transcribed-utterance window (window entries, condensed under state_char_budget)
+    -> Laya multilingual classification (in-process lane)
+    -> signals  ------------------------------------------------+
+    -> should_escalate? and extract_enabled?                    |
+         -> one triage-extract job on the LLM InferenceQueue    |
+              -> parsed JSON merged as content ------------------+
+```
+
+1. **Trigger.** After the chat dispatch block in the assistant tick, triage runs on any
+   settled turn in any mode (idle, chat, or command), independent of chat. It runs when
+   the triage components are wired and the turn has ended (`turn_ended()`: speech stopped
+   plus quiet for `turn_gap_s`). A repeated window is skipped, so an identical set of
+   utterances never re-gates.
+2. **Window.** The window is the newest `window` transcribed utterances from the STT
+   stream, not the chat conversation history. `build_state` condenses it newest-first
+   under `state_char_budget` and restores chronological order, with `- ` prefixed lines.
+3. **Classification.** The condensed state, the loaded questions, the last transcript
+   language, and the current triage generation go to the Laya lane. The lane runs one
+   multilingual `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.
+4. **Escalation.** `should_escalate` is true when any one of three arms fires:
+   - `noul`: `p >= escalate_threshold`
+   - `choice`: the gating confidence is `< choice_min_confidence`
+   - `score`: `expected >= (levels - 1) * 0.5`, where `levels` is the number of score
+     criteria
+5. **Extraction.** When the signals escalated, extraction is enabled, and an extract
+   function is configured, the service submits **one** `triage-extract` job to the existing
+   LLM `InferenceQueue`. 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.
+
+## Lanes: nothing is parallelized
+
+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 `triage-extract` 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.
+
+## Questions file
+
+Triage reads a bare Laya `questions` dict: a JSON object whose keys are question ids
+(`qid`) and whose values are question definitions.
+
+### Schema
+
+```json
+{
+  "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.
+
+### Getting a valid file
+
+A ready-made file ships in the repo:
+
+```bash
+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`).
+
+## Install and enable
+
+The Laya dependency lives behind an optional extra, pinned to one exact revision:
+
+```bash
+# 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.
+
+## Multilingual pin and the calibration caveat
+
+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 escalation behavior, 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:
+
+```bash
+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.
+
+## Configuration reference
+
+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` | `8` | Number of recent transcribed utterances condensed for each classification. |
+| `state_char_budget` | `1200` | Character cap for the condensed state sent to Laya. |
+| `confidence_field` | `"answer_confidence"` | Which Laya confidence gates escalation: `answer_confidence` (calibrated `max(p)`) or `confidence` (normalized entropy). |
+| `escalate_threshold` | `0.6` | A `noul` answer at or above this `p` escalates to extraction. |
+| `choice_min_confidence` | `0.85` | A `choice` answer below this confidence escalates. |
+| `extract_enabled` | `true` | Cascade to the LLM JSON extraction when signals escalate. |
+| `extract_max_tokens` | `512` | Token cap for the extraction reply. |
+| `extract_timeout_s` | `30.0` | Timeout for the extraction request. |
+| `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. |
+
+`[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.
+
+## Upgrading from a v5-draft config
+
+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; escalation uses the gating confidence
+selected by `confidence_field`.

+ 3 - 0
pyproject.toml

@@ -23,12 +23,15 @@ dependencies = [
 
 [project.optional-dependencies]
 portaudio = ["sounddevice>=0.4.6"]
+# In-process Laya multilingual triage lane: `uv sync --extra triage`, pinned in [tool.uv.sources].
+triage = ["laya"]
 
 [dependency-groups]
 dev = ["pytest>=8", "ruff"]
 
 [tool.uv.sources]
 pywhispercpp = { path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" }
+laya = { git = "https://github.com/NandhaKishorM/laya.git", rev = "970dc8c5f63d7b886a68409493f37d569424f933" }
 
 [project.scripts]
 stt-daemon = "stt_daemon.cli:main"

+ 46 - 0
questions.example.json

@@ -0,0 +1,46 @@
+{
+  "category": {
+    "type": "choice",
+    "instructions": "What is the user mainly talking about?",
+    "criteria": {
+      "support": "the user needs help with something that is broken or unclear",
+      "sales": "the user asks about pricing, plans, or buying",
+      "feedback": "the user shares an opinion about the product or service",
+      "other": "anything that does not fit the labels above"
+    }
+  },
+  "urgency": {
+    "type": "choice",
+    "instructions": "How urgent does the user sound?",
+    "criteria": ["low", "medium", "high"]
+  },
+  "frustration": {
+    "type": "noul",
+    "instructions": "The user sounds frustrated or annoyed.",
+    "criteria": {"true": "frustrated, annoyed, impatient", "false": "calm, neutral, pleased"}
+  },
+  "churn_risk": {
+    "type": "noul",
+    "instructions": "The user is thinking about leaving or cancelling."
+  },
+  "needs_action": {
+    "type": "noul",
+    "instructions": "The user asks for something concrete to be done."
+  },
+  "needs_clarification": {
+    "type": "noul",
+    "instructions": "The request is ambiguous and needs a follow-up question."
+  },
+  "contains_idea": {
+    "type": "noul",
+    "instructions": "The user proposes a new idea or feature."
+  },
+  "business_opportunity": {
+    "type": "noul",
+    "instructions": "The conversation hints at a business opportunity worth following up."
+  },
+  "positive_sentiment": {
+    "type": "noul",
+    "instructions": "The user sounds happy or satisfied."
+  }
+}

+ 25 - 0
src/stt_daemon/assistant/inference.py

@@ -85,6 +85,31 @@ class InferenceQueue:
             self._cond.notify_all()
         return True
 
+    def submit_if_room(self, job: InferenceJob, reserve: int = 1) -> bool:
+        """Append a job only if there is room; never drops an existing job.
+
+        Admission is atomic under the existing condition lock. Capacity counts
+        the in-flight job plus queued jobs, and the job is admitted only while
+        ``capacity_used < size - reserve``. ``reserve`` keeps slots free for
+        higher-priority work, so a ``reserve`` >= ``size`` makes admission
+        always refuse (it never crashes). Unlike ``submit()``, this never evicts
+        an existing job.
+
+        Returns:
+            True when the job was appended, False when stopped or full.
+        """
+        with self._cond:
+            if self._stop.is_set():
+                return False
+
+            capacity_used = (1 if self._in_flight else 0) + len(self._queue)
+            if capacity_used >= self.size - reserve:
+                return False
+
+            self._queue.append(job)
+            self._cond.notify_all()
+            return True
+
     def drop(self, predicate: Callable[[InferenceJob], bool]) -> int:
         """Remove queued jobs matching the predicate.
 

+ 226 - 0
src/stt_daemon/assistant/laya_lane.py

@@ -0,0 +1,226 @@
+"""In-process Laya triage lane: background loader + single-flight worker.
+
+Public API (consumed by the assistant service, todos 12-16, and the runner,
+todo 17)::
+
+    def device_or_none(value: str) -> str | None
+    class LayaLane:
+        def __init__(self, cfg, agent_factory=None, on_result=None,
+                     on_state=None) -> None
+        def start(self) -> None
+        def ready(self) -> bool
+        def state(self) -> tuple[str, str]
+        def submit(self, state: str, questions: dict,
+                   lang: str | None, generation: int) -> bool
+        def drop_pending(self) -> None
+        def stop(self) -> None
+
+``cfg`` is duck-typed; only ``cfg.device``, ``cfg.preload`` and
+``cfg.default_checkpoint`` are read (tests pass a ``SimpleNamespace``).
+
+Design notes:
+
+- No top-level ``import laya`` / ``import torch``: the router is built inside
+  the loader thread, either via the injected ``agent_factory`` (tests) or via
+  a lazy ``from laya import Router`` (production).
+- One daemon loader thread resolves the router (ctor, ``preload()``,
+  throwaway warmup ``predict``) then flips ready. Any failure is captured
+  into ``state()``; ``start()`` itself never raises.
+- One daemon worker thread runs at most one ``router.predict`` at a time
+  (single-flight). ``submit()`` is non-blocking and replaces the single
+  pending slot (newest wins). Results are delivered via
+  ``on_result(generation, payload)`` on the lane thread, exactly once per
+  executed predict (``None`` payload on predict exception).
+- ``on_state(state, reason)`` is an optional 4th ctor param and fires exactly
+  once when the loader resolves (success or failure).
+- ``stop()`` disables callbacks, notifies, and joins both threads with a
+  1.0 s timeout each; no callback fires after ``stop()`` returns.
+"""
+
+import logging
+import threading
+from typing import Any, Callable, Optional
+
+logger = logging.getLogger(__name__)
+
+LAYA_MISSING_HINT = "run: uv sync --extra triage"
+
+_WARMUP_QUESTIONS = {"_warmup": {"type": "noul", "instructions": "warmup"}}
+_WARMUP_STATE = "warmup"
+
+
+def device_or_none(value: str) -> Optional[str]:
+    """Return ``None`` for ``""`` (auto device), else ``value`` unchanged."""
+    return None if value == "" else value
+
+
+class LayaLane:
+    """Single-flight background lane owning the Laya router."""
+
+    def __init__(
+        self,
+        cfg: Any,
+        agent_factory: Optional[Callable[..., Any]] = None,
+        on_result: Optional[Callable[[int, Optional[dict]], None]] = None,
+        on_state: Optional[Callable[[str, str], None]] = None,
+    ) -> None:
+        self._cfg = cfg
+        self._agent_factory = agent_factory
+        self._on_result = on_result
+        self._on_state = on_state
+        self._lock = threading.Lock()
+        self._cond = threading.Condition(self._lock)
+        self._stop = threading.Event()
+        self._live = True  # False after stop(): suppress all further callbacks
+        self._loader: Optional[threading.Thread] = None
+        self._worker: Optional[threading.Thread] = None
+        self._router: Any = None
+        self._state: tuple = ("loading", "")
+        self._pending: Optional[tuple] = None
+
+    def start(self) -> None:
+        """Spawn loader + worker daemon threads. Never raises."""
+        try:
+            with self._lock:
+                if self._loader is not None:
+                    return
+                self._loader = threading.Thread(
+                    target=self._loader_body, name="laya-loader", daemon=True
+                )
+                self._worker = threading.Thread(
+                    target=self._worker_loop, name="laya-worker", daemon=True
+                )
+            self._worker.start()
+            self._loader.start()
+        except Exception as e:  # noqa: BLE001 - start() must never raise
+            logger.error("LayaLane start failed: %s", e)
+
+    def ready(self) -> bool:
+        """Return True once the loader resolved successfully."""
+        with self._lock:
+            return self._state[0] == "ok"
+
+    def state(self) -> tuple:
+        """Return ``(status, reason)`` where status is one of loading/ok/laya_missing/error."""
+        with self._lock:
+            return self._state
+
+    def submit(
+        self, state: str, questions: dict, lang: Optional[str], generation: int
+    ) -> bool:
+        """Store ``(state, questions, lang, generation)`` in the pending slot.
+
+        Non-blocking newest-wins: replaces any pending request. Returns False
+        (dropping silently) when the lane is stopped, failed, or not yet ready.
+        """
+        with self._cond:
+            if self._stop.is_set() or not self._live:
+                return False
+            if self._state[0] != "ok" or self._router is None:
+                return False
+            self._pending = (state, questions, lang, generation)
+            self._cond.notify_all()
+            return True
+
+    def drop_pending(self) -> None:
+        """Clear the pending slot (the in-flight predict, if any, still finishes)."""
+        with self._cond:
+            self._pending = None
+
+    def stop(self) -> None:
+        """Disable callbacks, wake both threads, join each with a 1.0 s timeout."""
+        with self._cond:
+            self._live = False
+            self._stop.set()
+            self._pending = None
+            self._cond.notify_all()
+        for thread in (self._loader, self._worker):
+            if thread is not None:
+                try:
+                    thread.join(timeout=1.0)
+                except Exception:  # noqa: BLE001 - best-effort shutdown
+                    pass
+
+    # -- internals ------------------------------------------------------
+
+    def _build_router(self) -> Any:
+        cfg = self._cfg
+        kwargs = {
+            "default": cfg.default_checkpoint,
+            "device": device_or_none(cfg.device),
+        }
+        if self._agent_factory is not None:
+            return self._agent_factory(**kwargs)
+        from laya import Router  # noqa: PLC0415 - lazy: keeps laya out of sys.modules
+
+        return Router(**kwargs)
+
+    def _resolve(self, status: str, reason: str) -> None:
+        with self._lock:
+            self._state = (status, reason)
+            fire = self._live and self._on_state is not None
+            callback = self._on_state if fire else None
+        if callback is not None:
+            try:
+                callback(status, reason)
+            except Exception as e:  # noqa: BLE001 - callback must not break the lane
+                logger.error("LayaLane on_state failed: %s", e)
+
+    def _loader_body(self) -> None:
+        try:
+            router = self._build_router()
+            router.preload(list(self._cfg.preload))
+            router.predict(
+                _WARMUP_STATE,
+                _WARMUP_QUESTIONS,
+                model=self._cfg.default_checkpoint,
+            )
+        except ImportError as e:
+            logger.warning("LayaLane: laya not installed: %s", e)
+            self._resolve("laya_missing", LAYA_MISSING_HINT)
+            return
+        except Exception as e:  # noqa: BLE001 - loader failure must not escape
+            logger.error("LayaLane loader failed: %s", e)
+            self._resolve("error", str(e) or repr(e))
+            return
+        with self._lock:
+            self._router = router
+            self._state = ("ok", "")
+            fire = self._live and self._on_state is not None
+            callback = self._on_state if fire else None
+        if callback is not None:
+            try:
+                callback("ok", "")
+            except Exception as e:  # noqa: BLE001 - callback must not break the lane
+                logger.error("LayaLane on_state failed: %s", e)
+
+    def _worker_loop(self) -> None:
+        while True:
+            with self._cond:
+                while self._pending is None and not self._stop.is_set():
+                    self._cond.wait()
+                if self._stop.is_set():
+                    return
+                assert self._pending is not None
+                state, questions, lang, generation = self._pending
+                self._pending = None
+                router = self._router
+            try:
+                payload = router.predict(
+                    state,
+                    questions,
+                    model=self._cfg.default_checkpoint,
+                    lang=lang,
+                )
+            except Exception as e:  # noqa: BLE001 - one bad predict gets None payload
+                logger.error("LayaLane predict failed: %s", e)
+                payload = None
+            with self._lock:
+                if not self._live:
+                    return
+                callback = self._on_result
+            if callback is not None:
+                try:
+                    callback(generation, payload)
+                except Exception as e:  # noqa: BLE001 - callback must not kill the lane
+                    logger.error("LayaLane on_result failed: %s", e)

+ 46 - 0
src/stt_daemon/assistant/runner.py

@@ -15,6 +15,37 @@ from .service import AssistantService
 from .tools import default_registry
 
 
+def build_triage(cfg, client, on_result=None):
+    """Build (analyzer, lane, extract_fn); (None, None, None) when disabled."""
+    if not cfg.triage.enabled:
+        return (None, None, None)
+    from .laya_lane import LayaLane
+    from .triage import TriageAnalyzer
+
+    analyzer = TriageAnalyzer(cfg.triage)
+    lane = LayaLane(
+        cfg.triage,
+        on_state=lambda state, reason: analyzer.refresh(state, reason),
+        on_result=on_result,
+    )
+    extract = None
+    if client is not None:
+        def _extract(prompt):
+            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,
+            )
+            return resp.content
+
+        extract = _extract
+
+    return (analyzer, lane, extract)
+
+
 def main() -> int:
     """Entry point for the `stt-assistant` process."""
     parser = argparse.ArgumentParser(
@@ -37,6 +68,7 @@ def main() -> int:
     chat_fn = None
     chat_reset = None
     session = None
+    client = None
     if cfg.llm.enabled:
         client = LlmClient(cfg.llm)
         session = ChatSession(cfg.llm, client)
@@ -44,6 +76,12 @@ def main() -> int:
         chat_fn = session.send
         chat_reset = session.reset
 
+    holder = {}
+    triage, lane, extract_fn = build_triage(
+        cfg,
+        client if cfg.llm.enabled else None,
+        on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
+    )
     service = AssistantService(
         cfg,
         subscriber,
@@ -51,7 +89,11 @@ def main() -> int:
         executor=executor,
         chat_fn=chat_fn,
         chat_reset=chat_reset,
+        triage=triage,
+        laya_lane=lane,
+        triage_extract_fn=extract_fn,
     )
+    holder["svc"] = service
     if session is not None:
         session.notify = service.chat_notify
     control = ControlServer(cfg, service)
@@ -59,6 +101,8 @@ def main() -> int:
 
     control.start()
     service.start()
+    if lane is not None:
+        lane.start()
     logging.info("Assistant ready. Control socket: %s", control.path)
 
     stop = threading.Event()
@@ -71,5 +115,7 @@ def main() -> int:
     stop.wait()
 
     service.stop()
+    if lane is not None:
+        lane.stop()
     control.close()
     return 0

+ 127 - 1
src/stt_daemon/assistant/service.py

@@ -8,7 +8,7 @@ Phase 3 control socket will call.
 import logging
 import threading
 import time
-from typing import Callable
+from typing import Any, Callable
 
 from .. import events
 from ..config import Config
@@ -25,6 +25,7 @@ TOOL_CALL_EVENT = "tool_call"
 TOOL_RESULT_EVENT = "tool_result"
 CHAT_MESSAGE_EVENT = "chat_message"
 CLEARED_EVENT = "cleared"
+TRIAGE_EVENT = "triage"
 
 
 class _ExecuteBusy(str):
@@ -39,6 +40,8 @@ READY = "ready"
 EXECUTING = "executing"
 RELAYED_TYPES = (events.TRANSCRIPT, events.SPEECH_START, events.SPEECH_STOP)
 
+_TRIAGE_HISTORY_MAX = 200
+
 
 class AssistantService:
     """Maintains assistant mode, command text, and readiness from STT events."""
@@ -54,6 +57,9 @@ class AssistantService:
         relay: Callable[[dict], None] | None = None,
         chat_fn: Callable[[str], str] | None = None,
         chat_reset: Callable[[], None] | None = None,
+        triage: Any = None,
+        laya_lane: Any = None,
+        triage_extract_fn: Callable[[str], str] | None = None,
     ):
         """Create the service.
 
@@ -67,6 +73,9 @@ class AssistantService:
             relay: Sink for relayed STT events (`transcript`, `speech_*`).
             chat_fn: Callable sending one chat turn and returning the reply.
             chat_reset: Callable clearing the chat session on exit.
+            triage: Triage analyzer (duck-typed); None disables triage.
+            laya_lane: Laya inference lane (duck-typed); None disables triage.
+            triage_extract_fn: Callable turning an extraction prompt into raw text.
         """
         self.cfg = cfg
         self._subscriber = subscriber
@@ -91,6 +100,16 @@ class AssistantService:
         # Chat session (volatile)
         self.chat_history: list[str] = []
         self._pending_chat: list[str] = []
+        self.triage_history: list[str] = []
+
+        self._triage = triage
+        self._laya_lane = laya_lane
+        self._triage_extract_fn = triage_extract_fn
+        self._last_triage_state: tuple[str, str] | None = None
+        self.last_triage: dict | None = None
+        self._last_triage_window: tuple = ()
+        self._triage_generation = 0
+        self._last_transcript_lang: str | None = None
 
         # Health
         self._last_heartbeat_ts: float | None = None
@@ -186,6 +205,10 @@ class AssistantService:
 
     def status(self) -> dict:
         """Snapshot for the control socket (`status`)."""
+        if self._triage is not None:
+            triage_state, triage_reason = self._triage.state()
+        else:
+            triage_state, triage_reason = "disabled", ""
         return {
             "mode": self.modes.mode.value,
             "ready": self.ready(),
@@ -197,6 +220,9 @@ class AssistantService:
             "chat_turns": len(self.chat_history),
             "inference_depth": self._inference.depth(),
             "inference_in_flight": self._inference.in_flight(),
+            "triage_state": triage_state,
+            "triage_reason": triage_reason,
+            "triage": self.last_triage,
         }
 
     # ------------------------------------------------------------------ actions
@@ -371,6 +397,7 @@ class AssistantService:
             except Exception as e:  # noqa: BLE001 - clearing chat must not fail the operation
                 logger.warning("chat reset failed: %s", e)
         self._emit(CLEARED_EVENT)
+        self._reset_triage()
         return True
 
     # ------------------------------------------------------------------ events
@@ -418,9 +445,22 @@ class AssistantService:
             and self.turn_ended()
         ):
             self._dispatch_chat()
+        if self._triage is not None and self._laya_lane is not None:
+            self._maybe_triage()
+        self._maybe_emit_triage_status()
         self._emit_heartbeat(now)
         self._emit_command_text()
 
+    def _maybe_emit_triage_status(self) -> None:
+        """Broadcast a status event when the triage state/reason changes."""
+        if self._triage is None:
+            return
+        current = self._triage.state()
+        if current == self._last_triage_state:
+            return
+        self._last_triage_state = current
+        self._emit("status", **self.status())
+
     def _emit_heartbeat(self, now: float) -> None:
         if now - self._last_heartbeat_emit < 1.0:
             return
@@ -446,9 +486,14 @@ class AssistantService:
 
     def _on_transcript(self, event: dict, now: float) -> None:
         text = (event.get("text") or "").strip()
+        lang = event.get("lang")
+        self._last_transcript_lang = lang if isinstance(lang, str) else None
         if not text:
             return
         self._last_activity_ts = now
+        with self._lock:
+            self.triage_history.append(text)
+            del self.triage_history[:-_TRIAGE_HISTORY_MAX]
 
         if self.modes.mode is Mode.COMMAND_ARMED:
             ts = float(event.get("ts", now))
@@ -536,6 +581,87 @@ class AssistantService:
         if reply:
             self._emit(CHAT_MESSAGE_EVENT, role="assistant", text=reply)
 
+    def _maybe_triage(self) -> None:
+        """Classify the recent transcribed utterances on any settled turn (independent of chat)."""
+        if not self.turn_ended():
+            return
+        questions = self._triage.questions
+        if not questions:
+            return
+        if not self._laya_lane.ready():
+            return
+        with self._lock:
+            window = tuple(self.triage_history[-int(self.cfg.triage.window):])
+            if not window or window == self._last_triage_window:
+                return
+            generation = self._triage_generation
+            self._last_triage_window = window
+        state = self._triage.build_state(window)
+        if not state:
+            return
+        self._laya_lane.submit(state, questions, self._last_transcript_lang, generation)
+
+    def _on_triage_result(self, generation, payload) -> None:
+        if self._triage is None or payload is None:
+            return
+        with self._lock:
+            if generation != self._triage_generation:
+                return
+            signals = self._triage.normalize(payload)
+            if not signals:
+                return
+            window = self._last_triage_window
+            self.last_triage = signals
+        self._emit(TRIAGE_EVENT, stage="signals", triage=signals)
+        if (
+            signals.get("escalated")
+            and self._triage_extract_fn is not None
+            and getattr(self.cfg.triage, "extract_enabled", True)
+        ):
+            job = InferenceJob(
+                name="triage-extract",
+                run=lambda: self._run_triage_extract(generation, window, signals),
+            )
+            self._inference.submit_if_room(job)
+
+    def _run_triage_extract(self, generation, window, signals) -> None:
+        if self._triage is None or self._triage_extract_fn is None:
+            return
+        with self._lock:
+            if generation != self._triage_generation:
+                return
+        prompt = self._triage.build_extraction_prompt(window, signals)
+        try:
+            text = self._triage_extract_fn(prompt)
+        except Exception as e:  # noqa: BLE001 - an extract failure must not kill the worker
+            logger.error("Triage extract error: %s", e)
+            return
+        with self._lock:
+            if generation != self._triage_generation:
+                return
+        parsed = self._triage.parse_extraction(text)
+        if parsed is None:
+            return
+        with self._lock:
+            if generation != self._triage_generation:
+                return
+            merged = {**signals, "content": parsed}
+            self.last_triage = merged
+        self._emit(TRIAGE_EVENT, stage="content", triage=merged)
+
+    def _reset_triage(self) -> None:
+        if self._triage is None and self._laya_lane is None:
+            return
+        with self._lock:
+            self._triage_generation += 1
+            self.last_triage = None
+            self._last_triage_window = ()
+            self.triage_history = []
+        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)
+
     def _clear_command(self) -> None:
         self.arm_ts = None
         self.command_text = ""

+ 435 - 0
src/stt_daemon/assistant/triage.py

@@ -0,0 +1,435 @@
+"""Triage analyzer: pure, import-light classification helpers.
+
+Stdlib only. No ML imports here (not even lazily): the dedicated lane module
+owns the model import, background thread, and inference. This module only
+validates the questions file, condenses transcript windows, normalizes raw
+answers into display items, decides escalation, and builds/parses the optional
+structured extraction. The only I/O is the one-time questions file read in the
+constructor.
+
+Public API (consumed by the assistant service layer):
+
+    TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing",
+                      "disabled", "error")
+
+    def load_questions(path: str) -> dict | None
+
+    class TriageAnalyzer:
+        def __init__(self, cfg) -> None
+        @property
+        def questions(self) -> dict | None
+        def build_state(self, window) -> str
+        def normalize(self, payload) -> dict
+        def should_escalate(self, signals) -> bool
+        def build_extraction_prompt(self, window, signals) -> str
+        def parse_extraction(self, text) -> dict | None
+        def state(self) -> tuple[str, str]
+        def refresh(self, state: str, reason: str = "") -> None
+
+``cfg`` is duck-typed (attribute access only, never imported): it provides
+``questions_file``, ``state_char_budget`` (default 1200),
+``confidence_field`` (default ``"answer_confidence"``),
+``escalate_threshold`` (default 0.6) and ``choice_min_confidence``
+(default 0.85).
+"""
+
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "error")
+
+_VALID_TYPES = ("choice", "score", "noul")
+
+_MAX_EXTRACT_LIST = 5
+_MAX_EXTRACT_STR = 500
+_MAX_EXTRACT_INPUT = 20000
+
+_EXTRACTION_INSTRUCTION = (
+    "Reply with strict JSON only, no prose and no markdown outside the JSON object, "
+    'using exactly these keys: {"summary": str, "opportunities": [str], '
+    '"doubts": [str], "action_items": [{"what": str, "why": str}], "risks": [str]}.'
+)
+
+
+def _invalid(path, qid, why):
+    logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
+    return None
+
+
+def load_questions(path: str) -> dict | None:
+    """Load and validate a bare Laya questions dict; None (never raise) on failure."""
+    try:
+        with open(path, encoding="utf-8") as handle:
+            data = json.load(handle)
+    except (OSError, ValueError, UnicodeError) as exc:
+        logger.warning("ignoring questions file %r: cannot read/parse (%s)", path, exc)
+        return None
+    if not isinstance(data, dict) or not data:
+        logger.warning("ignoring questions file %r: top level must be a non-empty object", path)
+        return None
+    for qid, qdef in data.items():
+        if not isinstance(qdef, dict):
+            return _invalid(path, qid, "definition must be an object")
+        qtype = qdef.get("type")
+        if qtype not in _VALID_TYPES:
+            return _invalid(
+                path, qid, "unknown type %r; use one of %s" % (qtype, sorted(_VALID_TYPES))
+            )
+        if "instructions" not in qdef:
+            return _invalid(path, qid, "no 'instructions'; add the text the model should answer")
+        crit = qdef.get("criteria")
+        if qtype == "choice":
+            if not isinstance(crit, (dict, list)):
+                return _invalid(path, qid, "a choice question takes 'criteria' as a dict or list")
+            if not crit:
+                return _invalid(path, qid, "a choice question needs at least one criterion")
+        elif qtype == "score":
+            if not isinstance(crit, list):
+                return _invalid(path, qid, "a score question takes 'criteria' as a list")
+            if not crit:
+                return _invalid(path, qid, "a score question needs at least one level")
+            if None in crit:
+                return _invalid(path, qid, "score criteria must not contain null")
+        elif crit is not None:
+            if not isinstance(crit, dict):
+                return _invalid(path, qid, "a noul question takes 'criteria' as a dict or omits it")
+            keys = {str(key).lower() for key in crit}
+            if not keys <= {"true", "false"}:
+                return _invalid(
+                    path, qid, "a noul question takes 'criteria' keyed only 'true'/'false'"
+                )
+    return data
+
+
+def _number(value):
+    if isinstance(value, bool):
+        return None
+    try:
+        result = float(value)
+    except (TypeError, ValueError):
+        return None
+    return result
+
+
+class TriageAnalyzer:
+    """Pure analyzer over a duck-typed triage config (see module docstring)."""
+
+    def __init__(self, cfg) -> None:
+        self._cfg = cfg
+        path = getattr(cfg, "questions_file", "questions.json")
+        try:
+            questions = load_questions(path)
+        except Exception as exc:  # load_questions never raises; belt and braces
+            logger.warning("ignoring questions file %r: unexpected error (%s)", path, exc)
+            questions = None
+        self._questions = questions
+        if questions is None:
+            self._state = "no_questions"
+            self._reason = "no questions loaded from %s" % (path,)
+        else:
+            self._state = "loading"
+            self._reason = ""
+
+    @property
+    def questions(self) -> dict | None:
+        return self._questions
+
+    def _budget(self) -> int:
+        try:
+            return int(getattr(self._cfg, "state_char_budget", 1200))
+        except (TypeError, ValueError):
+            return 1200
+
+    def _confidence_field(self) -> str:
+        return getattr(self._cfg, "confidence_field", "answer_confidence")
+
+    def _escalate_threshold(self) -> float:
+        try:
+            return float(getattr(self._cfg, "escalate_threshold", 0.6))
+        except (TypeError, ValueError):
+            return 0.6
+
+    def _choice_min_confidence(self) -> float:
+        try:
+            return float(getattr(self._cfg, "choice_min_confidence", 0.85))
+        except (TypeError, ValueError):
+            return 0.85
+
+    def build_state(self, window) -> str:
+        """Condense oldest->newest utterances newest-first, restore order, "- " lines."""
+        utterances = [str(item) for item in (window or [])]
+        if not utterances:
+            return ""
+        budget = self._budget()
+        rendered = ["- " + text for text in utterances]
+        kept = []
+        total = 0
+        for line in reversed(rendered):
+            extra = len(line) + (1 if kept else 0)
+            if kept and total + extra > budget:
+                break
+            kept.append(line)
+            total += extra
+        kept.reverse()
+        return "\n".join(kept)
+
+    def _normalize_item(self, qid, qdef, answer):
+        if not isinstance(answer, dict):
+            return None
+        qtype = qdef.get("type")
+        field = self._confidence_field()
+        if qtype == "choice":
+            label = answer.get("choice")
+            if not isinstance(label, str):
+                return None
+            crit = qdef.get("criteria")
+            if isinstance(crit, dict):
+                options = list(crit.keys())
+            elif isinstance(crit, list):
+                options = list(crit)
+            else:
+                return None
+            if options and label not in options:
+                return None
+            confidence = _number(answer.get("confidence"))
+            answer_confidence = _number(answer.get("answer_confidence"))
+            if confidence is None or answer_confidence is None:
+                return None
+            gate = confidence if field == "confidence" else answer_confidence
+            return {
+                "kind": "choice",
+                "choice": label,
+                "confidence": confidence,
+                "answer_confidence": answer_confidence,
+                "gate": gate,
+                "flagged": gate < self._choice_min_confidence(),
+            }
+        if qtype == "score":
+            expected = _number(answer.get("score"))
+            crit = qdef.get("criteria")
+            if expected is None or not isinstance(crit, list) or not crit:
+                return None
+            level = max(0, min(len(crit) - 1, round(expected)))
+            label = None
+            legend = answer.get("legend")
+            if isinstance(legend, dict) and str(level) in legend:
+                label = legend[str(level)]
+            else:
+                label = crit[level]
+            confidence = _number(answer.get("confidence"))
+            answer_confidence = _number(answer.get("answer_confidence"))
+            if confidence is None or answer_confidence is None:
+                return None
+            gate = confidence if field == "confidence" else answer_confidence
+            return {
+                "kind": "score",
+                "expected": expected,
+                "level": level,
+                "label": str(label),
+                "confidence": confidence,
+                "answer_confidence": answer_confidence,
+                "gate": gate,
+                "flagged": expected >= (len(crit) - 1) * 0.5,
+            }
+        if qtype == "noul":
+            prob = _number(answer.get("noul"))
+            if prob is None:
+                return None
+            confidence = _number(answer.get("confidence"))
+            answer_confidence = _number(answer.get("answer_confidence"))
+            if confidence is None or answer_confidence is None:
+                return None
+            gate = confidence if field == "confidence" else answer_confidence
+            return {
+                "kind": "noul",
+                "p": prob,
+                "confidence": confidence,
+                "answer_confidence": answer_confidence,
+                "gate": gate,
+                "flagged": prob >= self._escalate_threshold(),
+            }
+        return None
+
+    def normalize(self, payload) -> dict:
+        """Normalize a full laya result or a bare answers dict into signals."""
+        result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
+        if not isinstance(payload, dict):
+            return result
+        raw_answers = payload.get("answers")
+        if isinstance(raw_answers, dict):
+            answers = raw_answers
+            usage = payload.get("usage")
+            if isinstance(usage, dict):
+                tokens = _number(usage.get("input_tokens", 0))
+                result["input_tokens"] = int(tokens) if tokens is not None else 0
+        else:
+            answers = payload
+        questions = self._questions or {}
+        for qid in questions:
+            if qid not in answers:
+                continue
+            try:
+                item = self._normalize_item(qid, questions[qid], answers[qid])
+            except Exception:
+                continue
+            if item is None:
+                continue
+            result["order"].append(qid)
+            result["items"][qid] = item
+        result["escalated"] = self.should_escalate(result)
+        return result
+
+    def _score_levels(self, qid):
+        questions = self._questions or {}
+        qdef = questions.get(qid)
+        if not isinstance(qdef, dict):
+            return None
+        crit = qdef.get("criteria")
+        if not isinstance(crit, list) or not crit:
+            return None
+        return len(crit)
+
+    def should_escalate(self, signals) -> bool:
+        """True iff any arm fires: noul p, low choice gate, or high score expected."""
+        items = signals.get("items", {}) if isinstance(signals, dict) else {}
+        if not isinstance(items, dict):
+            return False
+        for qid, item in items.items():
+            if not isinstance(item, dict):
+                continue
+            kind = item.get("kind")
+            if kind == "noul":
+                prob = _number(item.get("p"))
+                if prob is not None and prob >= self._escalate_threshold():
+                    return True
+            elif kind == "choice":
+                gate = _number(item.get("gate"))
+                if gate is not None and gate < self._choice_min_confidence():
+                    return True
+            elif kind == "score":
+                expected = _number(item.get("expected"))
+                if expected is None:
+                    continue
+                levels = self._score_levels(qid)
+                if levels is None:
+                    if item.get("flagged") is True:
+                        return True
+                elif expected >= (levels - 1) * 0.5:
+                    return True
+        return False
+
+    def _signal_words(self, qid, item) -> str:
+        kind = item.get("kind")
+        if kind == "choice":
+            return "%s (choice): selected %s, confidence %.2f" % (
+                qid,
+                item.get("choice"),
+                item.get("gate"),
+            )
+        if kind == "score":
+            return "%s (score): expected %.2f, level %s (%s)" % (
+                qid,
+                item.get("expected"),
+                item.get("level"),
+                item.get("label"),
+            )
+        if kind == "noul":
+            return "%s: likelihood %.2f" % (qid, item.get("p"))
+        return "%s: flagged" % (qid,)
+
+    def build_extraction_prompt(self, window, signals) -> str:
+        """Render condensed window + flagged signals as words + JSON-only instruction."""
+        state_text = self.build_state(window)
+        items = signals.get("items", {}) if isinstance(signals, dict) else {}
+        order = signals.get("order", []) if isinstance(signals, dict) else []
+        if not isinstance(items, dict):
+            items = {}
+        if not isinstance(order, list):
+            order = []
+        flagged = []
+        for qid in order:
+            item = items.get(qid)
+            if isinstance(item, dict) and item.get("flagged") is True:
+                try:
+                    flagged.append("- " + self._signal_words(qid, item))
+                except Exception:
+                    continue
+        return (
+            "Recent user utterances:\n"
+            + state_text
+            + "\n\nFlagged signals:\n"
+            + ("\n".join(flagged) if flagged else "none")
+            + "\n\n"
+            + _EXTRACTION_INSTRUCTION
+        )
+
+    def parse_extraction(self, text) -> dict | None:
+        """Parse fenced-or-bare JSON extraction; cap lists/strings; None on failure."""
+        if not isinstance(text, str):
+            return None
+        cleaned = text.strip()
+        if not cleaned or len(cleaned) > _MAX_EXTRACT_INPUT:
+            return None
+        if cleaned.startswith("```"):
+            _, _, rest = cleaned.partition("\n")
+            cleaned = rest if rest else ""
+            end = cleaned.rfind("```")
+            if end != -1:
+                cleaned = cleaned[:end]
+            cleaned = cleaned.strip()
+        try:
+            data = json.loads(cleaned)
+        except (json.JSONDecodeError, ValueError):
+            return None
+        if not isinstance(data, dict):
+            return None
+        try:
+            summary = data["summary"]
+            opportunities = data["opportunities"]
+            doubts = data["doubts"]
+            action_items = data["action_items"]
+            risks = data["risks"]
+            if not isinstance(summary, str):
+                return None
+            for group in (opportunities, doubts, risks):
+                if not isinstance(group, list):
+                    return None
+                if not all(isinstance(entry, str) for entry in group):
+                    return None
+            if not isinstance(action_items, list):
+                return None
+            for entry in action_items:
+                if not isinstance(entry, dict):
+                    return None
+                if not isinstance(entry.get("what"), str) or not isinstance(entry.get("why"), str):
+                    return None
+        except (KeyError, TypeError, AttributeError):
+            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]],
+        }
+
+    def state(self) -> tuple[str, str]:
+        """Return the cached (state, reason) pair; performs no I/O."""
+        return (self._state, self._reason)
+
+    def refresh(self, state: str, reason: str = "") -> None:
+        """Explicit transition; keeps no_questions; ignores unknown states."""
+        if self._questions is None:
+            return
+        if state not in TRIAGE_REASONS:
+            logger.warning("ignoring unknown triage state %r", state)
+            return
+        self._state = state
+        self._reason = reason if isinstance(reason, str) else str(reason)

+ 65 - 0
src/stt_daemon/config.py

@@ -18,6 +18,7 @@ KNOWN_SECTIONS = {
     "assistant",
     "audit",
     "llm",
+    "triage",
 }
 
 
@@ -132,6 +133,13 @@ class LlmConfig:
         "You compress a conversation. Preserve names, numbers, decisions, user "
         "preferences, and unresolved questions. Omit pleasantries. Output only the summary."
     )
+    triage_system_prompt: str = (
+        "You extract structured signals from a spoken conversation. Output only a JSON "
+        "object with exactly these keys: "
+        '{"summary": str, "opportunities": [str], "doubts": [str], '
+        '"action_items": [{"what": str, "why": str}], "risks": [str]}. '
+        "Output no prose and no markdown fences. Use empty lists when a category is empty."
+    )
     inference_queue_size: int = 8
     trim_block_turns: int = 4
     max_tool_iterations: int = 3
@@ -148,6 +156,24 @@ class LlmConfig:
         return self.max_context_tokens - self.max_tokens - self.compaction_safety_margin
 
 
+@dataclass
+class TriageConfig:
+    """Laya triage-panel configuration (in-process lane, multilingual pinned)."""
+    enabled: bool = False
+    questions_file: str = "questions.json"
+    window: int = 8
+    state_char_budget: int = 1200
+    confidence_field: str = "answer_confidence"
+    escalate_threshold: float = 0.6
+    choice_min_confidence: float = 0.85
+    extract_enabled: bool = True
+    extract_max_tokens: int = 512
+    extract_timeout_s: float = 30.0
+    device: str = ""
+    preload: list[str] = field(default_factory=lambda: ["multilingual"])
+    default_checkpoint: str = "multilingual"
+
+
 @dataclass
 class SinkConfig:
     """Virtual audio sink configuration."""
@@ -170,6 +196,7 @@ class Config:
     assistant: AssistantConfig = field(default_factory=AssistantConfig)
     audit: AuditConfig = field(default_factory=AuditConfig)
     llm: LlmConfig = field(default_factory=LlmConfig)
+    triage: TriageConfig = field(default_factory=TriageConfig)
 
 
 def load_config(path: str | None = None) -> Config:
@@ -396,6 +423,9 @@ def load_config(path: str | None = None) -> Config:
                 compaction_system_prompt=llm_data.get(
                     "compaction_system_prompt", cfg.llm.compaction_system_prompt
                 ),
+                triage_system_prompt=llm_data.get(
+                    "triage_system_prompt", cfg.llm.triage_system_prompt
+                ),
                 inference_queue_size=llm_data.get(
                     "inference_queue_size", cfg.llm.inference_queue_size
                 ),
@@ -413,4 +443,39 @@ def load_config(path: str | None = None) -> Config:
                 ),
             )
 
+        # Triage section
+        if "triage" in data:
+            triage_data = data["triage"]
+            cfg.triage = TriageConfig(
+                enabled=triage_data.get("enabled", cfg.triage.enabled),
+                questions_file=triage_data.get("questions_file", cfg.triage.questions_file),
+                window=triage_data.get("window", cfg.triage.window),
+                state_char_budget=triage_data.get(
+                    "state_char_budget", cfg.triage.state_char_budget
+                ),
+                confidence_field=triage_data.get(
+                    "confidence_field", cfg.triage.confidence_field
+                ),
+                escalate_threshold=triage_data.get(
+                    "escalate_threshold", cfg.triage.escalate_threshold
+                ),
+                choice_min_confidence=triage_data.get(
+                    "choice_min_confidence", cfg.triage.choice_min_confidence
+                ),
+                extract_enabled=triage_data.get(
+                    "extract_enabled", cfg.triage.extract_enabled
+                ),
+                extract_max_tokens=triage_data.get(
+                    "extract_max_tokens", cfg.triage.extract_max_tokens
+                ),
+                extract_timeout_s=triage_data.get(
+                    "extract_timeout_s", cfg.triage.extract_timeout_s
+                ),
+                device=triage_data.get("device", cfg.triage.device),
+                preload=list(triage_data.get("preload", cfg.triage.preload)),
+                default_checkpoint=triage_data.get(
+                    "default_checkpoint", cfg.triage.default_checkpoint
+                ),
+            )
+
     return cfg

+ 24 - 4
src/stt_daemon/tui/app.py

@@ -7,6 +7,7 @@ import time
 
 from textual.app import App, ComposeResult
 from textual.binding import Binding
+from textual.containers import Horizontal
 from textual.widgets import Footer, Input, RichLog, Static
 
 from ..assistant.control import default_control_path
@@ -25,6 +26,9 @@ class AssistantTUI(App):
     #command { height: 3; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
     #edit { display: none; }
+    #main { height: 1fr; }
+    #main #transcript { width: 2fr; }
+    #triage { width: 1fr; height: 100%; border: solid $accent; }
     """
 
     BINDINGS = [
@@ -37,9 +41,10 @@ class AssistantTUI(App):
         Binding("q", "quit", "quit"),
     ]
 
-    def __init__(self, path: str):
+    def __init__(self, path: str, triage_enabled: bool = False):
         super().__init__()
         self.path = path
+        self.triage_enabled = triage_enabled
         self.state = ViewState()
         self.client = ControlClient(path)
         self.connect_error: str | None = None
@@ -47,13 +52,19 @@ class AssistantTUI(App):
         self._seen_transcripts = 0
         self._seen_lower = 0
         self._seen_epoch = 0
+        self._seen_triage_epoch = 0
         self._timer = None
         self._last_seg_title = ""
         self._last_inf_title = ""
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
-        yield RichLog(id="transcript", wrap=True, markup=False)
+        if self.triage_enabled:
+            with Horizontal(id="main"):
+                yield RichLog(id="transcript", wrap=True, markup=False)
+                yield RichLog(id="triage", wrap=True, markup=False)
+        else:
+            yield RichLog(id="transcript", wrap=True, markup=False)
         yield Static(id="command")
         yield RichLog(id="chat", wrap=True, markup=False)
         yield Input(placeholder="edit the command, then press Enter", id="edit")
@@ -61,6 +72,8 @@ class AssistantTUI(App):
 
     def _set_titles(self) -> None:
         self.query_one("#command", Static).border_title = "command buffer"
+        if self.triage_enabled:
+            self.query_one("#triage", RichLog).border_title = "triage"
         self._update_titles(self.state)
 
     def _update_titles(self, state) -> None:
@@ -140,6 +153,13 @@ class AssistantTUI(App):
             lower_log.write(lower_lines[self._seen_lower])
             self._seen_lower += 1
 
+        if self.triage_enabled:
+            triage_log = self.query_one("#triage", RichLog)
+            if self._seen_triage_epoch != state.triage_epoch:
+                triage_log.clear()
+                self._seen_triage_epoch = state.triage_epoch
+                triage_log.write("\n".join(state.triage_lines()))
+
     def _send(self, type_: str, **fields) -> None:
         try:
             self.client.send(type_, **fields)
@@ -223,15 +243,15 @@ def main() -> int:
     )
     args = parser.parse_args()
 
+    cfg = load_config(args.config)
     path = args.socket
     if path is None:
-        cfg = load_config(args.config)
         path = cfg.assistant.control_socket or default_control_path()
 
     if args.dump is not None:
         return _dump(path, args.dump)
 
-    app = AssistantTUI(path)
+    app = AssistantTUI(path, triage_enabled=cfg.triage.enabled)
     app.run()
     if app.connect_error:
         print(f"error: {app.connect_error}", file=sys.stderr)

+ 100 - 0
src/stt_daemon/tui/state.py

@@ -18,6 +18,23 @@ MODE_STYLES = {
 }
 
 
+def _num(value) -> float | None:
+    """Best-effort float coercion; None when missing/non-numeric."""
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _fmt_gate(item: dict) -> str:
+    """Format the gating confidence defensively; '?' when absent."""
+    for key in ("answer_confidence", "confidence"):
+        value = _num(item.get(key))
+        if value is not None:
+            return f"{value:.2f}"
+    return "?"
+
+
 @dataclass
 class ViewState:
     """Everything the TUI renders."""
@@ -43,6 +60,10 @@ class ViewState:
     tool_result: dict | None = None
     last_error: dict | None = None
     chat_epoch: int = 0
+    triage: dict | None = None
+    triage_state: str = "disabled"
+    triage_reason: str = ""
+    triage_epoch: int = 0
 
     def ingest(self, event: dict) -> None:
         """Fold one event into the view state."""
@@ -58,6 +79,28 @@ class ViewState:
             self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
             if "speech_open" in event:
                 self.speech_open = bool(event["speech_open"])
+            changed = False
+            if "triage" in event:
+                self.triage = event["triage"]
+                changed = True
+            if "triage_state" in event:
+                self.triage_state = event["triage_state"]
+                changed = True
+            if "triage_reason" in event:
+                self.triage_reason = event["triage_reason"]
+                changed = True
+            if changed:
+                self.triage_epoch += 1
+        elif etype == "triage":
+            stage = event.get("stage")
+            if stage == "cleared":
+                self.triage = None
+            else:
+                # A triage payload can only come from a ready lane: authoritative.
+                self.triage_state = "ok"
+                self.triage_reason = ""
+                self.triage = event.get("triage")
+            self.triage_epoch += 1
         elif etype == "speech_start":
             self.speech_open = True
         elif etype == "speech_stop":
@@ -122,7 +165,9 @@ class ViewState:
         self.tool        = None
         self.tool_result = None
         self.last_error  = None
+        self.triage      = None
         self.chat_epoch += 1
+        self.triage_epoch += 1
 
     def _set_mode(self, mode: str) -> None:
         if self.mode == "chat" and mode != "chat":
@@ -144,3 +189,58 @@ class ViewState:
 
     def chat_lines(self) -> list[str]:
         return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
+
+    def triage_lines(self) -> list[str]:
+        """Render the triage panel lines. Pure and defensive: never raises."""
+        if self.triage_state != "ok":
+            reason = self.triage_reason or self.triage_state or "unavailable"
+            return [f"triage: unavailable — {reason}"]
+        if not isinstance(self.triage, dict):
+            return ["triage: unavailable — no data"]
+
+        order = self.triage.get("order")
+        items = self.triage.get("items")
+        lines: list[str] = []
+        if isinstance(order, list) and isinstance(items, dict):
+            for qid in order:
+                item = items.get(qid)
+                if not isinstance(item, dict):
+                    lines.append(f"{qid} ⚠" if item else str(qid))
+                    continue
+                kind = item.get("kind")
+                flagged = bool(item.get("flagged"))
+                mark = " ⚠" if flagged else ""
+                if kind == "choice":
+                    line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
+                elif kind == "noul":
+                    p = _num(item.get("p"))
+                    bar = "█" * int(p * 10) if p is not None else ""
+                    bar = bar.ljust(10)
+                    ptxt = f"{p:.2f}" if p is not None else "?"
+                    line = f"{bar} {qid}: {ptxt}{mark}"
+                elif kind == "score":
+                    level = _num(item.get("level"))
+                    bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
+                    label = item.get("label", "?")
+                    line = f"{bar} {qid}: {label}{mark}"
+                else:
+                    line = f"{qid}{mark}" if flagged else str(qid)
+                lines.append(line)
+
+        content = self.triage.get("content")
+        if isinstance(content, dict):
+            if lines:
+                lines.append("")
+            summary = content.get("summary")
+            if summary:
+                lines.append(f"summary: {summary}")
+            for key in ("opportunities", "risks", "action_items"):
+                values = content.get(key)
+                if not isinstance(values, list):
+                    continue
+                for value in values[:3]:
+                    lines.append(f"{key[:-1]}: {value}")
+
+        if not lines:
+            return ["triage: no signals"]
+        return lines

+ 209 - 0
tests/test_assistant_integration.py

@@ -1,13 +1,19 @@
 """Integration: the assistant service consuming a real publish-only bus."""
 
+import json
 import os
 import shutil
+import signal
+import sys
 import tempfile
+import threading
 import time
+from types import SimpleNamespace
 
 import pytest
 
 from stt_daemon import events
+from stt_daemon.assistant.runner import build_triage
 from stt_daemon.assistant.service import AssistantService
 from stt_daemon.bus import EventPublisher, EventSubscriber
 from stt_daemon.config import Config
@@ -86,3 +92,206 @@ def test_service_consumes_real_bus(sock_path):
     finally:
         service.stop()
         publisher.close()
+
+
+def _enabled_triage_cfg(tmp_path):
+    cfg = Config()
+    cfg.audit.enabled = False
+    cfg.llm.enabled = False
+    cfg.triage.enabled = True
+    path = os.path.join(str(tmp_path), "questions.json")
+    with open(path, "w", encoding="utf-8") as handle:
+        json.dump({"urgent": {"type": "noul", "instructions": "is it urgent"}}, handle)
+    cfg.triage.questions_file = path
+    return cfg
+
+
+class _FakeChatClient:
+    def __init__(self):
+        self.calls = []
+
+    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None):
+        self.calls.append(
+            {
+                "messages": messages,
+                "tools": tools,
+                "max_tokens": max_tokens,
+                "timeout_s": timeout_s,
+            }
+        )
+        return SimpleNamespace(content="EXTRACTED")
+
+
+def test_build_triage_disabled_returns_nones():
+    cfg = Config()
+    assert cfg.triage.enabled is False
+    assert build_triage(cfg, None) == (None, None, None)
+    assert build_triage(cfg, _FakeChatClient()) == (None, None, None)
+    assert "laya" not in sys.modules
+    service = AssistantService(cfg, subscriber=object())
+    assert service.status()["triage_state"] == "disabled"
+
+
+def test_build_triage_enabled_constructs_without_client(tmp_path):
+    cfg = _enabled_triage_cfg(tmp_path)
+    before = len(threading.enumerate())
+    analyzer, lane, extract_fn = build_triage(cfg, None)
+    assert analyzer is not None
+    assert lane is not None
+    assert extract_fn is None
+    assert analyzer._cfg is cfg.triage
+    assert lane._cfg is cfg.triage
+    assert analyzer.state() == ("loading", "")
+    assert len(threading.enumerate()) == before
+    assert "laya" not in sys.modules
+
+
+def test_build_triage_extract_fn_calls_client(tmp_path):
+    cfg = _enabled_triage_cfg(tmp_path)
+    client = _FakeChatClient()
+    _, _, extract_fn = build_triage(cfg, client)
+    assert extract_fn is not None
+    assert extract_fn("PROMPT") == "EXTRACTED"
+    assert len(client.calls) == 1
+    call = client.calls[0]
+    assert call["messages"] == [
+        {"role": "system", "content": cfg.llm.triage_system_prompt},
+        {"role": "user", "content": "PROMPT"},
+    ]
+    assert call["max_tokens"] == cfg.triage.extract_max_tokens
+    assert call["timeout_s"] == cfg.triage.extract_timeout_s
+
+
+def test_build_triage_on_state_refreshes_analyzer(tmp_path):
+    from stt_daemon.assistant.laya_lane import LAYA_MISSING_HINT
+
+    cfg = _enabled_triage_cfg(tmp_path)
+    sentinel = object()
+    analyzer, lane, _ = build_triage(cfg, None, on_result=sentinel)
+    assert analyzer is not None
+    assert lane is not None
+    assert lane._on_result is sentinel
+    assert callable(lane._on_state)
+    lane._on_state("ok", "")
+    assert analyzer.state() == ("ok", "")
+    lane._on_state("laya_missing", LAYA_MISSING_HINT)
+    assert analyzer.state() == ("laya_missing", LAYA_MISSING_HINT)
+
+
+def _run_runner_main(monkeypatch, cfg):
+    from stt_daemon.assistant import runner as runner_mod
+    import stt_daemon.assistant.laya_lane as lane_mod
+
+    calls = []
+    made_lanes = []
+    made_services = []
+
+    class FakeLane:
+        def __init__(self, triage_cfg, agent_factory=None, on_result=None, on_state=None):
+            made_lanes.append(self)
+            self.cfg = triage_cfg
+            self.on_state = on_state
+            self.on_result = on_result
+            calls.append("lane.construct")
+
+        def start(self):
+            calls.append("lane.start")
+
+        def stop(self):
+            calls.append("lane.stop")
+
+    class FakeSubscriber:
+        def close(self):
+            calls.append("subscriber.close")
+
+    class FakeService:
+        def __init__(self, cfg, subscriber, **kwargs):
+            made_services.append(self)
+            self.kwargs = kwargs
+            calls.append("service.construct")
+
+        def set_sinks(self, **kwargs):
+            pass
+
+        def chat_notify(self, *args, **kwargs):
+            return None
+
+        def _on_triage_result(self, generation, payload):
+            calls.append("triage.result")
+
+        def start(self):
+            calls.append("service.start")
+
+        def stop(self):
+            calls.append("service.stop")
+
+    class FakeControl:
+        def __init__(self, cfg, service):
+            self.service = service
+            calls.append("control.construct")
+
+        path = "/tmp/fake.sock"
+
+        def start(self):
+            calls.append("control.start")
+
+        def broadcast(self, *args, **kwargs):
+            pass
+
+        def relay(self, event):
+            pass
+
+        def close(self):
+            calls.append("control.close")
+
+    class FakeEvent:
+        def set(self):
+            pass
+
+        def wait(self, timeout=None):
+            return True
+
+    monkeypatch.setattr(lane_mod, "LayaLane", FakeLane)
+    monkeypatch.setattr(runner_mod, "EventSubscriber", lambda *a, **k: FakeSubscriber())
+    monkeypatch.setattr(runner_mod, "ControlServer", FakeControl)
+    monkeypatch.setattr(runner_mod, "AssistantService", FakeService)
+    monkeypatch.setattr(runner_mod, "load_config", lambda path=None: cfg)
+    monkeypatch.setattr(threading, "Event", FakeEvent)
+    monkeypatch.setattr(signal, "signal", lambda *a, **k: None)
+    monkeypatch.setattr(sys, "argv", ["stt-assistant"])
+    rc = runner_mod.main()
+    return rc, calls, made_lanes, made_services
+
+
+def test_runner_main_shutdown_order_lane_before_control(tmp_path, monkeypatch):
+    cfg = _enabled_triage_cfg(tmp_path)
+    rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
+    assert rc == 0
+    assert len(made_lanes) == 1
+    assert made_lanes[0].cfg is cfg.triage
+    assert made_services[0].kwargs["laya_lane"] is made_lanes[0]
+    assert made_services[0].kwargs["triage"] is not None
+    assert made_services[0].kwargs["triage_extract_fn"] is None
+    assert calls.index("control.start") < calls.index("service.start")
+    assert calls.index("service.start") < calls.index("lane.start")
+    assert calls.index("service.stop") < calls.index("lane.stop")
+    assert calls.index("lane.stop") < calls.index("control.close")
+    made_lanes[0].on_result(3, {"answers": {}})
+    assert "triage.result" in calls
+    assert "laya" not in sys.modules
+
+
+def test_runner_main_disabled_builds_no_lane(monkeypatch):
+    cfg = Config()
+    cfg.audit.enabled = False
+    cfg.llm.enabled = False
+    rc, calls, made_lanes, made_services = _run_runner_main(monkeypatch, cfg)
+    assert rc == 0
+    assert made_lanes == []
+    assert "lane.construct" not in calls
+    assert "lane.start" not in calls
+    assert "lane.stop" not in calls
+    assert made_services[0].kwargs["triage"] is None
+    assert made_services[0].kwargs["laya_lane"] is None
+    assert made_services[0].kwargs["triage_extract_fn"] is None
+    assert not [t for t in threading.enumerate() if t.name.startswith("laya-")]

+ 804 - 0
tests/test_assistant_service.py

@@ -3,6 +3,7 @@
 import itertools
 import threading
 import time
+from typing import Any
 
 from stt_daemon import events
 from stt_daemon.assistant.inference import InferenceQueue
@@ -468,3 +469,806 @@ def test_status_contains_inference_depth_and_in_flight():
     assert isinstance(status["inference_depth"], int)
     assert "inference_in_flight" in status
     assert isinstance(status["inference_in_flight"], bool)
+
+
+_AUTO = object()
+
+
+class FakeTriage:
+    def __init__(
+        self,
+        questions=_AUTO,
+        state_pair=("ok", ""),
+        escalate=False,
+        parsed=_AUTO,
+        normalized=_AUTO,
+    ):
+        if questions is _AUTO:
+            questions = {"urgency": {"type": "noul", "instructions": "how urgent"}}
+        self._questions = questions
+        self._state_pair = state_pair
+        self._escalate = escalate
+        self._parsed = parsed
+        self._normalized = normalized
+        self.build_state_calls: list[tuple] = []
+        self.normalize_calls: list = []
+        self.prompt_calls: list[tuple] = []
+
+    @property
+    def questions(self):
+        return self._questions
+
+    def state(self):
+        return self._state_pair
+
+    def build_state(self, window):
+        self.build_state_calls.append(tuple(window))
+        return "\n".join("- " + str(item) for item in (window or []))
+
+    def normalize(self, payload):
+        self.normalize_calls.append(payload)
+        if self._normalized is not _AUTO:
+            return self._normalized
+        if not isinstance(payload, dict):
+            return {}
+        signals = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
+        signals.update(payload)
+        if self._escalate:
+            signals["escalated"] = True
+        return signals
+
+    def build_extraction_prompt(self, window, signals):
+        self.prompt_calls.append((tuple(window), dict(signals)))
+        return "EXTRACT"
+
+    def parse_extraction(self, text):
+        if self._parsed is _AUTO:
+            return {
+                "summary": "s",
+                "opportunities": [],
+                "doubts": [],
+                "action_items": [],
+                "risks": [],
+            }
+        return self._parsed
+
+
+class FakeLane:
+    def __init__(self, ready=True):
+        self._ready = ready
+        self.submits: list[tuple] = []
+        self.drops = 0
+        self.predict_gate = threading.Event()
+
+    def ready(self):
+        return self._ready
+
+    def state(self):
+        return ("ok", "") if self._ready else ("loading", "")
+
+    def submit(self, state, questions, lang, generation):
+        self.submits.append((state, questions, lang, generation))
+        return True
+
+    def drop_pending(self):
+        self.drops += 1
+
+
+def _ok_reply(text):
+    return "ok"
+
+
+def _triage_service(triage=None, lane=None, extract_fn=None, chat_fn: Any = _AUTO):
+    cfg = Config()
+    cfg.assistant.turn_gap_s = 0.5
+    clock = Clock()
+    emitted: list[dict] = []
+
+    def emit(type_, **fields):
+        emitted.append({"type": type_, **fields})
+
+    if triage is None:
+        triage = FakeTriage()
+    if lane is None:
+        lane = FakeLane()
+    if chat_fn is _AUTO:
+        chat_fn = _ok_reply
+    service = AssistantService(
+        cfg,
+        FakeSubscriber(),
+        emit=emit,
+        time_fn=clock,
+        triage=triage,
+        laya_lane=lane,
+        triage_extract_fn=extract_fn,
+        chat_fn=chat_fn,
+    )
+    return service, emitted, clock, triage, lane
+
+
+def _triage_events(emitted):
+    return [e for e in emitted if e["type"] == "triage"]
+
+
+def _escalated_payload():
+    return {"order": [], "items": {}, "escalated": True, "input_tokens": 3}
+
+
+def test_triage_status_disabled_when_no_analyzer():
+    service, _, _ = _service()
+
+    status = service.status()
+
+    assert status["triage_state"] == "disabled"
+    assert status["triage_reason"] == ""
+    assert status["triage"] is None
+
+
+def test_triage_status_reflects_analyzer():
+    service, _, _, _, _ = _triage_service(
+        triage=FakeTriage(state_pair=("loading", "warming up"))
+    )
+
+    status = service.status()
+
+    assert status["triage_state"] == "loading"
+    assert status["triage_reason"] == "warming up"
+    assert status["triage"] is None
+
+
+def test_triage_captures_transcript_lang():
+    service, _, clock, _, _ = _triage_service()
+    assert service._last_transcript_lang is None
+
+    event = _transcript(clock, "hola")
+    event["lang"] = "es"
+    service.handle_event(event)
+    assert service._last_transcript_lang == "es"
+
+    service.handle_event(_transcript(clock, "hello"))
+    assert service._last_transcript_lang is None
+
+
+def test_maybe_triage_passes_lang_to_lane():
+    service, _, clock, _, lane = _triage_service()
+    event = _transcript(clock, "hola")
+    event["lang"] = "es"
+    service.handle_event(event)
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert len(lane.submits) == 1
+    _, _, lang, _ = lane.submits[0]
+    assert lang == "es"
+
+
+def test_maybe_triage_runs_in_idle_mode():
+    service, _, clock, triage, lane = _triage_service()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert len(lane.submits) == 1
+    state, questions, lang, gen = lane.submits[0]
+    assert state == "- hello"
+    assert questions is triage.questions
+    assert lang is None
+    assert gen == 0
+    assert triage.build_state_calls == [("hello",)]
+    assert service.triage_history == ["hello"]
+
+
+def test_maybe_triage_uses_transcript_buffer_not_chat_history():
+    service, _, clock, triage, lane = _triage_service(chat_fn=None)
+    service.chat_history = ["stale chat only"]
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert lane.submits == []
+    assert triage.build_state_calls == []
+
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert len(lane.submits) == 1
+    assert triage.build_state_calls == [("hello",)]
+
+
+def test_maybe_triage_requires_settled_turn():
+    service, _, clock, _, lane = _triage_service()
+    service.handle_event(_transcript(clock, "hello"))
+    service.tick()
+
+    assert lane.submits == []
+
+
+def test_maybe_triage_skips_unready_lane():
+    service, _, clock, _, lane = _triage_service(lane=FakeLane(ready=False))
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert lane.submits == []
+
+
+def test_maybe_triage_skips_without_questions():
+    service, _, clock, triage, lane = _triage_service(
+        triage=FakeTriage(questions=None)
+    )
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert lane.submits == []
+    assert triage.build_state_calls == []
+
+
+def test_maybe_triage_dedupes_identical_window():
+    service, _, clock, triage, lane = _triage_service()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert len(lane.submits) == 1
+    state, questions, lang, gen = lane.submits[0]
+    assert state == "- hello"
+    assert questions is triage.questions
+    assert lang is None
+    assert gen == 0
+
+    service.tick()
+    service.tick()
+    assert len(lane.submits) == 1
+
+    service.handle_event(_transcript(clock, "second"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 2
+    assert lane.submits[1][0] == "- hello\n- second"
+
+
+def test_second_transcript_produces_second_submit():
+    service, _, clock, _, lane = _triage_service()
+    service.handle_event(_transcript(clock, "first"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 1
+
+    service.handle_event(_transcript(clock, "second"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+
+    assert len(lane.submits) == 2
+    assert lane.submits[1][0] == "- first\n- second"
+
+
+def test_tick_under_50ms_with_blocked_lane():
+    service, _, clock, _, lane = _triage_service()
+    assert not lane.predict_gate.is_set()
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+
+    start = time.perf_counter()
+    service.tick()
+    elapsed = time.perf_counter() - start
+
+    assert len(lane.submits) == 1
+    assert elapsed < 0.05
+
+
+def test_on_triage_result_emits_signals():
+    service, emitted, clock, _, lane = _triage_service()
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    payload = {
+        "order": ["urgency"],
+        "items": {"urgency": {"kind": "noul"}},
+        "escalated": False,
+        "input_tokens": 12,
+    }
+
+    service._on_triage_result(gen, payload)
+
+    staged = _triage_events(emitted)
+    assert len(staged) == 1
+    assert staged[0]["stage"] == "signals"
+    assert staged[0]["triage"] == service.last_triage
+    assert staged[0]["triage"]["escalated"] is False
+    assert staged[0]["triage"]["input_tokens"] == 12
+    assert service.status()["triage"] == service.last_triage
+
+
+def test_on_triage_result_suppresses_stale_generation():
+    service, emitted, clock, _, lane = _triage_service()
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    service.clear()
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is None
+    assert [e for e in _triage_events(emitted) if e.get("stage") == "signals"] == []
+
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 2
+
+
+def test_on_triage_result_accepts_non_chat_mode():
+    service, emitted, clock, _, lane = _triage_service()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    window = service._last_triage_window
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is not None
+    assert service._last_triage_window == window
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
+
+
+def test_on_triage_result_ignores_none_payload():
+    service, emitted, clock, _, lane = _triage_service()
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, None)
+
+    assert service.last_triage is None
+    assert _triage_events(emitted) == []
+
+
+def test_on_triage_result_ignores_empty_signals():
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(normalized={})
+    )
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is None
+    assert _triage_events(emitted) == []
+
+
+def test_escalation_enqueues_extract_job():
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
+    )
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "we might churn"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(
+        gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
+    )
+
+    assert service.last_triage is not None
+    assert service.last_triage["escalated"] is True
+    assert service._inference.depth() == 2
+    assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
+
+
+def test_no_extract_without_escalation():
+    service, _, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "all calm"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(
+        gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
+    )
+
+    assert service.last_triage is not None
+    assert service._inference.depth() == 1
+    assert [j.name for j in service._inference._queue] == ["chat"]
+
+
+def test_no_extract_when_disabled_in_config():
+    service, _, clock, _, lane = _triage_service(
+        triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
+    )
+    service.cfg.triage.extract_enabled = False
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "churn risk"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is not None
+    assert service.last_triage["escalated"] is True
+    assert [j.name for j in service._inference._queue] == ["chat"]
+
+
+def test_extract_refused_when_queue_full():
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
+    )
+    service._inference = InferenceQueue(size=2)
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "churn risk"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert service._inference.depth() == 1
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service._inference.depth() == 1
+    assert service._inference._queue[0].name == "chat"
+    assert service.last_triage is not None
+    assert service.last_triage["escalated"] is True
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
+
+
+def test_extract_merges_content():
+    service, emitted, _, triage, _ = _triage_service(extract_fn=lambda prompt: "RAW")
+    service.toggle_chat()
+    window = ("hello",)
+    signals = _escalated_payload()
+
+    service._run_triage_extract(0, window, signals)
+
+    contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
+    assert len(contents) == 1
+    assert contents[0]["triage"] == {**signals, "content": triage.parse_extraction("RAW")}
+    assert service.last_triage == contents[0]["triage"]
+    assert triage.prompt_calls == [(window, signals)]
+
+
+def test_extract_none_parse_keeps_signals():
+    service, emitted, _, _, _ = _triage_service(
+        triage=FakeTriage(parsed=None), extract_fn=lambda prompt: "GARBAGE"
+    )
+    service.toggle_chat()
+    signals = _escalated_payload()
+    service.last_triage = signals
+
+    service._run_triage_extract(0, ("hello",), signals)
+
+    assert [e for e in _triage_events(emitted) if e.get("stage") == "content"] == []
+    assert service.last_triage == signals
+    assert "content" not in service.last_triage
+
+
+def test_extract_error_is_swallowed():
+    def boom(prompt):
+        raise RuntimeError("llm down")
+
+    service, emitted, _, _, _ = _triage_service(extract_fn=boom)
+    service.toggle_chat()
+
+    service._run_triage_extract(0, ("hello",), _escalated_payload())
+
+    assert _triage_events(emitted) == []
+    assert service.last_triage is None
+
+
+def test_extract_suppressed_after_reset():
+    service, emitted, _, _, _ = _triage_service(extract_fn=lambda prompt: "RAW")
+    service.toggle_chat()
+    service._reset_triage()
+    emitted.clear()
+
+    service._run_triage_extract(0, ("hello",), _escalated_payload())
+
+    assert _triage_events(emitted) == []
+    assert service.last_triage is None
+
+
+def test_extract_works_after_leaving_chat():
+    seen = []
+    service, emitted, _, _, _ = _triage_service(
+        extract_fn=lambda prompt: seen.append(prompt) or "RAW"
+    )
+    service.toggle_chat()
+    assert service.modes.exit_chat() is True
+
+    service._run_triage_extract(0, ("hello",), _escalated_payload())
+
+    assert seen != []
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["content"]
+    assert service.last_triage is not None
+    assert "content" in service.last_triage
+
+
+def test_clear_resets_triage():
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
+    )
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "churn"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    service._on_triage_result(gen, _escalated_payload())
+    assert service._inference.depth() == 2
+
+    assert service.clear() is True
+
+    assert service._triage_generation == 1
+    assert service.last_triage is None
+    assert service._last_triage_window == ()
+    assert service.triage_history == []
+    assert lane.drops == 1
+    assert service._inference.depth() == 0
+    assert list(service._inference._queue) == []
+    cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
+    assert len(cleared) == 1
+    assert cleared[0]["triage"] is None
+    assert {"type": "cleared"} in emitted
+
+    signals_before = len(
+        [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
+    )
+    service._on_triage_result(gen, _escalated_payload())
+    assert service.last_triage is None
+    assert (
+        len([e for e in _triage_events(emitted) if e.get("stage") == "signals"])
+        == signals_before
+    )
+
+    submits_before = len(lane.submits)
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == submits_before
+
+
+def test_exit_chat_does_not_reset_triage():
+    service, emitted, clock, _, lane = _triage_service(
+        extract_fn=lambda prompt: "RAW"
+    )
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 1
+    _, _, _, gen = lane.submits[0]
+    service._on_triage_result(gen, _escalated_payload())
+    assert service.last_triage is not None
+    assert service._triage_generation == 0
+
+    assert service.toggle_chat() is True
+
+    assert service.modes.mode.value == "idle"
+    assert service._triage_generation == 0
+    assert service.last_triage is not None
+    assert service._last_triage_window == ("hello",)
+    assert service.triage_history == ["hello"]
+    assert lane.drops == 0
+    cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
+    assert cleared == []
+
+
+def test_clear_without_triage_emits_no_triage_event():
+    service, emitted, _ = _service()
+
+    service.clear()
+
+    assert {"type": "cleared"} in emitted
+    assert _triage_events(emitted) == []
+
+
+def test_triage_end_to_end_signals_content_cleared():
+    prompts: list[str] = []
+    service, emitted, clock, triage, lane = _triage_service(
+        extract_fn=lambda prompt: prompts.append(prompt) or '{"summary": "s"}'
+    )
+    service.toggle_chat()
+
+    service.handle_event(_transcript(clock, "first thought"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 1
+    _, _, _, gen0 = lane.submits[0]
+    service._on_triage_result(
+        gen0, {"order": ["urgency"], "items": {}, "escalated": False, "input_tokens": 1}
+    )
+
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
+    assert prompts == []
+    assert service.last_triage is not None
+    assert service.last_triage["escalated"] is False
+    assert [j.name for j in service._inference._queue] == ["chat"]
+
+    triage._escalate = True
+    service.handle_event(_transcript(clock, "we might churn"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 2
+    _, _, _, gen1 = lane.submits[1]
+    service._on_triage_result(gen1, _escalated_payload())
+
+    assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
+    assert [j.name for j in service._inference._queue] == [
+        "chat",
+        "chat",
+        "triage-extract",
+    ]
+    job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
+    job.run()
+
+    contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
+    assert len(contents) == 1
+    assert len(prompts) == 1
+    assert contents[0]["triage"]["escalated"] is True
+    assert contents[0]["triage"]["content"] == triage.parse_extraction("x")
+    assert service.last_triage == contents[0]["triage"]
+    assert service.status()["triage"] == contents[0]["triage"]
+
+    service.clear()
+
+    cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
+    assert len(cleared) == 1
+    assert cleared[0]["triage"] is None
+    assert service.last_triage is None
+    service._on_triage_result(gen1, _escalated_payload())
+    assert [e.get("stage") for e in _triage_events(emitted)] == [
+        "signals",
+        "signals",
+        "content",
+        "cleared",
+    ]
+
+    assert service.toggle_chat() is True
+    service.handle_event(_transcript(clock, "idle thought"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    assert len(lane.submits) == 3
+    assert lane.submits[2][0].endswith("- idle thought")
+
+
+def test_tick_emits_status_on_triage_state_change():
+    service, emitted, _, triage, _ = _triage_service(
+        triage=FakeTriage(state_pair=("loading", "warming up"))
+    )
+
+    service.tick()
+    statuses = [e for e in emitted if e["type"] == "status"]
+    assert len(statuses) == 1
+    assert statuses[0]["triage_state"] == "loading"
+    assert statuses[0]["triage_reason"] == "warming up"
+    assert "triage" in statuses[0]
+
+    triage._state_pair = ("ok", "")
+    service.tick()
+    statuses = [e for e in emitted if e["type"] == "status"]
+    assert len(statuses) == 2
+    assert statuses[1]["triage_state"] == "ok"
+    assert statuses[1]["triage_reason"] == ""
+    assert "triage" in statuses[1]
+
+    service.tick()
+    assert len([e for e in emitted if e["type"] == "status"]) == 2
+
+
+def test_no_status_emitted_when_triage_disabled():
+    service, emitted, _ = _service()
+
+    service.tick()
+    service.tick()
+
+    assert [e for e in emitted if e["type"] == "status"] == []
+
+
+def test_tick_heals_triage_panel_after_lane_ready():
+    import time as _time
+    from pathlib import Path
+
+    from stt_daemon.assistant.laya_lane import LayaLane
+    from stt_daemon.assistant.triage import TriageAnalyzer
+    from stt_daemon.tui.state import ViewState
+
+    class _FakeRouter:
+        def preload(self, names):
+            return self
+
+        def predict(self, state, questions, model=None, lang=None, **kwargs):
+            if "_warmup" in questions:
+                return {"model": model, "answers": {}, "usage": {}, "routing": {}}
+            return {
+                "model": model,
+                "answers": {
+                    "frustration": {
+                        "noul": 0.1,
+                        "confidence": 0.9,
+                        "answer_confidence": 0.9,
+                    },
+                    "urgency": {
+                        "choice": "low",
+                        "confidence": 0.9,
+                        "answer_confidence": 0.9,
+                    },
+                },
+                "usage": {"input_tokens": 5},
+            }
+
+    def _factory(**kwargs):
+        return _FakeRouter()
+
+    cfg = Config()
+    cfg.assistant.turn_gap_s = 0.5
+    cfg.triage.questions_file = str(
+        Path(__file__).resolve().parent.parent / "questions.example.json"
+    )
+    clock = Clock()
+    emitted: list[dict] = []
+
+    def emit(type_, **fields):
+        emitted.append({"type": type_, **fields})
+
+    analyzer = TriageAnalyzer(cfg.triage)
+    assert analyzer.questions, "example questions must load"
+    holder: dict = {}
+    lane = LayaLane(
+        cfg.triage,
+        agent_factory=_factory,
+        on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
+        on_state=lambda state, reason: analyzer.refresh(state, reason),
+    )
+    service = AssistantService(
+        cfg,
+        FakeSubscriber(),
+        emit=emit,
+        time_fn=clock,
+        triage=analyzer,
+        laya_lane=lane,
+        chat_fn=_ok_reply,
+    )
+    holder["svc"] = service
+    try:
+        lane.start()
+        deadline = _time.monotonic() + 5.0
+        while not lane.ready() and _time.monotonic() < deadline:
+            _time.sleep(0.005)
+        assert lane.ready(), "lane did not become ready"
+        assert analyzer.state() == ("ok", "")
+
+        service.tick()
+        statuses = [e for e in emitted if e["type"] == "status"]
+        assert statuses, "tick emitted no status after lane ready"
+        assert statuses[-1]["triage_state"] == "ok"
+
+        service.handle_event(_transcript(clock, "hello"))
+        clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+        service.tick()
+
+        deadline = _time.monotonic() + 5.0
+        while service.last_triage is None and _time.monotonic() < deadline:
+            _time.sleep(0.005)
+        assert service.last_triage is not None, "no triage signals from lane"
+
+        view = ViewState()
+        for event in emitted:
+            view.ingest(event)
+        assert view.triage_state == "ok"
+        lines = view.triage_lines()
+        assert lines
+        assert not any("unavailable" in line for line in lines)
+        assert not any("loading" in line for line in lines)
+    finally:
+        lane.stop()

+ 146 - 0
tests/test_config.py

@@ -1,10 +1,15 @@
 """Tests for configuration loading."""
 
+import importlib.util
 import tempfile
+import tomllib
 from pathlib import Path
 
 from stt_daemon.config import load_config
 
+REPO_ROOT = Path(__file__).resolve().parents[1]
+LAYA_REV = "970dc8c5f63d7b886a68409493f37d569424f933"
+
 
 def test_load_config_defaults():
     """Test that load_config returns defaults when no file exists."""
@@ -504,3 +509,144 @@ backend = "bogus"
     assert cfg.whisper.backend == "faster-whisper"
     assert "Unknown whisper backend" in caplog.text
     assert "bogus" in caplog.text
+
+
+def test_triage_defaults():
+    """The [triage] section defaults match the plan's surviving-key list."""
+    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.triage.enabled is False
+    assert cfg.triage.questions_file == "questions.json"
+    assert cfg.triage.window == 8
+    assert cfg.triage.state_char_budget == 1200
+    assert cfg.triage.confidence_field == "answer_confidence"
+    assert cfg.triage.escalate_threshold == 0.6
+    assert cfg.triage.choice_min_confidence == 0.85
+    assert cfg.triage.extract_enabled is True
+    assert cfg.triage.extract_max_tokens == 512
+    assert cfg.triage.extract_timeout_s == 30.0
+    assert cfg.triage.device == ""
+    assert cfg.triage.preload == ["multilingual"]
+    assert cfg.triage.default_checkpoint == "multilingual"
+
+
+def test_triage_config_fields_parsed():
+    """Every surviving [triage] key is applied from TOML."""
+    config_toml = """
+[triage]
+enabled = true
+questions_file = "custom_questions.json"
+window = 4
+state_char_budget = 800
+confidence_field = "confidence"
+escalate_threshold = 0.7
+choice_min_confidence = 0.9
+extract_enabled = false
+extract_max_tokens = 256
+extract_timeout_s = 12.5
+device = "cuda"
+preload = ["multilingual", "english"]
+default_checkpoint = "english"
+"""
+    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.enabled is True
+    assert cfg.triage.questions_file == "custom_questions.json"
+    assert cfg.triage.window == 4
+    assert cfg.triage.state_char_budget == 800
+    assert cfg.triage.confidence_field == "confidence"
+    assert cfg.triage.escalate_threshold == 0.7
+    assert cfg.triage.choice_min_confidence == 0.9
+    assert cfg.triage.extract_enabled is False
+    assert cfg.triage.extract_max_tokens == 256
+    assert cfg.triage.extract_timeout_s == 12.5
+    assert cfg.triage.device == "cuda"
+    assert cfg.triage.preload == ["multilingual", "english"]
+    assert cfg.triage.default_checkpoint == "english"
+
+
+def test_unknown_triag_section_warns_and_keeps_defaults(caplog):
+    """A typo'd [triag] section must warn and never raise; triage keeps defaults."""
+    config_toml = """
+[triag]
+enabled = true
+window = 3
+"""
+    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 "triag" in caplog.text
+    assert cfg.triage.enabled is False
+    assert cfg.triage.window == 8
+
+
+def test_triage_config_has_no_struck_keys():
+    """The v5 keys are deliberately gone from TriageConfig (superseded/removed)."""
+    from stt_daemon.config import TriageConfig
+
+    struck = {"socket", "model", "predict_timeout_s", "checkpoint", "subfolder", "max_len"}
+    for name in struck:
+        assert not hasattr(TriageConfig, name), f"TriageConfig must not expose {name!r}"
+
+
+def test_llm_triage_system_prompt_is_parsed():
+    """[llm].triage_system_prompt overrides the JSON-only extractor default."""
+    config_toml = """
+[llm]
+triage_system_prompt = "ONLY JSON, nothing else."
+"""
+    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.triage_system_prompt == "ONLY JSON, nothing else."
+
+
+def test_no_stt_laya_script():
+    """Triage is in-process only: no stt-laya console script may ever be added."""
+    with (REPO_ROOT / "pyproject.toml").open("rb") as fh:
+        data = tomllib.load(fh)
+
+    scripts = data["project"]["scripts"]
+    assert set(scripts) == {"stt-daemon", "stt-assistant", "stt-ctl", "stt-tui"}
+    assert not any("laya" in name for name in scripts)
+
+
+def test_layasvc_module_not_importable():
+    """The retired external laya service package must not exist (in-process lane)."""
+    assert importlib.util.find_spec("stt_daemon.layasvc") is None
+
+
+def test_triage_extra_pins_laya_revision():
+    """The triage extra pins laya to the exact verified upstream revision."""
+    with (REPO_ROOT / "pyproject.toml").open("rb") as fh:
+        data = tomllib.load(fh)
+
+    assert data["project"]["optional-dependencies"]["triage"] == ["laya"]
+
+    source = data["tool"]["uv"]["sources"]["laya"]
+    assert source["git"] == "https://github.com/NandhaKishorM/laya.git"
+    assert source["rev"] == LAYA_REV
+
+
+def test_questions_json_gitignored():
+    """The user's private questions.json is never committed (like config.toml)."""
+    lines = (REPO_ROOT / ".gitignore").read_text().splitlines()
+    assert "questions.json" in lines

+ 186 - 0
tests/test_inference.py

@@ -1,9 +1,27 @@
 """Tests for the inference queue (Phase B)."""
 
 import threading
+import time
+
 from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
 
 
+def _wait_in_flight(runner: "_JobRunner", timeout: float = 1.0) -> None:
+    """Block until a job is executing (or fail the caller's timeout)."""
+    deadline = time.monotonic() + timeout
+    while not runner.in_flight():
+        assert time.monotonic() < deadline, "no job started in time"
+        time.sleep(0.001)
+
+
+def _wait_idle(queue: InferenceQueue, timeout: float = 2.0) -> None:
+    """Block until nothing is queued and no job is in-flight."""
+    deadline = time.monotonic() + timeout
+    while queue.depth() > 0 or queue.in_flight():
+        assert time.monotonic() < deadline, "queue did not drain in time"
+        time.sleep(0.001)
+
+
 class _JobRunner:
     """Helper to track job execution timing and detect concurrency."""
 
@@ -314,3 +332,171 @@ def test_in_flight_true_during_job_execution():
     assert queue.in_flight() is False
 
     queue.stop()
+
+
+def test_submit_if_room_refuses_when_full_without_eviction():
+    """1 in-flight + 3 queued at size=4 (reserve=1) refuses and mutates nothing."""
+    queue = InferenceQueue(size=4)
+    runner = _JobRunner()
+    unblock = threading.Event()
+
+    queue.start()
+    try:
+        job1 = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock))
+        assert queue.submit(job1) is True
+        _wait_in_flight(runner)
+
+        job2 = InferenceJob(name="job2", run=runner.job("job2"))
+        job3 = InferenceJob(name="job3", run=runner.job("job3"))
+        job4 = InferenceJob(name="job4", run=runner.job("job4"))
+        assert queue.submit(job2) is True
+        assert queue.submit(job3) is True
+        assert queue.submit(job4) is True
+
+        # 1 in-flight + 3 queued == size; reserve=1 caps admission at 3.
+        before = [id(job) for job in queue._queue]
+        assert len(before) == 3
+
+        assert queue.submit_if_room(InferenceJob(name="triage-extract", run=lambda: None)) is False
+
+        after = [id(job) for job in queue._queue]
+        assert after == before
+        assert queue.depth() == 3
+    finally:
+        unblock.set()
+        queue.stop()
+
+
+def test_submit_if_room_admits_and_runs_when_room():
+    """With room, submit_if_room appends the job and the worker runs it."""
+    queue = InferenceQueue(size=4)
+    ran = threading.Event()
+
+    queue.start()
+    try:
+        job = InferenceJob(name="triage-extract", run=ran.set)
+        assert queue.submit_if_room(job) is True
+        assert ran.wait(timeout=1.0) is True
+    finally:
+        queue.stop()
+
+    assert queue.depth() == 0
+
+
+def test_submit_if_room_after_stop_refuses_and_enqueues_nothing():
+    """After stop(), submit_if_room refuses and leaves the queue empty."""
+    queue = InferenceQueue(size=4)
+    queue.start()
+    queue.stop()
+
+    job = InferenceJob(name="triage-extract", run=lambda: None)
+    assert queue.submit_if_room(job) is False
+    assert queue.depth() == 0
+
+
+def test_submit_if_room_reserve_larger_than_size_always_refuses():
+    """A reserve >= size refuses without crashing, even on an empty queue."""
+    queue = InferenceQueue(size=2)
+    queue.start()
+    try:
+        assert queue.submit_if_room(InferenceJob(name="j", run=lambda: None), reserve=5) is False
+        assert queue.depth() == 0
+    finally:
+        queue.stop()
+
+
+def test_submit_still_evicts_oldest_normal_while_submit_if_room_refuses():
+    """submit() keeps its drop-oldest behavior where submit_if_room refuses."""
+    queue = InferenceQueue(size=3)
+    runner = _JobRunner()
+    unblock = threading.Event()
+
+    queue.start()
+    try:
+        job1 = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
+        assert queue.submit(job1) is True
+        _wait_in_flight(runner)
+
+        job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
+        job3 = InferenceJob(name="job3", run=runner.job("job3"), priority=0)
+        assert queue.submit(job2) is True
+        assert queue.submit(job3) is True
+
+        # 1 in-flight + 2 queued == size; reserve=1 caps admission at 2.
+        refused = InferenceJob(name="triage-extract", run=lambda: None)
+        assert queue.submit_if_room(refused) is False
+        assert all(job is not refused for job in queue._queue)
+
+        # submit() still evicts the oldest normal queued job (job2).
+        job4 = InferenceJob(name="job4", run=lambda: None)
+        assert queue.submit(job4) is True
+        remaining_names = [job.name for job in queue._queue]
+        assert remaining_names == ["job3", "job4"]
+    finally:
+        unblock.set()
+        queue.stop()
+
+
+def test_submit_if_room_concurrent_with_draining_worker_loses_no_job():
+    """Admission from a producer thread racing the worker never raises or loses accepted jobs."""
+    queue = InferenceQueue(size=8)
+    accepted: list[str] = []
+    executed: set[str] = set()
+    errors: list[Exception] = []
+    guard = threading.Lock()
+
+    def make_run(name: str):
+        def run():
+            with guard:
+                executed.add(name)
+
+        return run
+
+    def producer():
+        try:
+            for i in range(500):
+                name = f"job{i}"
+                job = InferenceJob(name=name, run=make_run(name))
+                if queue.submit_if_room(job):
+                    with guard:
+                        accepted.append(name)
+        except Exception as exc:  # noqa: BLE001 - recorded for assertion
+            errors.append(exc)
+
+    queue.start()
+    try:
+        thread = threading.Thread(target=producer)
+        thread.start()
+        thread.join(timeout=5.0)
+        assert not thread.is_alive()
+        _wait_idle(queue)
+
+        assert errors == []
+        assert accepted
+        assert set(accepted) <= executed
+    finally:
+        queue.stop()
+
+
+def test_stop_during_in_flight_is_bounded_then_refuses_admission():
+    """stop() returns within its join bound while a job blocks; admission stays refused."""
+    queue = InferenceQueue(size=4)
+    started = threading.Event()
+    release = threading.Event()
+
+    def block():
+        started.set()
+        release.wait()
+
+    queue.start()
+    assert queue.submit(InferenceJob(name="long", run=block)) is True
+    assert started.wait(timeout=1.0) is True
+
+    begin = time.monotonic()
+    queue.stop()
+    elapsed = time.monotonic() - begin
+    assert elapsed < 1.5
+    assert queue.submit_if_room(InferenceJob(name="triage-extract", run=lambda: None)) is False
+    assert queue.depth() == 0
+
+    release.set()

+ 519 - 0
tests/test_laya_lane.py

@@ -0,0 +1,519 @@
+"""Tests for the in-process Laya triage lane (todos 5, 7, 8).
+
+All lane tests inject a fake agent factory; the real `laya` package is never
+required. The single measurement test (todo 5) is decorated with
+`pytest.importorskip("laya")` so the default suite skips it green.
+"""
+
+import sys
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+
+from stt_daemon.assistant.laya_lane import LayaLane, device_or_none
+
+LAYA_PIN_SHA = "970dc8c5f63d7b886a68409493f37d569424f933"
+
+
+def make_cfg(**overrides):
+    cfg = {
+        "device": "",
+        "preload": ["multilingual"],
+        "default_checkpoint": "multilingual",
+    }
+    cfg.update(overrides)
+    return SimpleNamespace(**cfg)
+
+
+class FakeRouter:
+    """Fake laya Router: records ctor kwargs, preload list, predict calls."""
+
+    def __init__(self, calls, **kwargs):
+        self._calls = calls
+        self.ctor_kwargs = kwargs
+        self.preload_names = None
+        self.predict_log = []
+        self.predict_impl = None
+        self.fail_warmup = False
+        self.current = 0
+        self.max_concurrent = 0
+
+    def preload(self, names):
+        self.preload_names = list(names)
+        return self
+
+    def predict(self, state, questions, model=None, lang=None, **kwargs):
+        self._calls["count"] += 1
+        self.current += 1
+        self.max_concurrent = max(self.max_concurrent, self.current)
+        try:
+            self.predict_log.append(
+                {"state": state, "questions": questions, "model": model, "lang": lang}
+            )
+            if "_warmup" in questions:
+                # loader warmup: never gated, returns immediately
+                if self.fail_warmup:
+                    raise RuntimeError("warmup boom")
+                return {"model": model, "answers": {}, "usage": {}, "routing": {}}
+            if self.predict_impl is not None:
+                return self.predict_impl(state, questions, model=model, lang=lang)
+            return {"model": model, "answers": {}, "usage": {}, "routing": {}}
+        finally:
+            self.current -= 1
+
+
+class FakeFactory:
+    """Callable recording ctor kwargs and spawning FakeRouter instances."""
+
+    def __init__(self, calls, predict_impl=None):
+        self._calls = calls
+        self._predict_impl = predict_impl
+        self.routers = []
+
+    def __call__(self, **kwargs):
+        router = FakeRouter(self._calls, **kwargs)
+        router.predict_impl = self._predict_impl
+        self.routers.append(router)
+        self._calls["ctor_kwargs"] = kwargs
+        return router
+
+
+def make_factory(calls, predict_impl=None):
+    return FakeFactory(calls, predict_impl=predict_impl)
+
+
+def wait_until(predicate, timeout=5.0):
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        if predicate():
+            return True
+        time.sleep(0.005)
+    return predicate()
+
+
+def start_ready_lane(calls=None, **kwargs):
+    """Start a lane whose loader succeeds immediately; wait for ready."""
+    calls = calls if calls is not None else {"count": 0}
+    results = []
+    states = []
+    factory = make_factory(calls)
+    lane = LayaLane(
+        make_cfg(),
+        agent_factory=factory,
+        on_result=lambda gen, payload: results.append((gen, payload)),
+        on_state=lambda s, r: states.append((s, r)),
+        **kwargs,
+    )
+    lane.start()
+    assert wait_until(lane.ready), "lane did not become ready"
+    return lane, factory, results, states
+
+
+# -- todo 7: lazy import + loader --------------------------------------
+
+def test_no_top_level_laya_import():
+    assert "laya" not in sys.modules
+    assert "torch" not in sys.modules
+
+
+def test_device_or_none():
+    assert device_or_none("") is None
+    assert device_or_none("cpu") == "cpu"
+    assert device_or_none("cuda") == "cuda"
+
+
+def test_loader_builds_router_preloads_and_warms_up():
+    calls = {"count": 0}
+    lane, factory, results, states = start_ready_lane(calls)
+    try:
+        assert lane.state() == ("ok", "")
+        assert states == [("ok", "")]
+        assert calls["ctor_kwargs"] == {"default": "multilingual", "device": None}
+        router = factory.routers[0]
+        assert router.preload_names == ["multilingual"]
+        # one throwaway warmup predict with model="multilingual"
+        assert len(router.predict_log) == 1
+        warmup = router.predict_log[0]
+        assert warmup["model"] == "multilingual"
+        assert warmup["questions"] == {
+            "_warmup": {"type": "noul", "instructions": "warmup"}
+        }
+        assert results == []  # warmup never emits on_result
+    finally:
+        lane.stop()
+
+
+def test_loader_device_passthrough():
+    calls = {"count": 0}
+    factory = make_factory(calls)
+    lane = LayaLane(make_cfg(device="cpu"), agent_factory=factory)
+    lane.start()
+    try:
+        assert wait_until(lane.ready)
+        assert calls["ctor_kwargs"] == {"default": "multilingual", "device": "cpu"}
+    finally:
+        lane.stop()
+
+
+def test_loader_import_error_gives_laya_missing():
+    def factory(**kwargs):
+        raise ImportError("No module named 'laya'")
+
+    states = []
+    lane = LayaLane(make_cfg(), agent_factory=factory,
+                    on_state=lambda s, r: states.append((s, r)))
+    lane.start()  # must not raise
+    try:
+        assert wait_until(lambda: lane.state()[0] == "laya_missing")
+        assert lane.ready() is False
+        status, hint = lane.state()
+        assert status == "laya_missing"
+        assert "uv sync --extra triage" in hint
+        assert states == [("laya_missing", hint)]
+    finally:
+        lane.stop()
+
+
+def test_loader_generic_exception_gives_error():
+    def factory(**kwargs):
+        raise RuntimeError("CUDA OOM")
+
+    lane = LayaLane(make_cfg(), agent_factory=factory)
+    lane.start()  # must not raise
+    try:
+        assert wait_until(lambda: lane.state()[0] == "error")
+        assert lane.ready() is False
+        assert "CUDA OOM" in lane.state()[1]
+    finally:
+        lane.stop()
+
+
+def test_loader_warmup_crash_gives_error():
+    calls = {"count": 0}
+    real_factory = make_factory(calls)
+
+    def arming_factory(**kwargs):
+        router = real_factory(**kwargs)
+        router.fail_warmup = True
+        return router
+
+    lane = LayaLane(make_cfg(), agent_factory=arming_factory)
+    lane.start()
+    try:
+        assert wait_until(lambda: lane.state()[0] == "error")
+        assert lane.ready() is False
+    finally:
+        lane.stop()
+
+
+def test_start_never_raises_even_when_spawn_fails(monkeypatch):
+    calls = {"count": 0}
+    lane = LayaLane(make_cfg(), agent_factory=make_factory(calls))
+
+    def boom(*args, **kwargs):
+        raise RuntimeError("no threads")
+
+    monkeypatch.setattr(threading, "Thread", boom)
+    lane.start()  # must not raise
+
+
+def test_submit_false_when_failed():
+    def factory(**kwargs):
+        raise ImportError("No module named 'laya'")
+
+    lane = LayaLane(make_cfg(), agent_factory=factory)
+    lane.start()
+    try:
+        assert wait_until(lambda: lane.state()[0] == "laya_missing")
+        assert lane.submit("s", {}, None, 1) is False
+    finally:
+        lane.stop()
+
+
+def test_submit_false_when_error():
+    def factory(**kwargs):
+        raise RuntimeError("hub down")
+
+    lane = LayaLane(make_cfg(), agent_factory=factory)
+    lane.start()
+    try:
+        assert wait_until(lambda: lane.state()[0] == "error")
+        assert lane.submit("s", {}, None, 1) is False
+    finally:
+        lane.stop()
+
+
+# -- todo 8: single-flight / coalesce / shutdown ------------------------
+
+def test_pre_ready_submit_drops_silently():
+    calls = {"count": 0}
+    results = []
+    gate = threading.Event()
+
+    def predict_impl(state, questions, model=None, lang=None):
+        gate.wait(timeout=5.0)
+        return {"answers": {}}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    lane = LayaLane(make_cfg(), agent_factory=factory,
+                    on_result=lambda gen, payload: results.append((gen, payload)))
+    # submit before start(): not ready -> False, nothing recorded
+    assert lane.submit("early", {"q": 1}, None, 0) is False
+    assert calls["count"] == 0
+    assert results == []
+    lane.stop()
+    assert calls["count"] == 0
+    assert results == []
+
+
+def test_submit_newest_wins_while_blocked():
+    calls = {"count": 0}
+    entered = threading.Event()
+    release = threading.Event()
+    first = {"done": False}
+
+    def predict_impl(state, questions, model=None, lang=None):
+        if not first["done"]:
+            first["done"] = True
+            entered.set()
+            assert release.wait(timeout=5.0)
+            return {"answers": {"gen": 0}}
+        return {"answers": {"state": state}}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    results = []
+    done = threading.Event()
+
+    def on_result(gen, payload):
+        results.append((gen, payload))
+        if len(results) >= 2:
+            done.set()
+
+    lane = LayaLane(
+        make_cfg(), agent_factory=factory, on_result=on_result,
+    )
+    lane.start()
+    try:
+        assert wait_until(lane.ready)
+        router = factory.routers[0]
+        router.predict_log.clear()
+        calls["count"] = 0
+        assert lane.submit("state-0", {"q": 0}, None, 0) is True
+        assert entered.wait(timeout=5.0)
+        # 10 rapid submits while the first predict is blocked
+        for i in range(1, 11):
+            assert lane.submit("state-%d" % i, {"q": i}, "es", i) is True
+        release.set()
+        assert wait_until(lambda: len(results) >= 2, timeout=5.0)
+        # exactly one coalesced predict for the newest window
+        assert len(router.predict_log) == 2
+        assert router.predict_log[0]["state"] == "state-0"
+        assert router.predict_log[1]["state"] == "state-10"
+        assert router.predict_log[1]["questions"] == {"q": 10}
+        assert router.predict_log[1]["lang"] == "es"
+        assert router.predict_log[1]["model"] == "multilingual"
+        assert [gen for gen, _ in results] == [0, 10]
+    finally:
+        lane.stop()
+
+
+def test_max_concurrent_predicts_is_one():
+    calls = {"count": 0}
+    started = threading.Barrier(2, timeout=5.0)
+
+    def predict_impl(state, questions, model=None, lang=None):
+        try:
+            started.wait(timeout=5.0)
+        except threading.BrokenBarrierError:
+            pass
+        time.sleep(0.02)
+        return {"answers": {}}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    results = []
+    results_lock = threading.Lock()
+
+    def on_hammer_result(gen, payload):
+        with results_lock:
+            results.append(gen)
+
+    lane = LayaLane(
+        make_cfg(), agent_factory=factory, on_result=on_hammer_result,
+    )
+    lane.start()
+    try:
+        assert wait_until(lane.ready)
+        router = factory.routers[0]
+
+        def hammer(n):
+            for i in range(n):
+                lane.submit("s-%d-%d" % (n, i), {}, None, n * 100 + i)
+
+        threads = [threading.Thread(target=hammer, args=(n,)) for n in range(4)]
+        for t in threads:
+            t.start()
+        for t in threads:
+            t.join()
+        assert wait_until(lambda: calls["count"] >= 2, timeout=5.0)
+        # drain: wait until no more predicts happen
+        before = calls["count"]
+        time.sleep(0.15)
+        # single worker thread => concurrency counter never exceeded 1
+        assert router.max_concurrent == 1
+        assert calls["count"] >= before
+    finally:
+        lane.stop()
+
+
+def test_on_result_exactly_once_per_predict_none_on_exception():
+    calls = {"count": 0}
+
+    def predict_impl(state, questions, model=None, lang=None):
+        if state == "bad":
+            raise ValueError("infer failed")
+        return {"answers": {"ok": True}, "model": model}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    results = []
+    lane = LayaLane(
+        make_cfg(), agent_factory=factory,
+        on_result=lambda gen, payload: results.append((gen, payload)),
+    )
+    lane.start()
+    try:
+        assert wait_until(lane.ready)
+        router = factory.routers[0]
+        router.predict_log.clear()
+        assert lane.submit("good", {}, None, 7) is True
+        assert wait_until(lambda: len(results) >= 1, timeout=5.0)
+        assert lane.submit("bad", {}, None, 8) is True
+        assert wait_until(lambda: len(results) >= 2, timeout=5.0)
+        assert results[0][0] == 7
+        assert results[0][1]["answers"] == {"ok": True}
+        assert results[1] == (8, None)
+        assert len(router.predict_log) == 2
+    finally:
+        lane.stop()
+
+
+def test_drop_pending_clears_slot():
+    calls = {"count": 0}
+    entered = threading.Event()
+    release = threading.Event()
+    gate_calls = {"n": 0}
+
+    def predict_impl(state, questions, model=None, lang=None):
+        gate_calls["n"] += 1
+        if gate_calls["n"] == 1:
+            entered.set()
+            assert release.wait(timeout=5.0)
+        return {"answers": {"state": state}}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    results = []
+    lane = LayaLane(
+        make_cfg(), agent_factory=factory,
+        on_result=lambda gen, payload: results.append((gen, payload)),
+    )
+    lane.start()
+    try:
+        assert wait_until(lane.ready)
+        assert lane.submit("first", {}, None, 1) is True
+        assert entered.wait(timeout=5.0)
+        assert lane.submit("dropped", {}, None, 2) is True
+        lane.drop_pending()
+        release.set()
+        assert wait_until(lambda: len(results) >= 1, timeout=5.0)
+        time.sleep(0.1)  # allow any stray coalesced predict to (not) run
+        states = [entry["state"] for entry in factory.routers[0].predict_log[1:]]
+        assert "dropped" not in states
+        assert [gen for gen, _ in results] == [1]
+    finally:
+        lane.stop()
+
+
+def test_stop_during_blocked_predict_bounded_and_silent():
+    calls = {"count": 0}
+    entered = threading.Event()
+    release = threading.Event()
+
+    def predict_impl(state, questions, model=None, lang=None):
+        entered.set()
+        release.wait(timeout=10.0)
+        return {"answers": {}}
+
+    factory = make_factory(calls, predict_impl=predict_impl)
+    results = []
+    lane = LayaLane(
+        make_cfg(), agent_factory=factory,
+        on_result=lambda gen, payload: results.append((gen, payload)),
+    )
+    lane.start()
+    assert wait_until(lane.ready)
+    router = factory.routers[0]
+    router.predict_log.clear()
+    calls["count"] = 0  # exclude the loader warmup predict
+    assert lane.submit("blocked", {}, None, 3) is True
+    assert entered.wait(timeout=5.0)
+    start = time.monotonic()
+    lane.stop()
+    elapsed = time.monotonic() - start
+    assert elapsed < 2.5  # 1.0 s join bound per thread (loader already done)
+    assert lane.submit("after", {}, None, 4) is False
+    release.set()
+    time.sleep(0.3)  # let the stranded predict finish if it can
+    assert results == []  # no callback fires after stop() returns
+    assert calls["count"] == 1
+
+
+def test_repeated_submit_drop_stop_leaves_no_thread_alive():
+    before = set(threading.enumerate())
+    for _ in range(3):
+        calls = {"count": 0}
+        lane = LayaLane(make_cfg(), agent_factory=make_factory(calls))
+        lane.start()
+        assert wait_until(lane.ready)
+        for i in range(5):
+            lane.submit("s-%d" % i, {}, None, i)
+        lane.drop_pending()
+        lane.stop()
+        lane.stop()  # idempotent
+    # give daemon threads a beat to exit, then check none linger
+    assert wait_until(
+        lambda: not ({t.name for t in threading.enumerate() if t.is_alive()}
+                     & {"laya-loader", "laya-worker"}),
+        timeout=5.0,
+    )
+    lingering = [t for t in threading.enumerate()
+                 if t.is_alive() and t.name in ("laya-loader", "laya-worker")
+                 and t not in before]
+    assert lingering == []
+
+
+# -- todo 5: multilingual pin + lang + resource measurement ------------
+
+def test_multilingual_pin_lang_and_resources():
+    laya = pytest.importorskip("laya")
+    import resource
+
+    router = laya.Router(default="multilingual")
+    assert router.default == "multilingual"
+
+    questions = {"_probe": {"type": "noul", "instructions": "warmup probe"}}
+    rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+    start = time.monotonic()
+    result = router.predict("hola mundo", questions, model="multilingual", lang="es")
+    latency_s = time.monotonic() - start
+    rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+
+    assert set(result) >= {"model", "answers", "usage", "routing"}
+    assert "multilingual" in str(result["routing"])
+    assert "_probe" in result["answers"]
+    assert latency_s >= 0.0
+    assert isinstance(router.loaded, list) and "multilingual" in router.loaded
+    assert rss_after >= 0 and rss_before >= 0
+    # pin verified against the todo 2 laya revision
+    assert LAYA_PIN_SHA == "970dc8c5f63d7b886a68409493f37d569424f933"
+    assert laya.__version__ == "0.3.20"

+ 86 - 0
tests/test_supervisor.py

@@ -4,6 +4,7 @@ import signal
 import subprocess
 import sys
 import types
+from pathlib import Path
 
 from stt_daemon.config import Config
 from stt_daemon.supervisor import Supervisor
@@ -493,3 +494,88 @@ def test_supervisor_leaves_terminal_alone_without_tui(monkeypatch):
     supervisor.run()
 
     assert log == []
+
+
+_TRIAGE_FORBIDDEN_ARGV = ("layasvc", "stt-laya", "laya")
+
+
+def _assert_no_triage_process(argv_lists):
+    """No spawned argv may reference a laya service process or module."""
+    for argv in argv_lists:
+        joined = " ".join(argv)
+        for marker in _TRIAGE_FORBIDDEN_ARGV:
+            assert marker not in joined, f"triage process leaked into argv: {argv}"
+
+
+def test_supervisor_triage_enabled_headless_still_spawns_two():
+    """triage.enabled=True headless still spawns exactly daemon + assistant."""
+    cfg = _cfg()
+    cfg.triage.enabled = True
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append(argv)
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
+    result = supervisor.run()
+
+    assert len(spawned) == 2
+    assert "stt_daemon" in " ".join(spawned[0]) and "run" in spawned[0]
+    assert "stt_daemon.assistant" in " ".join(spawned[1])
+    _assert_no_triage_process(spawned)
+    assert result == 0
+
+
+def test_supervisor_triage_enabled_with_tui_still_spawns_three(tmp_path):
+    """triage.enabled=True with TUI still spawns exactly daemon + assistant + TUI."""
+    cfg = _cfg()
+    cfg.triage.enabled = True
+    cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
+    (tmp_path / "assistant.sock").touch()
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(argv)
+        if len(spawned) == 3:
+            proc.returncode = 0
+        return proc
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(_s):
+        clock_val[0] += 0.1
+
+    supervisor = Supervisor(
+        cfg,
+        with_tui=True,
+        spawn=fake_spawn,
+        sleep=fake_sleep,
+        clock=fake_clock,
+        poll_s=0.001,
+    )
+    result = supervisor.run()
+
+    assert len(spawned) == 3
+    assert "stt_daemon" in " ".join(spawned[0]) and "run" in spawned[0]
+    assert "stt_daemon.assistant" in " ".join(spawned[1])
+    assert "stt_daemon.tui" in " ".join(spawned[2])
+    _assert_no_triage_process(spawned)
+    assert result == 0
+
+
+def test_supervisor_source_never_spawns_a_laya_service():
+    """Regression guard: supervisor.py itself must not reference a laya process."""
+    source_path = Path(__file__).resolve().parents[1] / "src" / "stt_daemon" / "supervisor.py"
+    source = source_path.read_text(encoding="utf-8")
+
+    assert "layasvc" not in source
+    assert "stt-laya" not in source

+ 707 - 0
tests/test_triage.py

@@ -0,0 +1,707 @@
+"""Tests for the triage analyzer (todos 3, 9, 10, 11). Pure, hardware-free."""
+
+import json
+import logging
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from stt_daemon import events
+from stt_daemon.assistant import triage
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.assistant.triage import TriageAnalyzer, load_questions
+from stt_daemon.config import Config
+from stt_daemon.tui.state import ViewState
+
+EXAMPLE_PATH = str(Path(__file__).resolve().parent.parent / "questions.example.json")
+
+EXPECTED_ORDER = [
+    "category",
+    "urgency",
+    "frustration",
+    "churn_risk",
+    "needs_action",
+    "needs_clarification",
+    "contains_idea",
+    "business_opportunity",
+    "positive_sentiment",
+]
+
+
+def _cfg(path=EXAMPLE_PATH, **overrides):
+    base = {
+        "questions_file": path,
+        "state_char_budget": 1200,
+        "confidence_field": "answer_confidence",
+        "escalate_threshold": 0.6,
+        "choice_min_confidence": 0.85,
+    }
+    base.update(overrides)
+    return SimpleNamespace(**base)
+
+
+def _write_questions(tmp_path, questions):
+    path = tmp_path / "questions.json"
+    path.write_text(json.dumps(questions), encoding="utf-8")
+    return str(path)
+
+
+def _analyzer(tmp_path, questions=None, **overrides):
+    if questions is None:
+        return TriageAnalyzer(_cfg(**overrides))
+    return TriageAnalyzer(_cfg(_write_questions(tmp_path, questions), **overrides))
+
+
+# --- todo 3: load_questions -------------------------------------------------
+
+
+def test_load_example_order_preserved():
+    questions = load_questions(EXAMPLE_PATH)
+    assert questions is not None
+    assert list(questions.keys()) == EXPECTED_ORDER
+    assert questions["category"]["type"] == "choice"
+    assert questions["urgency"]["type"] == "choice"
+    for qid in EXPECTED_ORDER[2:]:
+        assert questions[qid]["type"] == "noul"
+
+
+def test_load_missing_file_returns_none_with_warning(caplog):
+    with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
+        assert load_questions("/nonexistent/questions.json") is None
+    assert caplog.records, "expected a warning for the missing file"
+
+
+def test_load_bad_json_returns_none_with_warning(tmp_path, caplog):
+    path = tmp_path / "questions.json"
+    path.write_text("{not valid json", encoding="utf-8")
+    with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
+        assert load_questions(str(path)) is None
+    assert caplog.records
+
+
+@pytest.mark.parametrize(
+    "bad_entry",
+    [
+        {"type": "mystery", "instructions": "do something"},  # unknown type
+        {"type": "score", "instructions": "rate it", "criteria": {"0": "low"}},  # score dict
+        {  # noul criteria with a bad key
+            "type": "noul",
+            "instructions": "holds?",
+            "criteria": {"true": "yes", "maybe": "hmm"},
+        },
+        "just a string",  # non-object entry
+        {"type": "choice", "instructions": "pick one", "criteria": []},  # empty choice
+        {"type": "choice", "instructions": "pick one", "criteria": {}},  # empty choice dict
+        {"type": "choice", "criteria": ["a", "b"]},  # missing instructions
+        {"type": "score", "instructions": "rate it", "criteria": ["a", None]},  # None level
+        {"type": "noul", "instructions": "holds?", "criteria": ["true"]},  # noul list
+    ],
+)
+def test_load_malformed_returns_none_with_warning(tmp_path, caplog, bad_entry):
+    path = _write_questions(
+        tmp_path, {"good": {"type": "noul", "instructions": "ok?"}, "bad": bad_entry}
+    )
+    with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
+        assert load_questions(str(path)) is None
+    assert caplog.records
+
+
+# --- todo 9: normalize ------------------------------------------------------
+
+
+def _score_questions():
+    return {
+        "severity": {
+            "type": "score",
+            "instructions": "How severe is the issue?",
+            "criteria": ["none", "low", "high", "critical"],
+        },
+        "category": {
+            "type": "choice",
+            "instructions": "What is this about?",
+            "criteria": {"support": "help needed", "sales": "buying"},
+        },
+        "churn_risk": {"type": "noul", "instructions": "User may leave."},
+    }
+
+
+def test_normalize_choice_exact(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "model": "multilingual",
+        "answers": {
+            "category": {
+                "type": "choice",
+                "choice": "support",
+                "confidence": 0.7,
+                "answer_confidence": 0.4,
+            }
+        },
+        "usage": {"input_tokens": 123, "output_tokens": 0},
+    }
+    result = analyzer.normalize(payload)
+    assert result["order"] == ["category"]
+    assert result["items"]["category"] == {
+        "kind": "choice",
+        "choice": "support",
+        "confidence": 0.7,
+        "answer_confidence": 0.4,
+        "gate": 0.4,
+        "flagged": True,
+    }
+    assert result["input_tokens"] == 123
+    assert result["escalated"] is True
+
+
+def test_normalize_score_with_legend(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "answers": {
+            "severity": {
+                "type": "score",
+                "score": 2.6,
+                "legend": {"0": "none", "1": "low", "2": "high", "3": "critical"},
+                "confidence": 0.5,
+                "answer_confidence": 0.6,
+            }
+        },
+        "usage": {"input_tokens": 7, "output_tokens": 0},
+    }
+    item = analyzer.normalize(payload)["items"]["severity"]
+    assert item["kind"] == "score"
+    assert item["expected"] == 2.6
+    assert item["level"] == 3
+    assert item["label"] == "critical"
+    assert item["confidence"] == 0.5
+    assert item["answer_confidence"] == 0.6
+    assert item["gate"] == 0.6
+    assert item["flagged"] is True
+
+
+def test_normalize_score_legend_fallback(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "answers": {
+            "severity": {"type": "score", "score": 1.2, "confidence": 0.9, "answer_confidence": 0.9}
+        }
+    }
+    item = analyzer.normalize(payload)["items"]["severity"]
+    assert item["level"] == 1
+    assert item["label"] == "low"  # falls back to criteria[level]
+    assert item["flagged"] is False  # 1.2 < (4 - 1) * 0.5
+
+
+def test_normalize_noul_and_bare_payload(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    bare = {
+        "churn_risk": {"type": "noul", "noul": 0.9, "confidence": 0.9, "answer_confidence": 0.85}
+    }
+    result = analyzer.normalize(bare)
+    assert result["order"] == ["churn_risk"]
+    assert result["items"]["churn_risk"] == {
+        "kind": "noul",
+        "p": 0.9,
+        "confidence": 0.9,
+        "answer_confidence": 0.85,
+        "gate": 0.85,
+        "flagged": True,
+    }
+    assert result["input_tokens"] == 0
+
+
+def test_normalize_skips_unknown_qid_and_malformed(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "answers": {
+            "ghost": {"type": "noul", "noul": 0.99, "confidence": 0.9, "answer_confidence": 0.9},
+            "category": {"type": "choice", "choice": 42},  # malformed: no confidences
+            "churn_risk": "nonsense",  # malformed: not a dict
+        },
+        "usage": {"input_tokens": 3, "output_tokens": 0},
+    }
+    result = analyzer.normalize(payload)
+    assert result["order"] == []
+    assert result["items"] == {}
+    assert result["escalated"] is False
+
+
+def test_normalize_ignores_side_channel_fields(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "answers": {
+            "category": {
+                "type": "choice",
+                "choice": "sales",
+                "confidence": 0.95,
+                "answer_confidence": 0.9,
+                "action": {"act" + "_probability": 0.99},
+            }
+        },
+        "usage": {"input_tokens": 1, "output_tokens": 0},
+    }
+    result = analyzer.normalize(payload)
+    assert result["items"]["category"]["flagged"] is False
+    assert "act" + "_probability" not in json.dumps(result)
+    source = Path(triage.__file__).read_text(encoding="utf-8")
+    assert "act" + "_probability" not in source
+
+
+def test_normalize_gating_field_selectable(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), confidence_field="confidence")
+    payload = {
+        "answers": {
+            "category": {
+                "type": "choice",
+                "choice": "sales",
+                "confidence": 0.5,
+                "answer_confidence": 0.99,
+            }
+        }
+    }
+    item = analyzer.normalize(payload)["items"]["category"]
+    assert item["gate"] == 0.5  # gates on confidence, keeps both
+    assert item["answer_confidence"] == 0.99
+    assert item["flagged"] is True
+
+
+# --- todo 9: condensation ---------------------------------------------------
+
+
+def test_condense_keeps_newest_chronological_no_numerals(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), state_char_budget=40)
+    window = ["alpha first", "beta second", "gamma third newest here"]
+    state = analyzer.build_state(window)
+    assert "gamma third newest here" in state
+    assert "alpha first" not in state  # oldest dropped under the budget
+    lines = state.splitlines()
+    assert all(line.startswith("- ") for line in lines)
+    assert not any(ch.isdigit() for ch in state)  # no numerals injected
+    # chronological restore: kept lines stay in original order
+    kept = [u for u in window if u in state]
+    assert [line[2:] for line in lines] == kept
+
+
+def test_condense_empty_window(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.build_state([]) == ""
+
+
+# --- todo 10: escalation + extraction ---------------------------------------
+
+
+def _signals(choice_gate=0.9, noul_p=0.1, score_expected=0.2):
+    return {
+        "order": ["category", "churn_risk", "severity"],
+        "items": {
+            "category": {
+                "kind": "choice",
+                "choice": "sales",
+                "confidence": choice_gate,
+                "answer_confidence": choice_gate,
+                "gate": choice_gate,
+                "flagged": choice_gate < 0.85,
+            },
+            "churn_risk": {
+                "kind": "noul",
+                "p": noul_p,
+                "confidence": noul_p,
+                "answer_confidence": noul_p,
+                "gate": noul_p,
+                "flagged": noul_p >= 0.6,
+            },
+            "severity": {
+                "kind": "score",
+                "expected": score_expected,
+                "level": 0,
+                "label": "none",
+                "confidence": 0.9,
+                "answer_confidence": 0.9,
+                "gate": 0.9,
+                "flagged": score_expected >= 1.5,
+            },
+        },
+        "escalated": False,
+        "input_tokens": 0,
+    }
+
+
+def test_escalation_arms_fire_independently(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.should_escalate(_signals(choice_gate=0.5)) is True  # choice arm
+    assert analyzer.should_escalate(_signals(noul_p=0.8)) is True  # noul arm
+    assert analyzer.should_escalate(_signals(score_expected=2.0)) is True  # score arm
+    assert analyzer.should_escalate(_signals()) is False
+
+
+def test_normalize_escalated_matches_should_escalate(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "answers": {
+            "category": {
+                "type": "choice",
+                "choice": "sales",
+                "confidence": 0.9,
+                "answer_confidence": 0.9,
+            },
+            "churn_risk": {
+                "type": "noul",
+                "noul": 0.1,
+                "confidence": 0.9,
+                "answer_confidence": 0.9,
+            },
+            "severity": {
+                "type": "score",
+                "score": 0.1,
+                "confidence": 0.9,
+                "answer_confidence": 0.9,
+            },
+        }
+    }
+    result = analyzer.normalize(payload)
+    assert result["escalated"] == analyzer.should_escalate(result) is False
+
+
+def test_build_extraction_prompt_words_not_dump(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_extraction_prompt(["please cancel my account"], _signals(noul_p=0.8))
+    assert "please cancel my account" in prompt
+    assert "churn_risk" in prompt  # flagged signal named in words
+    assert "category" not in prompt  # unflagged signals omitted
+    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 _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)
+
+
+def test_parse_extraction_valid_fenced(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    text = "```json\n" + _extraction_doc() + "\n```"
+    parsed = analyzer.parse_extraction(text)
+    assert parsed is not None
+    assert parsed["summary"] == "user wants to cancel"
+    assert parsed["action_items"] == [{"what": "call back", "why": "save account"}]
+
+
+def test_parse_extraction_invalid_returns_none(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_extraction("not json at all {{{") is None
+    assert analyzer.parse_extraction(json.dumps({"summary": "only"})) is None  # bad shape
+    assert analyzer.parse_extraction(None) is None
+    assert analyzer.parse_extraction("") is None
+    assert analyzer.parse_extraction("x" * 30000) is None  # oversized
+
+
+def test_parse_extraction_caps_lists_and_strings(tmp_path):
+    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
+
+
+# --- todo 11: cached state() ------------------------------------------------
+
+
+def test_state_initial_loading_with_good_questions(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.state() == ("loading", "")
+
+
+def test_state_initial_no_questions_with_bad_path(tmp_path):
+    missing = str(tmp_path / "missing.json")
+    analyzer = TriageAnalyzer(_cfg(missing))
+    state, reason = analyzer.state()
+    assert state == "no_questions"
+    assert missing in reason
+
+
+def test_state_vocabulary_has_no_extra_member():
+    assert set(triage.TRIAGE_REASONS) == {
+        "ok",
+        "no_questions",
+        "loading",
+        "laya_missing",
+        "disabled",
+        "error",
+    }
+    assert "service" + "_down" not in triage.TRIAGE_REASONS
+
+
+def test_refresh_transitions(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    analyzer.refresh("ok")
+    assert analyzer.state() == ("ok", "")
+    analyzer.refresh("laya_missing", "run: uv sync --extra triage")
+    assert analyzer.state() == ("laya_missing", "run: uv sync --extra triage")
+    analyzer.refresh("error", "boom")
+    assert analyzer.state() == ("error", "boom")
+
+
+def test_refresh_unknown_state_ignored(tmp_path, caplog):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    analyzer.refresh("ok")
+    with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
+        analyzer.refresh("bogus_state")
+    assert analyzer.state() == ("ok", "")
+    assert caplog.records
+
+
+def test_no_questions_state_cannot_be_overridden(tmp_path):
+    analyzer = TriageAnalyzer(_cfg(str(tmp_path / "missing.json")))
+    assert analyzer.state()[0] == "no_questions"
+    analyzer.refresh("ok")
+    analyzer.refresh("error", "boom")
+    assert analyzer.state()[0] == "no_questions"
+
+
+def test_state_does_not_touch_lane(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+
+    class StrictLane:
+        def __init__(self):
+            self.calls = []
+
+        def __getattr__(self, name):
+            def record(*args, **kwargs):
+                self.calls.append(name)
+
+            return record
+
+    lane = StrictLane()
+    for _ in range(3):
+        analyzer.state()
+    analyzer.refresh("ok")
+    analyzer.state()
+    assert lane.calls == []
+
+
+# --- F3: end-to-end triage flow on fakes ------------------------------------
+
+EXTRACT_JSON = (
+    '{"summary":"s","opportunities":[],"doubts":[],"action_items":[],"risks":[]}'
+)
+
+
+class _Clock:
+    """Injected clock: no sleeps, fully deterministic."""
+
+    def __init__(self, start=1000.0):
+        self.t = float(start)
+
+    def __call__(self):
+        return self.t
+
+    def advance(self, dt):
+        self.t += float(dt)
+
+
+class _FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+class _FakeLane:
+    """Duck-typed LayaLane: canned-ready, records submits, counts drops."""
+
+    def __init__(self):
+        self.submits: list[tuple] = []
+        self.drops = 0
+
+    def ready(self) -> bool:
+        return True
+
+    def state(self):
+        return ("ok", "")
+
+    def submit(self, state, questions, lang, generation):
+        self.submits.append((state, questions, lang, generation))
+        return True
+
+    def drop_pending(self):
+        self.drops += 1
+        return 1
+
+
+def _answers(noul_p=0.1):
+    """A full laya-shaped answers payload for the example questions."""
+    answers = {
+        "category": {
+            "type": "choice",
+            "choice": "sales",
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+        },
+        "urgency": {
+            "type": "choice",
+            "choice": "low",
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+        },
+    }
+    for qid in (
+        "frustration",
+        "churn_risk",
+        "needs_action",
+        "needs_clarification",
+        "contains_idea",
+        "business_opportunity",
+        "positive_sentiment",
+    ):
+        answers[qid] = {
+            "type": "noul",
+            "noul": noul_p,
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+        }
+    return {"model": "multilingual", "answers": answers, "usage": {"input_tokens": 42}}
+
+
+def test_end_to_end_triage_flow():
+    """signals -> content -> cleared across service, analyzer, lane, and ViewState.
+
+    The lane result is wired by calling `service._on_triage_result(gen, payload)`
+    directly (the runner does exactly this in production); the extraction job is
+    drained with `job.run()` so no worker thread is started.
+    """
+    cfg = Config()
+    cfg.assistant.turn_gap_s = 0.5
+    cfg.triage.enabled = True
+    cfg.triage.questions_file = EXAMPLE_PATH
+    clock = _Clock(1000.0)
+    lane = _FakeLane()
+    emitted: list[dict] = []
+
+    def emit(type_, **fields):
+        emitted.append({"type": type_, **fields})
+
+    analyzer = TriageAnalyzer(cfg.triage)
+    service = AssistantService(
+        cfg,
+        _FakeSubscriber(),
+        emit=emit,
+        time_fn=clock,
+        chat_fn=lambda text: "reply",
+        triage=analyzer,
+        laya_lane=lane,
+        triage_extract_fn=lambda prompt: EXTRACT_JSON,
+    )
+
+    def triage_events():
+        return [e for e in emitted if e["type"] == "triage"]
+
+    def extract_jobs():
+        return [j for j in service._inference._queue if j.name == "triage-extract"]
+
+    # 1. settled turn -> exactly one lane submit, lang + generation recorded
+    assert service.toggle_chat() is True
+    service.handle_event(
+        {"type": events.TRANSCRIPT, "text": "hola, necesito ayuda", "lang": "es"}
+    )
+    clock.advance(1.0)
+    service.tick()
+    assert len(lane.submits) == 1
+    state_text, questions, lang, gen1 = lane.submits[0]
+    assert "hola, necesito ayuda" in state_text
+    assert questions is analyzer.questions
+    assert lang == "es"
+    assert gen1 == 0
+
+    # 2. non-escalating result -> signals only, no extraction job queued
+    service._on_triage_result(gen1, _answers(noul_p=0.1))
+    assert [e["stage"] for e in triage_events()] == ["signals"]
+    assert triage_events()[0]["triage"]["escalated"] is False
+    assert service.last_triage is not None
+    assert service.last_triage["escalated"] is False
+    assert extract_jobs() == []
+
+    # 3. escalating turn -> second submit, then exactly one extract job
+    service.handle_event(
+        {"type": events.TRANSCRIPT, "text": "quiero cancelar mi suscripcion", "lang": "es"}
+    )
+    clock.advance(1.0)
+    service.tick()
+    assert len(lane.submits) == 2
+    gen2 = lane.submits[1][3]
+    service._on_triage_result(gen2, _answers(noul_p=0.9))
+    assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
+    assert triage_events()[1]["triage"]["escalated"] is True
+    jobs = extract_jobs()
+    assert len(jobs) == 1
+
+    # 4. run the extraction job directly (deterministic, no worker thread)
+    jobs[0].run()
+    content_events = [e for e in triage_events() if e["stage"] == "content"]
+    assert len(content_events) == 1
+    merged = content_events[0]["triage"]
+    assert merged["content"]["summary"] == "s"
+    assert merged["escalated"] is True  # signals preserved under the content
+    assert merged["order"] == triage_events()[1]["triage"]["order"]
+
+    # 5. drive the emitted stream through a fresh ViewState
+    view = ViewState()
+    for event in emitted:
+        view.ingest(event)
+    analyzer.refresh("ok")  # the lane callback does this in production
+    view.ingest({"type": "status", **service.status()})
+    assert view.triage is not None
+    assert view.triage["content"]["summary"] == "s"
+    lines = view.triage_lines()
+    assert lines
+    assert any("summary" in line and "s" in line for line in lines)
+
+    # 6. clear -> cleared event empties the panel and bumps the epoch
+    baseline_epoch = view.triage_epoch
+    already = len(emitted)
+    service.clear()
+    for event in emitted[already:]:
+        view.ingest(event)
+    assert view.triage is None
+    assert view.triage_epoch > baseline_epoch
+
+    # a stale result after clear must not re-populate the panel
+    service._on_triage_result(gen2, _answers(noul_p=0.9))
+    assert [e["stage"] for e in triage_events()] == [
+        "signals",
+        "signals",
+        "content",
+        "cleared",
+    ]
+    assert view.triage is None
+    assert lane.drops == 1
+
+    # 7. disabled control: no triage/lane -> status disabled, clear emits nothing
+    cfg2 = Config()
+    clock2 = _Clock(2000.0)
+    emitted2: list[dict] = []
+
+    def emit2(type_, **fields):
+        emitted2.append({"type": type_, **fields})
+
+    plain = AssistantService(cfg2, _FakeSubscriber(), emit=emit2, time_fn=clock2)
+    assert plain.status()["triage_state"] == "disabled"
+    assert plain.clear() is True
+    assert [e for e in emitted2 if e["type"] == "triage"] == []

+ 206 - 0
tests/test_tui_app.py

@@ -348,3 +348,209 @@ def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
 
     monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None)
     assert tui_app.main() == 0
+
+
+def test_triage_disabled_composes_no_triage_widget(server):
+    """Default (triage_enabled=False): no #triage, no #main — tree as today."""
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            assert not app.query("#triage")
+            assert not app.query("#main")
+            # The rest of the tree is intact.
+            app.query_one("#transcript")
+            app.query_one("#status")
+            app.query_one("#command")
+            app.query_one("#chat")
+            app.query_one("#edit")
+
+    asyncio.run(scenario())
+
+
+def test_triage_enabled_composes_split(server):
+    """triage_enabled=True: #main wraps #transcript + #triage."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            main = app.query_one("#main", Horizontal)
+            transcript = app.query_one("#transcript", RichLog)
+            triage = app.query_one("#triage", RichLog)
+            assert transcript.parent is main
+            assert triage.parent is main
+            assert triage.border_title == "triage"
+            # Outside widgets stay outside #main.
+            assert app.query_one("#status").parent is not main
+            assert app.query_one("#chat").parent is not main
+
+    asyncio.run(scenario())
+
+
+def test_triage_enabled_renders_signals_on_epoch_change(server):
+    """A triage event + status event must actually write rendered lines into #triage."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    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": "triage",
+                    "stage": "signals",
+                    "triage": {
+                        "order": ["category"],
+                        "items": {
+                            "category": {
+                                "kind": "choice",
+                                "choice": "question",
+                                "answer_confidence": 0.91,
+                            }
+                        },
+                    },
+                }
+            )
+            app.state.ingest(
+                {"type": "status", "triage_state": "ok", "triage_reason": ""}
+            )
+            app._render()
+            await pilot.pause()
+            return [str(line) for line in app.query_one("#triage", RichLog).lines]
+
+    lines = asyncio.run(scenario())
+    rendered = "\n".join(lines)
+    assert "category: question (0.91)" in rendered
+
+
+def test_triage_enabled_loading_shows_reason_line_never_blank(server):
+    """loading/unknown triage_state renders the reason line — never a blank panel."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    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": "status",
+                    "triage_state": "loading",
+                    "triage_reason": "model loading",
+                }
+            )
+            app._render()
+            await pilot.pause()
+            loading_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
+
+            # Unknown state falls back to a generic reason line, no crash.
+            app.state.ingest(
+                {"type": "status", "triage_state": "weird_state", "triage_reason": ""}
+            )
+            app._render()
+            await pilot.pause()
+            unknown_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
+            return loading_lines, unknown_lines
+
+    loading_lines, unknown_lines = asyncio.run(scenario())
+    assert loading_lines, "triage panel was blank in loading state"
+    assert any("triage: unavailable" in line for line in loading_lines)
+    assert any("model loading" in line for line in loading_lines)
+    assert unknown_lines, "triage panel was blank for unknown state"
+    assert any("triage: unavailable" in line for line in unknown_lines)
+    assert any("weird_state" in line for line in unknown_lines)
+
+
+def test_triage_epoch_change_clears_before_rewrite(server):
+    """Epoch bump clears #triage first: no duplicated stale lines."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    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": "triage",
+                    "stage": "signals",
+                    "triage": {"order": ["q1"], "items": {"q1": {"kind": "choice", "choice": "a"}}},
+                }
+            )
+            app.state.ingest({"type": "status", "triage_state": "ok"})
+            app._render()
+            await pilot.pause()
+
+            # New epoch: cleared stage empties the payload, panel must not keep old lines.
+            app.state.ingest({"type": "triage", "stage": "cleared"})
+            app._render()
+            await pilot.pause()
+            return [str(line) for line in app.query_one("#triage", RichLog).lines]
+
+    lines = asyncio.run(scenario())
+    rendered = "\n".join(lines)
+    assert "q1" not in rendered, "stale triage lines survived an epoch change"
+    assert "triage: unavailable — no data" in rendered
+
+
+def test_triage_disabled_never_writes_triage(server):
+    """Disabled: triage events are ingested but #triage is never touched (it doesn't exist)."""
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.state.ingest({"type": "triage", "stage": "signals", "triage": {}})
+            app.state.ingest({"type": "status", "triage_state": "ok"})
+            app._render()  # must not raise NoMatches for #triage
+            await pilot.pause()
+            assert not app.query("#triage")
+
+    asyncio.run(scenario())
+
+
+def test_default_ctor_has_no_triage_arg(server):
+    """AssistantTUI(path) stays valid: triage_enabled defaults to False."""
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    assert app.triage_enabled is False
+    assert app._seen_triage_epoch == 0

+ 214 - 0
tests/test_tui_state.py

@@ -264,3 +264,217 @@ def test_cleared_event_wipes_output_and_increments_epoch():
     assert state.chat_epoch == epoch + 1
     assert len(state.transcripts) == 1
     assert state.transcript_lines() == ["[en] preserve this"]
+
+
+def _signals_triage():
+    return {
+        "order": ["q_choice", "q_noul", "q_score", "q_unknown"],
+        "items": {
+            "q_choice": {
+                "kind": "choice",
+                "choice": "yes",
+                "answer_confidence": 0.91,
+                "flagged": True,
+            },
+            "q_noul": {"kind": "noul", "p": 0.75, "flagged": False},
+            "q_score": {"kind": "score", "level": 3, "label": "high", "flagged": True},
+            "q_unknown": {"kind": "mystery", "flagged": False},
+        },
+        "escalated": True,
+        "input_tokens": 42,
+    }
+
+
+def test_triage_signals_event_stores_and_bumps_epoch():
+    state = ViewState()
+    epoch = state.triage_epoch
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    assert state.triage == _signals_triage()
+    assert state.triage_epoch == epoch + 1
+
+
+def test_triage_content_event_replaces_triage_and_bumps_epoch():
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    epoch = state.triage_epoch
+    merged = {
+        **_signals_triage(),
+        "content": {"summary": "s", "opportunities": [], "risks": [], "action_items": []},
+    }
+    state.ingest({"type": "triage", "stage": "content", "triage": merged})
+    assert state.triage == merged
+    assert state.triage_epoch == epoch + 1
+
+
+def test_triage_cleared_event_empties_and_bumps_epoch():
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    epoch = state.triage_epoch
+    state.ingest({"type": "triage", "stage": "cleared"})
+    assert state.triage is None
+    assert state.triage_epoch == epoch + 1
+
+
+def test_status_adopts_triage_fields():
+    state = ViewState()
+    epoch = state.triage_epoch
+    state.ingest(
+        {
+            "type": "status",
+            "triage": _signals_triage(),
+            "triage_state": "ok",
+            "triage_reason": "",
+        }
+    )
+    assert state.triage_state == "ok"
+    assert state.triage == _signals_triage()
+    assert state.triage_epoch == epoch + 1
+
+
+def test_status_without_triage_fields_bumps_nothing():
+    state = ViewState()
+    state.ingest({"type": "status", "mode": "idle", "ready": True})
+    assert state.triage_epoch == 0
+    assert state.triage_state == "disabled"
+
+
+def test_triage_lines_choice_noul_score_and_flag_marks():
+    state = ViewState()
+    state.triage = _signals_triage()
+    state.triage_state = "ok"
+    lines = state.triage_lines()
+    assert lines[0] == "q_choice: yes (0.91) ⚠"
+    assert lines[1].startswith("███████    q_noul: 0.75")
+    assert lines[2].startswith("███") and "q_score: high ⚠" in lines[2]
+    assert lines[3] == "q_unknown"
+
+
+def test_triage_lines_content_block_appended():
+    state = ViewState()
+    state.triage = {
+        "order": ["q1"],
+        "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
+        "content": {
+            "summary": "user wants action",
+            "opportunities": ["opp1", "opp2", "opp3", "opp4"],
+            "risks": ["risk1"],
+            "action_items": [{"what": "call", "why": "urgent"}],
+        },
+    }
+    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
+
+
+def test_triage_lines_non_ok_state_renders_reason():
+    state = ViewState()
+    state.triage_state = "loading"
+    state.triage_reason = "model warming up"
+    assert state.triage_lines() == ["triage: unavailable — model warming up"]
+
+
+def test_triage_lines_unknown_reason_falls_back_generically():
+    state = ViewState()
+    state.triage_state = "service_down"
+    assert state.triage_lines() == ["triage: unavailable — service_down"]
+
+
+def test_triage_lines_ok_without_data_renders_no_data():
+    state = ViewState()
+    state.triage_state = "ok"
+    assert state.triage_lines() == ["triage: unavailable — no data"]
+
+
+def test_triage_lines_malformed_payload_never_raises():
+    state = ViewState()
+    state.triage_state = "ok"
+    state.triage = {
+        "order": ["a", "b", "c"],
+        "items": {
+            "a": {"kind": "choice", "flagged": True},
+            "b": {"kind": "noul", "p": "not-a-number", "flagged": True},
+            "c": {"kind": "score", "level": None},
+        },
+    }
+    lines = state.triage_lines()
+    assert lines[0] == "a: ? (?) ⚠"
+    assert "b: ? ⚠" in lines[1]
+    assert "c: ?" in lines[2]
+
+
+def test_triage_lines_empty_order_renders_no_signals():
+    state = ViewState()
+    state.triage_state = "ok"
+    state.triage = {"order": [], "items": {}}
+    assert state.triage_lines() == ["triage: no signals"]
+
+
+def test_clear_output_resets_triage_and_bumps_epoch():
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    epoch = state.triage_epoch
+    state.clear_output()
+    assert state.triage is None
+    assert state.triage_epoch == epoch + 1
+
+
+def test_status_adopted_triage_survives_then_cleared_event_empties_panel():
+    state = ViewState()
+    state.ingest(
+        {"type": "status", "triage": _signals_triage(), "triage_state": "ok", "triage_reason": ""}
+    )
+    assert state.triage_lines()[0].startswith("q_choice:")
+    state.ingest({"type": "triage", "stage": "cleared"})
+    assert state.triage is None
+
+
+def test_lone_signals_event_renders_without_status_snapshot():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "triage",
+            "stage": "signals",
+            "triage": {
+                "order": ["category"],
+                "items": {
+                    "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
+                },
+            },
+        }
+    )
+    assert state.triage_state == "ok"
+    assert state.triage_lines() == ["category: question (0.91)"]
+
+
+def test_lone_content_event_renders_without_status_snapshot():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "triage",
+            "stage": "content",
+            "triage": {
+                "order": ["category"],
+                "items": {
+                    "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
+                },
+                "content": {"summary": "wants action"},
+            },
+        }
+    )
+    assert state.triage_state == "ok"
+    lines = state.triage_lines()
+    assert lines[0] == "category: question (0.91)"
+    assert "summary: wants action" in lines
+
+
+def test_cleared_event_after_lone_signals_leaves_state_usable():
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    state.ingest({"type": "triage", "stage": "cleared"})
+    assert state.triage is None
+    assert state.triage_lines() == ["triage: unavailable — no data"]

+ 647 - 1
uv.lock

@@ -7,6 +7,15 @@ resolution-markers = [
     "python_full_version < '3.12'",
 ]
 
+[[package]]
+name = "annotated-doc"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
+]
+
 [[package]]
 name = "anyio"
 version = "4.15.1"
@@ -355,6 +364,83 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/6a/39/9d316f00f184cea15e807a977df5bc76fc8f27f81246015f12083f42cd1c/ctranslate2-4.8.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f6f0b576c247984d3fc299a372ccc9319b668d6d25b0539f3c861beba15d0504", size = 19500896, upload-time = "2026-08-31T19:38:09.111Z" },
 ]
 
+[[package]]
+name = "cuda-bindings"
+version = "13.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cuda-pathfinder" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f1/3a/06569edfd5db020c1ff7c0361268a3eaa60ead0c0ccca460fef69d68022f/cuda_bindings-13.4.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df8b3767facca8acde216460df684dfc3826d519096c13adfd3030a55870dc79", size = 6494098, upload-time = "2026-09-23T02:21:50.472Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/c7/ff34500e11229c5487bd7c35e03d0f91832ffde50bdb3f963f7aba485357/cuda_bindings-13.4.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9ea13b9cfe711515ae83b5efc99101af4d3f8ad882f99724a839c8b5417fb7e", size = 7160766, upload-time = "2026-09-23T02:21:52.858Z" },
+    { url = "https://files.pythonhosted.org/packages/65/11/1293429c1c3a3e19b551275e65efddd122a905bbe7e368816a59f3ef2a41/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfbd3f7d4ac04dd41dc49121b9e408c8283992f47124c2290ecb79bbbadcca8e", size = 6488226, upload-time = "2026-09-23T02:22:00.578Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/c3/efb6bbb7307bf5c83dc4acca650280b210c67ed1a1a60f898a90e7c82e38/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f72bcfcdf3be23e1da3c792f68f508586f48d037bca8b10f552c4cca5971f2", size = 7172185, upload-time = "2026-09-23T02:22:03.114Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/a9/c83eb5aa055a4b0c3776d83f6f88b9e778a6fe0415210977c889c6a0bb8a/cuda_bindings-13.4.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7c6c9f46fca7f3fc61959ef9a2398ac656172145b43f408e0a6492360cf1c0c", size = 6316533, upload-time = "2026-09-23T02:22:09.694Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/24/9c01edfd2210737ee9471b47db857a079e5a23f2677e5d9778c0ff23d099/cuda_bindings-13.4.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fd7d8459b364aedc11f3e59703453ced823135f78a9111ca70feef8d56d4d21", size = 6929124, upload-time = "2026-09-23T02:22:11.765Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/e6/3c094ef0eb00a7b0ff69a3915327e2c2d14e712ebe471a2217bf7f020f33/cuda_bindings-13.4.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4796864ce829bd95ef2ef0d23c6ba21bb64e08f7fab0a377302ed1affb6605c7", size = 6407948, upload-time = "2026-09-23T02:22:18.484Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/49/7a3769c43e432b0434dd46424058b47af4347167f0dfca1ecb27e2de92a1/cuda_bindings-13.4.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbacde6f75665b197016b986164cfdaa33b17515e5e635a63ddb75926aaa71c3", size = 6978955, upload-time = "2026-09-23T02:22:20.535Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/ca/2c4419ca787278f65faf0f0155791a80fa141f39a628e97e4663e2ba09fa/cuda_bindings-13.4.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6eb969920e28f66f8fc3b0b3afcb6e09381cc96bf8e8158d774e9488ae89980", size = 6314272, upload-time = "2026-09-23T02:22:26.785Z" },
+    { url = "https://files.pythonhosted.org/packages/29/9c/f878de5de8e6d1a64d55096539b7b72821e6dc62682d5968e842b95d97df/cuda_bindings-13.4.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e11cfe8fec4c85ce79feda18124971c52596f0cbd642a94f5dafc257124a4b3", size = 6865978, upload-time = "2026-09-23T02:22:29.041Z" },
+]
+
+[[package]]
+name = "cuda-pathfinder"
+version = "1.8.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/98/59/239c7259e669c46ddbcac0aa60e3a0ef00bfeaaa687f905b24dd6a7a10fe/cuda_pathfinder-1.8.2-py3-none-any.whl", hash = "sha256:4e65059febdb4d19d5cbc4798677e19db2b582f2f702f457b609e571690d357e", size = 62551, upload-time = "2026-09-17T22:19:09.396Z" },
+]
+
+[[package]]
+name = "cuda-toolkit"
+version = "13.0.3.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" },
+]
+
+[package.optional-dependencies]
+cublas = [
+    { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cudart = [
+    { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cufft = [
+    { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cufile = [
+    { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cupti = [
+    { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+curand = [
+    { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cusolver = [
+    { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+cusparse = [
+    { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+    { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+nvjitlink = [
+    { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+nvrtc = [
+    { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+nvtx = [
+    { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
+]
+
 [[package]]
 name = "faster-whisper"
 version = "1.2.1"
@@ -496,6 +582,31 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
 ]
 
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "laya"
+version = "0.3.20"
+source = { git = "https://github.com/NandhaKishorM/laya.git?rev=970dc8c5f63d7b886a68409493f37d569424f933#970dc8c5f63d7b886a68409493f37d569424f933" }
+dependencies = [
+    { name = "huggingface-hub" },
+    { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+    { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+    { name = "safetensors" },
+    { name = "torch" },
+    { name = "transformers" },
+]
+
 [[package]]
 name = "linkify-it-py"
 version = "2.2.0"
@@ -522,6 +633,80 @@ linkify = [
     { name = "linkify-it-py" },
 ]
 
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+    { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+    { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+    { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+    { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+    { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
 [[package]]
 name = "mdit-py-plugins"
 version = "0.6.1"
@@ -543,6 +728,40 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
 ]
 
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.7"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version == '3.13.*'",
+    "python_full_version == '3.12.*' or python_full_version >= '3.14'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" },
+]
+
 [[package]]
 name = "numpy"
 version = "2.4.6"
@@ -702,6 +921,158 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
 ]
 
+[[package]]
+name = "nvidia-cublas"
+version = "13.1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nvidia-cuda-nvrtc" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
+    { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime"
+version = "13.0.96"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu13"
+version = "9.24.0.43"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nvidia-cublas" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" },
+]
+
+[[package]]
+name = "nvidia-cufft"
+version = "12.0.0.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nvidia-nvjitlink" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
+]
+
+[[package]]
+name = "nvidia-cufile"
+version = "1.15.1.6"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
+]
+
+[[package]]
+name = "nvidia-curand"
+version = "10.4.0.35"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver"
+version = "12.0.4.66"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nvidia-cublas" },
+    { name = "nvidia-cusparse" },
+    { name = "nvidia-nvjitlink" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse"
+version = "12.6.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nvidia-nvjitlink" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu13"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" },
+    { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu13"
+version = "2.30.7"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink"
+version = "13.4.92"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1d/6b/eef7a9e32872b8f41e145bf10cddc9af26e153c338852811fe9a9baddf9e/nvidia_nvjitlink-13.4.92-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e0391f24ed94ec879b84e3da4d4ec320c879aff681f2c7a638462f7199284323", size = 42452378, upload-time = "2026-09-16T20:45:29.042Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/a8/1cbd4014898af8b419e69b0d7dbc63da2121ee92d92b47d59f4fe9075349/nvidia_nvjitlink-13.4.92-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:25f74fad0d654271c921ac4dca614bd6258bc21791242fc7b2289dad7ae9c099", size = 40420120, upload-time = "2026-09-16T20:45:19.163Z" },
+]
+
+[[package]]
+name = "nvidia-nvshmem-cu13"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
+]
+
 [[package]]
 name = "onnxruntime"
 version = "1.30.0"
@@ -921,6 +1292,126 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
 ]
 
+[[package]]
+name = "regex"
+version = "2026.9.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f403115361de25809e8f785686ec7096e30fef73be9ae35aa51da4e80abb/regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d", size = 417072, upload-time = "2026-09-09T21:00:21.521Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/65/82/36fdcd669c7c47e11bea67e3495011ec365be0942aae3ba74f82f92ed6a2/regex-2026.9.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:debc629e98b95abaea1cf3057ca296151f348c697c9b8a59d18013adb302c0dd", size = 493891, upload-time = "2026-09-09T20:56:45.767Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/57/bb6a8273bcb53077a1855fea0ff416fe4d4bcb1d9f350a9fe3b909853983/regex-2026.9.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d772586951d7d6a5d162d48f414065e483b1c81ab38fd8ed97c78b05883421a", size = 295107, upload-time = "2026-09-09T20:56:47.359Z" },
+    { url = "https://files.pythonhosted.org/packages/38/71/9d96d04c36556f057398a08dfc21aecc5409b5fef83fd7653a310227df7e/regex-2026.9.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b43456de605c8ee77eb75f07bc1ee44ba27f9cee22207deb77d495e954b7d953", size = 290649, upload-time = "2026-09-09T20:56:48.731Z" },
+    { url = "https://files.pythonhosted.org/packages/af/cb/4dd3b7190b5a169c81a4d63b487d65e9fb23689b771f5aceb91d9e7f65bb/regex-2026.9.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f0a8b4928823bc8b217a1ab7bf3d90598909dec9a70fbbfe9a52cc4eca55990", size = 796358, upload-time = "2026-09-09T20:56:50.021Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/bb/aceb2329a2d54a3bbd95e54f08689692414674c213e8fc958cf253206e5e/regex-2026.9.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b91c37551bf39d75116c02b146956f65b9aa0337a4a652f4ae186983789d4001", size = 866258, upload-time = "2026-09-09T20:56:51.363Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/fc/f8deac9c4c475b6bc35f25565a2253df4d27d49dcb6b189d9432ae69294e/regex-2026.9.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b9ba3b2765cdfe18f0f561a69f78a69701f2896654a81c711108d35d14e5099", size = 912779, upload-time = "2026-09-09T20:56:52.811Z" },
+    { url = "https://files.pythonhosted.org/packages/74/dc/2dd4d56932f55ea412c9808f27a8661f93568bfee596ed04bc57a74e591f/regex-2026.9.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0acee94b480dd853e39434aa9a575f95385b1b4b8fa3feae56db363ca5cad782", size = 802962, upload-time = "2026-09-09T20:56:54.241Z" },
+    { url = "https://files.pythonhosted.org/packages/46/a8/43edbb94e6c9caffd08bffca5b5c486d18845569fcad3953565b85dd71f1/regex-2026.9.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a41693eb3fc4b92e6127d113813c6c395237f7edd3224abf67609af48c690d11", size = 777136, upload-time = "2026-09-09T20:56:55.749Z" },
+    { url = "https://files.pythonhosted.org/packages/78/2a/6de302fe3dfa94b75a414bffe5180c146967f96fc52fd0b3527a05ad7c40/regex-2026.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6888065672b341e5246f391ec16dc258a29218ac784172fd67c30d941544755b", size = 786593, upload-time = "2026-09-09T20:56:57.211Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/22/66bad9464b4b9122180d2fec86209d7f1bcaf4388d34f644139799f94a20/regex-2026.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8c07021a4faa3f092869adbd1f35cdc7a592276c807aeebc3ceb8ff1a638f0b4", size = 861247, upload-time = "2026-09-09T20:56:59.194Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/90/a862d6fd9ee3bc0c1f3a2fecffb325097608a4c9b57f0e35030f68c878f7/regex-2026.9.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:58c01f7b81079cf0817ba831ff4d9eff5d28be4a3ac76c353e6f09bd63f4c386", size = 767925, upload-time = "2026-09-09T20:57:01.031Z" },
+    { url = "https://files.pythonhosted.org/packages/98/4a/aea909c0699aec3c5ee6eff93e4dabc0d310cfb0a5618094e1c5228b234d/regex-2026.9.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f0e2e5d23448b660d60a6ed85c46cc03b4b48bd276b8f4041d4a5fe2a4a0626b", size = 850772, upload-time = "2026-09-09T20:57:02.774Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/a9/a55f1257d95871b0e042b42bf52f931b3a92b4d530def5201c5b9d597e20/regex-2026.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:94d096369b7cd96d15343fef5257fe39eff9d0e8758b92a0e15e358b92cdb2fc", size = 791475, upload-time = "2026-09-09T20:57:04.216Z" },
+    { url = "https://files.pythonhosted.org/packages/60/96/04bb3dabe9ac7aa64ff758dc34ae0378d60f5279ccb66788e1f837a0cac0/regex-2026.9.10-cp311-cp311-win32.whl", hash = "sha256:7f8f10015866608fe4c043cec2e4fe4c39a94bb50e45091de4cdf4004b9ae4b0", size = 266937, upload-time = "2026-09-09T20:57:05.726Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/7f/2d39cd798871b683409cd14ab4458f60e53db86591028a8244c3155f2e92/regex-2026.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ce7c118cb102975f974585688357a717ffbf9dddd64ab0bb1bc93eb5b367cf95", size = 278263, upload-time = "2026-09-09T20:57:07.089Z" },
+    { url = "https://files.pythonhosted.org/packages/13/5f/eb3a6fd0ef50719d105e77bcc8a94b5b6f4d2873819fcf481279c454ce73/regex-2026.9.10-cp311-cp311-win_arm64.whl", hash = "sha256:1e954e246466d5a1a78f563ce8364b5d7cb19e7adb0ccdec8f9c9610083187bc", size = 277305, upload-time = "2026-09-09T20:57:08.442Z" },
+    { url = "https://files.pythonhosted.org/packages/32/c8/bfbe893e90ee0148bd2860dd086f09b5d2080ca2b125f740c2e118c16982/regex-2026.9.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880ac684c27176464c00c3fdc456116364f5ebc70da07aad0c2d4a7ba45e98db", size = 496609, upload-time = "2026-09-09T20:57:09.987Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/ac/56d5ae6efb759255c3b3db650a4be25f96a844ea3613b91e1e189a3b7294/regex-2026.9.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9d36b03dc362aa40ffaaec9d9bd75e87763529563ec008c43b0e07782f5be7a", size = 297024, upload-time = "2026-09-09T20:57:11.35Z" },
+    { url = "https://files.pythonhosted.org/packages/60/4b/0f2d5f6bbb791cc10f22f0ed16c487e630dde8fa8fa0bd92a2bfe21a4b20/regex-2026.9.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866de9f98df0611d7b62b3a8729d3284a64c0cc6edd90bb95a533e443a4939cb", size = 291905, upload-time = "2026-09-09T20:57:13.127Z" },
+    { url = "https://files.pythonhosted.org/packages/89/51/3fb5fe0d32f4cf0bc982286722c729a8d6f522d2fa2d5d14a702d9fc87f8/regex-2026.9.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c66d54042a14a503907d81861b8a5235e6d1f03d4fbc1d8767f652eaf957ac1", size = 800055, upload-time = "2026-09-09T20:57:14.68Z" },
+    { url = "https://files.pythonhosted.org/packages/89/46/ee507bd2f9d4420f26a594b35c551d7194b66f5d7897f63730fae6ec05c1/regex-2026.9.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:032da15431c890d376f53547f0a6219f4f4cd19f3e4f11bdc321453b5bd207e4", size = 871133, upload-time = "2026-09-09T20:57:16.674Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/75/cbaa90689684f91b1bc017e7f8c6d9425c6bd299108db02482dc51376d8a/regex-2026.9.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:23ac9a28180f274d7dd7651fa131ad5b02d343b75df4b040737f0356223895dd", size = 919627, upload-time = "2026-09-09T20:57:18.402Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/f5/dcdf5e0d898024005cfcce631e3e934d111dfbe177ca0b7f253ae8a735a2/regex-2026.9.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e67f8843f0e4b931f1fa860bf3bbe4134b714c0155cc5c7c0d7ea450230aae0", size = 804587, upload-time = "2026-09-09T20:57:19.859Z" },
+    { url = "https://files.pythonhosted.org/packages/73/70/eedfe81c29bae266a06ab4250978361a9bccd474704d88d4f4ef4506dff8/regex-2026.9.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bb7774924f8cd69f49cba0b3c2d679a6326f777e0e67d130ad5203e4df53f0d3", size = 777320, upload-time = "2026-09-09T20:57:21.62Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/e5/86b207077efbcd91305700488f170b7eb1e1c54721cea74175273aa3b9a4/regex-2026.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c32480f3371b75068decaf9e5da72c224e953830dd71e36e06cf80e30ea39d8", size = 790572, upload-time = "2026-09-09T20:57:23.048Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/92/f622c3b2323f4c035b98e80221740a442127ad7993135b814f52057430db/regex-2026.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d2d377fd1cad611b806cdd732d86b65f536c768209890cb442556548daa65a23", size = 865485, upload-time = "2026-09-09T20:57:24.658Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/be/34bd621d3d6ac906ad67e57ed56c40cd45f7d51b9c0328335e97a7cb8ecb/regex-2026.9.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c014641157e9049b0603b8daa5343bd408d9b757b709aaa0f373cd3fab2d7944", size = 767925, upload-time = "2026-09-09T20:57:26.268Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/28/ddbf7cba86f2adf5038c6c16aa829636ffc6e437f81bb0cbf302899cea5e/regex-2026.9.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1562aabd9d4eb09bd88a62ad97ed06800094b529ac43419e43020b9cefec79b0", size = 858800, upload-time = "2026-09-09T20:57:27.901Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/f1/e8d7656ff3d3bd32e881d32f540b4981c79dee61908d6b790a45966e6895/regex-2026.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2479171edccced52ef02b899558f88ab2c235fe05b93180fdcae1670aacd89e1", size = 791648, upload-time = "2026-09-09T20:57:29.786Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/65/eac1a79115c8475d8ff539602eb54031564d719fdea5a599c4b0a1a26d1b/regex-2026.9.10-cp312-cp312-win32.whl", hash = "sha256:239620b0e0681669367c0e218c8eb2551d9f8fe3b9fccfc8d0003377804e8348", size = 267326, upload-time = "2026-09-09T20:57:31.782Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/d4/4dcd0a05d3e97ca829165df1c39717f899d1d484dac8a6032439f2cb8d6d/regex-2026.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4db7d00c4afbfbb55b8e17b1e371da11418ea9389b030acec63c1fa4c7ad4b86", size = 277933, upload-time = "2026-09-09T20:57:33.648Z" },
+    { url = "https://files.pythonhosted.org/packages/55/f8/22617a80dee28f2451011eae36bc26b3d78c4994ba87b5281d60acf9b6c0/regex-2026.9.10-cp312-cp312-win_arm64.whl", hash = "sha256:c25a754bb81a2edcfc3b65eda50f017d736f818112ed43e8aafd595cb00678ae", size = 277447, upload-time = "2026-09-09T20:57:35.156Z" },
+    { url = "https://files.pythonhosted.org/packages/20/90/d4452bf1ef7dbe406980e8b921a257024482203c1dafac535eae207611bc/regex-2026.9.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ef5a059ea1c6ee5d1c7e99a2484e628608d010921efe876c6f0e2029d2f35eca", size = 496408, upload-time = "2026-09-09T20:57:36.757Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/35/c763c6424a0f99d021d46dc1f9065147bb5a40c2b2cdf28d2ebdbcd96508/regex-2026.9.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dce932f8e3ba936475ea3d0d8b59f7b050a9e206e994f53f8fd80299871e87da", size = 296931, upload-time = "2026-09-09T20:57:38.811Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/68/241f88458b17c46ed2f80147a60a03b2ada7fb815c23b6bc76c298abb0a5/regex-2026.9.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d8c668af8f7bdb1d18739c27d30cd9f4b371495a883f75a002fb7a39d740fecd", size = 291741, upload-time = "2026-09-09T20:57:40.482Z" },
+    { url = "https://files.pythonhosted.org/packages/90/9e/974d6de404c63e2d09525f4ddb99874c7ab8e1f781ccbe0dd3e26fa6f6e5/regex-2026.9.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aebdd9a946de328b3f6f61dbf48dd064a36eb6dddf96e34ae6651d37f6e9383", size = 800088, upload-time = "2026-09-09T20:57:42.098Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/fd/3875b73f9e7ba3321dcaa02c19f650c05c61345328acf84599ac6f45ceed/regex-2026.9.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f2374c27deb189b282ec7e16106752c22ad39b056bbd8018960b1e4cc95d67a1", size = 871212, upload-time = "2026-09-09T20:57:44.03Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/f5/2358e791c0e171194dd6a8b97b520579098a21397fb79dbe6b7edc9e3fa7/regex-2026.9.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e0dc78251154b66dc60211563fc115345da332eaa881e4e2523fb1edae3772f4", size = 919752, upload-time = "2026-09-09T20:57:45.691Z" },
+    { url = "https://files.pythonhosted.org/packages/20/3b/000c79c3f9c06b7542225a5d3a7f9a85405da7224b3b9af94a491d07abea/regex-2026.9.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bafa41b0dd63669e5c0f8adf3d24819efeb73c847f492eb011212eb352e69041", size = 804578, upload-time = "2026-09-09T20:57:47.548Z" },
+    { url = "https://files.pythonhosted.org/packages/30/6d/195eedb1de87f26639191e7487e41eb81e2ce255bc7563a64f3f5a95eb08/regex-2026.9.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ebb2ba68e4641a994061f70bf44ed448fba0b9b1d18c94ffb9efc1cca805b39b", size = 777345, upload-time = "2026-09-09T20:57:49.63Z" },
+    { url = "https://files.pythonhosted.org/packages/79/11/11fe2b313fcd92cb75c583648f2746031b9f4da9e9ed4241204a5e8b3721/regex-2026.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:048a89ee797db10160bd2bd519286577a6b43a100279bd4b7d8456a3d69c80a0", size = 790556, upload-time = "2026-09-09T20:57:51.27Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/c0/07ec9b4c43b0e16d62454971a5ab3886eccb0bfa161300a02d801ab28620/regex-2026.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:79e9432995e14c749d34209413de5e621ec8e67789bf4f46dbfabea9d06a2406", size = 865572, upload-time = "2026-09-09T20:57:53.163Z" },
+    { url = "https://files.pythonhosted.org/packages/19/07/43bc9a9cf9fc8e37d2ba47980dfe4a6e151d2cf3ab969e0031e2a9b21484/regex-2026.9.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5847e22bbf959764d776937d791d034cc2d19b787e361c88d97e859e8dc68502", size = 767971, upload-time = "2026-09-09T20:57:54.805Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/49/3b9286a3a94f3c89ed4ddbe74e72bdde21c1a5eadd520d5f4ed4a61936cb/regex-2026.9.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c103b3b14e011774af4fb7e4617ad4d72b9171905cd3b231a70a4efd76e477d7", size = 858835, upload-time = "2026-09-09T20:57:56.627Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/9e/e5d27ce9fee8e3ef95f886c7b6ecec211efa4cfc18bd73bd5cf26cca4741/regex-2026.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6b34a778c695d24e77c140e3b4c95da69282e34f2f6b02b55656aa4a0379f643", size = 791793, upload-time = "2026-09-09T20:57:58.313Z" },
+    { url = "https://files.pythonhosted.org/packages/63/03/c28a6bebedc3e2d86ee27ec2de16f7ec0419dcd10e771d43dcc9c58a2e99/regex-2026.9.10-cp313-cp313-win32.whl", hash = "sha256:7abb38b8c40f3a235235a44da452c64b7b5c1d650ec6351027db0e090804f2e5", size = 267298, upload-time = "2026-09-09T20:58:00.009Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/fd/5c85fa6cfb8e034080bda5a72fa0a4df2b7777a35eb7e73c2799c2adda7a/regex-2026.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:20e8bfb07ad79a282f8b95b56fe67f9750b1b7f775724e4ba1f23cb296115ce4", size = 277894, upload-time = "2026-09-09T20:58:01.731Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/28/f5a25f6f65501675977fda35d9f61abb1468c4b87c0f73e536d8b21a60b8/regex-2026.9.10-cp313-cp313-win_arm64.whl", hash = "sha256:3bdeed3318a8eb2bbadc9c56347e0ff651639e934a47e168d05a3b12929fd0e7", size = 277436, upload-time = "2026-09-09T20:58:03.422Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/ed/98e9b07d8bb9c765d07774f0b2c19b301b96d51f44630fea48951051c94e/regex-2026.9.10-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd6bd89b9fc06018d35851cab0240adb7dd84d51941b19f6574ac90cd54e3ae5", size = 496662, upload-time = "2026-09-09T20:58:05.118Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/a8/9dfe9be48378b47c5a8f04b0f200ea225f9ee0f8e93f010433f661a37878/regex-2026.9.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ef4c0a9dfdc90581b90b1b95a8c3d1557f8ff8f5a2a53536d26314de699d1468", size = 297115, upload-time = "2026-09-09T20:58:06.822Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/e4/5d1f005a3825ec49842ad349061c1c26e6d42f47ccf105e6e5aa6aeed392/regex-2026.9.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14caa05ce39ec70437af5aac8814c50ee6628f4a90353871c059692f448a164f", size = 291896, upload-time = "2026-09-09T20:58:08.675Z" },
+    { url = "https://files.pythonhosted.org/packages/be/15/44ce83fca50c6058f42b62fa8300a8030eea7e4e5a973a2dd33db0f557fb/regex-2026.9.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3264132d576847ab5f88bb83e7debe67854bf165b3ea613bd467312b6099536a", size = 800534, upload-time = "2026-09-09T20:58:10.339Z" },
+    { url = "https://files.pythonhosted.org/packages/af/6e/a62e070a5a033643b287489576f02ae6a9c584c337d62349e340a2b4d001/regex-2026.9.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cef9f3d14796500ea834c41dbe688f1f6b23c7024dc23e8a794d7ebaf5d71d0", size = 872038, upload-time = "2026-09-09T20:58:12.186Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/06/8b8e2483949b1329df10c5b615e85d332066deb426b11332b799629b9201/regex-2026.9.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d278ad30ec83b6b9202685b0f80b741a51ea3ca7f0595ebda96e7628b6398876", size = 918927, upload-time = "2026-09-09T20:58:13.903Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/64/9b56f69100d3afdbc9c4fa6e302764f9cb717fbc06a9d50558d98ca89cd2/regex-2026.9.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb018cfe7144585fc83882405906ff84994a2d154afc2509ecc7752c51f864", size = 803694, upload-time = "2026-09-09T20:58:15.949Z" },
+    { url = "https://files.pythonhosted.org/packages/07/43/d00d59a7c8fd0e070ae8457a8743597f45ad9682b100f57b9c9c405fbdfd/regex-2026.9.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fd555fc9abef50c530869690b2daca054c8811a7aff632d11f9a7b2590b2742", size = 777770, upload-time = "2026-09-09T20:58:17.628Z" },
+    { url = "https://files.pythonhosted.org/packages/46/6b/a11d0446484efbc9eb67abec133f254c6d66a1568b8f3fb36d39a73a1129/regex-2026.9.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d5c4518235a2ec1611e57af85fa488d529c1106aacff12adadcedf8687012cd", size = 791234, upload-time = "2026-09-09T20:58:19.476Z" },
+    { url = "https://files.pythonhosted.org/packages/12/09/bcd24e78b373fd4f98090caa43eade439223f6703b909be79b9efd9ab0ab/regex-2026.9.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:175cf49ce7a994c88b8f15e3cb17cdb66a48ebb2d36de736b8205033db950f89", size = 866259, upload-time = "2026-09-09T20:58:21.683Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/c6/c57ba5e94222a813260af4c17ced92d40cdb44737eb6b50979688310b6a3/regex-2026.9.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b71649169a9fcf30b395ee01047fa7ad6654a4c900ca75b23c04dedcce6a1f8c", size = 768219, upload-time = "2026-09-09T20:58:23.842Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/a2/3820037587d00901ace96c5864335c2cd1b899d5263ea0dd2261359e0bee/regex-2026.9.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ba1f78bd4fef2d8f84b894ec28ac3481afe6cc07aaa253ad4717ef7b3fe6bcb", size = 858582, upload-time = "2026-09-09T20:58:25.607Z" },
+    { url = "https://files.pythonhosted.org/packages/47/f0/f9a838ca6219ae4821de0175e4548db73ef56de5ec08d032fb427732fa07/regex-2026.9.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:217e98ba5fc8908ed8ffd4ebac04753a0c831067cbfb495b9821b94cc61eaa76", size = 791405, upload-time = "2026-09-09T20:58:27.406Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/bf/67d71cc4e13ae2e0022d21243ca069e0868701a99eb29cdecd13d356e694/regex-2026.9.10-cp314-cp314-win32.whl", hash = "sha256:b298cdc33c5cc6969ff07f0fba19cc73e0fd8576373c50935feadaca2f6b4405", size = 272702, upload-time = "2026-09-09T20:58:29.116Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/38/40a93e72703a741235115ed1b1e5f6b869917677b7643005034ce1611d70/regex-2026.9.10-cp314-cp314-win_amd64.whl", hash = "sha256:c32818b28bcd153b25b63038348a9fe9b9fbcddb60df43f204c3ab55eeb57f77", size = 281170, upload-time = "2026-09-09T20:58:30.899Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/ac/e95387f00617c16bb41b786d900bacd17eab88afa81b4f263df532b3a731/regex-2026.9.10-cp314-cp314-win_arm64.whl", hash = "sha256:75242f44a3e283106077be4ab717bc535e4701c9d54ad69e195945c22f137a1d", size = 281511, upload-time = "2026-09-09T20:58:32.594Z" },
+    { url = "https://files.pythonhosted.org/packages/39/e5/a4b12262edc488a8a7a95b672db317dd8aa9bf2fab98297f9c91bb11ad4d/regex-2026.9.10-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2dd9286093c71afc8f55ef035c5b9d2776641fd72c6535f1febc92d0b0be9666", size = 501128, upload-time = "2026-09-09T20:58:34.306Z" },
+    { url = "https://files.pythonhosted.org/packages/55/c8/9ca31c0fa5197ded8614c8ae0e105bcff2d979025ffa3c75580baa334e2f/regex-2026.9.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:71879292c9c7ac67b1680345b16daba1be937cb027362cfa04e68f65db2dcfdd", size = 299428, upload-time = "2026-09-09T20:58:36.296Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/d1/ee7662561735f90475443c3ca1977e5cecaeaa8f29620dec75580aebc839/regex-2026.9.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ccd139b2061132e7b265cfb4b4721baeb9f8928b81415304abf1ec7e3181c26", size = 294494, upload-time = "2026-09-09T20:58:37.964Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/b1/333168e45ed6cfe71f6d17e21e5f54725f44d171f84edd12469a2f739227/regex-2026.9.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7327795089ddb44912dce1434e1d7244be2e9fb48fcc2d6782936af7a3062db", size = 814925, upload-time = "2026-09-09T20:58:40.438Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/a5/df1b38536d0a3b24a030eb4130ce98b403cc925220ff530f5313a7c436eb/regex-2026.9.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff4d7b14ea19e50c8d9d6d83f45bd9b45cbb624c07ac1fa54db0a019049abed7", size = 873323, upload-time = "2026-09-09T20:58:42.349Z" },
+    { url = "https://files.pythonhosted.org/packages/93/ea/aa71fe62dd63a8336bbdec1ff002a6c53b40ccfca95961c18ee4f09bf03c/regex-2026.9.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f0407474ffac8e5e89d93ca41d60891e29f0ab8423eb66ff292d850a86a0843", size = 923080, upload-time = "2026-09-09T20:58:44.488Z" },
+    { url = "https://files.pythonhosted.org/packages/86/38/49f8d6fd34fc1a9c75b5b96364ebdde702a8144e5ed63d2213c58d454c27/regex-2026.9.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ad10a135fa0b4e4a462a61d07c6654d7518cfdb5cb8da08f9ff7d61384af1fe", size = 821284, upload-time = "2026-09-09T20:58:46.56Z" },
+    { url = "https://files.pythonhosted.org/packages/32/45/91a977c96d4be13d1ade8208c260c26841c836d4c91745e1828a30589070/regex-2026.9.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9fbd2e5d8002dc49a6129fb321ec51c57a025e752ed525ddce0ba9223c4350a7", size = 789256, upload-time = "2026-09-09T20:58:48.473Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/79/4d110bf01bf9651bf9b3f88a6d9fa7e643e0586921a18432b25a478edbfa/regex-2026.9.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4a761ea45f2ad74c575ef5850ea514cef97302a552d3c7c9d1a1a870d4661d6c", size = 803722, upload-time = "2026-09-09T20:58:50.286Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/67/b587a0d3bbac2635309ed9c40c197120c39e1ec0afb8813cbed1338fdd75/regex-2026.9.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:75aa39d3f4f1650eea84e46b0d8cefe77dd5478c10e3d0aaf0b0f00493475a7a", size = 870085, upload-time = "2026-09-09T20:58:52.224Z" },
+    { url = "https://files.pythonhosted.org/packages/07/fc/0827bca20ddba1d70fa5111a2a64e6b6b38bfdab43fc435022b54172a111/regex-2026.9.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f5c629df03adec31ee505dda3c8988f106c9390e4cbd343600036eb8b3d6724f", size = 776970, upload-time = "2026-09-09T20:58:54.478Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/03/ab9d08d30568ca868791bfb99551db60b947f05e2e65dcd1261e87083c21/regex-2026.9.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3a66e40a1a20de96a2fee00ed67e11012b62d85b277688258677fd19997addb7", size = 863611, upload-time = "2026-09-09T20:58:56.432Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/6d/8063ae86b543ae878a7d6e7ba21ebf0af4af06161230e0012e2d652320c6/regex-2026.9.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:968c1e33edd9a104d1bf24c8d476c72de7e3839ae7f894b37e9e4f4739fdeeca", size = 804064, upload-time = "2026-09-09T20:58:59.066Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/63/b19305c4b8d3d7867699f7d83c43550273d91a2f31793452d87edb1d259f/regex-2026.9.10-cp314-cp314t-win32.whl", hash = "sha256:fbc4e2f3cb7ce8436154e6483079e7d35eeb321a952fa936e180300630d8b873", size = 274607, upload-time = "2026-09-09T20:59:00.942Z" },
+    { url = "https://files.pythonhosted.org/packages/32/b8/1695072a512a49060294024e23b945eeb87675b02c06e53a92a1b42b0bfd/regex-2026.9.10-cp314-cp314t-win_amd64.whl", hash = "sha256:c37fa93bf18bf4f90b01c0fa9f11ea567ee4b7dd8bf96e63663e5edc37aa38cf", size = 283944, upload-time = "2026-09-09T20:59:02.876Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/9f/65bac17f39991a67e22f8b3c849fdc02a56147c97029b5351494341959b4/regex-2026.9.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ffc2da104e43db716ce30cef9f28049a1faa6aca385dd8771b033268d0730b07", size = 283780, upload-time = "2026-09-09T20:59:05.009Z" },
+    { url = "https://files.pythonhosted.org/packages/67/ca/1d1f83bc2f8fff4f186266ac82d73254e686565530cec9ab5228fb5c63dc/regex-2026.9.10-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6afcad14310f1311d077553ed374b42a5e538f85a8c884b4e38e52de091c8077", size = 496869, upload-time = "2026-09-09T20:59:07.051Z" },
+    { url = "https://files.pythonhosted.org/packages/33/42/4217510286501a2ebcd372b781b4754ac961e043fa13ef8dce803c44d89c/regex-2026.9.10-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:3fb4ae8cf83ef4e9addd43b2da31a9f45be816a8036fae8af59c8998b72718e2", size = 297121, upload-time = "2026-09-09T20:59:09.007Z" },
+    { url = "https://files.pythonhosted.org/packages/55/a7/595468ed0bbccd94be92c6b5d67736ba128b204d942429a8485c2693914d/regex-2026.9.10-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f7d4656e17ab736e9415a6442a345bfc97bb8b7dcce47884bb74a37f70f08d0c", size = 292139, upload-time = "2026-09-09T20:59:10.859Z" },
+    { url = "https://files.pythonhosted.org/packages/29/1c/ac92c123e0ab9bea75a904272171b356940bd6139e4a35de44f2254dca8f/regex-2026.9.10-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ba3bab0c45079735f55ac61526774de1d84bc4a0333cc554e1a4ab74913924", size = 802375, upload-time = "2026-09-09T20:59:12.823Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/16/d349f6fa9f908162359004e4f067353a0146e8074d24989490778244be44/regex-2026.9.10-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff6b3267318661dfddf6b3628663e00e5946bd0a5c8fa678537a1401f0388f91", size = 872328, upload-time = "2026-09-09T20:59:15.352Z" },
+    { url = "https://files.pythonhosted.org/packages/df/81/251b5aef23147057e926346fdb7c8c352d0568f65a492e4e9eb6120f6446/regex-2026.9.10-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030fa9e23624e39b3b94e46b90a5abd1a1678eb2f58fcdd3fd6c27526bf91c7e", size = 919594, upload-time = "2026-09-09T20:59:17.31Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/6e/1f25319dc1b9cf4b7ffa303f3d16ca53260fe92aff45e81aa1b3c6c7cba2/regex-2026.9.10-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fbc8314436353e097c050e11b01a6c11433579437ed0579730157676ef59e2f", size = 807394, upload-time = "2026-09-09T20:59:19.328Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/fc/3da6b3dffddf12d5e96cbbe6f5e65ebcd64f92e3388a6690a53e0878232d/regex-2026.9.10-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e6c0b5ec6ddee4032247585dc491b0fa58627745b66a705728703a3f0331231", size = 786018, upload-time = "2026-09-09T20:59:21.592Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/6f/1cc86dafddc912ef44c5ccd4f729060be8c6735600932664eb6d0e25469b/regex-2026.9.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bf29611e5376fec8f795879bb5c6153a76c3a292573d173c26784042b01eb840", size = 793504, upload-time = "2026-09-09T20:59:23.734Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/cb/11692e29388d006211163627fcefa0c79917ab9f94d46a1b2254fe5efc81/regex-2026.9.10-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8855f08c17895a26fbf5f19ed829722e19b34a96629e49a43c92974924026b", size = 866766, upload-time = "2026-09-09T20:59:25.798Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/3631df969830f94d83fcfc5fc71a7b39caad905e18f7b17350a94135d625/regex-2026.9.10-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94c5ce3bc41d226b4eb89ca3f842b2e28c031487fb1f34eb2153d98235831325", size = 775825, upload-time = "2026-09-09T20:59:28.51Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/8a/762892a8e3e21d119eacaffde848aa247e6cf4e1d90c46011671e86b1b9e/regex-2026.9.10-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:9ce239acb15843ab03976626af810a4424b0409689ec2bbc52088ab5479ab487", size = 858901, upload-time = "2026-09-09T20:59:30.656Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/f0/78048fada5c2f61d795efb26ea24f820a5564c4aa26574920284d45cd656/regex-2026.9.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c22df8dd6373bbe3898e77429ffc85594300e39d752fd0e68a31e59d37899376", size = 796208, upload-time = "2026-09-09T20:59:32.852Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/58/632681f7b9aaa3d83b40e5862ba46364a453c8eb2bc7d43fece3dc31f972/regex-2026.9.10-cp315-cp315-win32.whl", hash = "sha256:1aa309ab7ba89a62d6cf70dbd38d4176440bce3c7001ab86256704cf4c18c6eb", size = 272704, upload-time = "2026-09-09T20:59:34.927Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/64/81cce28754c37037b1fe740b6d7a556d51d97cf935ea4547bfab104f43f7/regex-2026.9.10-cp315-cp315-win_amd64.whl", hash = "sha256:58da726d3e766c0b3f5a3997dfaf0275898a1107b8191cdd6b0437fe45fd817d", size = 281182, upload-time = "2026-09-09T20:59:36.86Z" },
+    { url = "https://files.pythonhosted.org/packages/33/28/5a13a340c9c759e863a0e7f765d323601d03d6538c8a627ed628662e083b/regex-2026.9.10-cp315-cp315-win_arm64.whl", hash = "sha256:75f9297b16fcb588a1f8d8a55dabef3c0c20b0c7bac43c87ceaaaf1a825c12f4", size = 281512, upload-time = "2026-09-09T20:59:38.875Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/38/a3caebcd5105be90708071db20bd261b0961b8ea4fe5e8be45c2632519b5/regex-2026.9.10-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1270cdec69248592bbe38a0b263ed58d907b891bd2b93703e225c317e421bda1", size = 501336, upload-time = "2026-09-09T20:59:40.987Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/b5/ec8887b2658bf0a5df143c7c1fcd562b0abd2fa208c04ebf15c6607c9bb2/regex-2026.9.10-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:681ed38664b64c6617d3c3c332018d1948c77e139c5ea667c1886efa671e426f", size = 299318, upload-time = "2026-09-09T20:59:43.039Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/58/84724a9eccf6e8cd46f7e4534576093e2476a5eeb55e2453aa6606e86059/regex-2026.9.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8e127d9a80cbf1c3276bb465c6d047e8705e97b58c2b8f2f0c0a69c336b44b37", size = 294847, upload-time = "2026-09-09T20:59:44.954Z" },
+    { url = "https://files.pythonhosted.org/packages/50/03/70ccc5e53905984abf8eab63eebd3ce740522ef8c8d392622b64d79ef290/regex-2026.9.10-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:990797e765d89a423880052c68b61c31afe701de94a8c060f61c40605ca6c727", size = 814359, upload-time = "2026-09-09T20:59:47.194Z" },
+    { url = "https://files.pythonhosted.org/packages/29/53/40f7a11ec547e4a947883c9d5e8a075f6d4f59af2b8dbcaa8bf5b504aca0/regex-2026.9.10-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5e4a6e0734a685d13b9685622bb503bdbb2927f8b0df025a5085f0ea067475b", size = 875586, upload-time = "2026-09-09T20:59:49.537Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/9d/83f3e022d99ce601727c4ef5f7b527753901ce4509ed89a1bb6a2263380a/regex-2026.9.10-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63bb62cf62217dc38c8a6b2b61b165b0e4eb8fa93b0aba12139251c0986a8fa3", size = 920990, upload-time = "2026-09-09T20:59:51.776Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/64/dfdb367d8f4f5c9b8ccb4b59789c2ce996f2c69c3b8192aa986fe7d92ec4/regex-2026.9.10-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb76a9c4e07a6a47849726af0ed14c41741a182f097f134a8cf29c1bc0f4dde8", size = 818660, upload-time = "2026-09-09T20:59:54.428Z" },
+    { url = "https://files.pythonhosted.org/packages/88/a6/fb7d0b64487913845834f319aa84f8377d55305958a5db7e19c128798366/regex-2026.9.10-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31e4df2b11d48f61d511019bc1ee9b477055f17c352b68fe72db7a98b14d603c", size = 794976, upload-time = "2026-09-09T20:59:56.799Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/1c/23484edaae387ea0d31f4d414463211e3516c8aa210ce8d0640f5aff3502/regex-2026.9.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cf377960d2ac37d987394a9dbaa75e91338c41a46d41e1d25e90125e7b3ee2dc", size = 804081, upload-time = "2026-09-09T20:59:59.39Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/e1/e30d138f13aecec99ea9aecef7e31563de6ec6a2f5ce1d65fc506aee33fe/regex-2026.9.10-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8fbd9cb30c68c1686b94029b9ef845d5870d3d65baf66cb126b676849b9d72b", size = 870430, upload-time = "2026-09-09T21:00:03.304Z" },
+    { url = "https://files.pythonhosted.org/packages/53/dc/81f9ce86f7ae4f57901543597c95751fa01c41a672ec1636dd913f8a000a/regex-2026.9.10-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:53e182b6b04d0011909b47d51a2d72d908de07c7b1c7f16b3adda2204d723bc1", size = 783327, upload-time = "2026-09-09T21:00:05.68Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/1b/d7bf8f91534740f6a8ca17e5ac9c5337903baf527c8de240fbac1bbedfd0/regex-2026.9.10-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:0aa7589394230e0f0a422ab6b90841ff12c87e855e7aaf75d192a54a5f124548", size = 860874, upload-time = "2026-09-09T21:00:08.41Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/1d/52cc88364aca7c9013dfe9abe0fea67f8d394efe384d07798300a6e2f27d/regex-2026.9.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1b891f77554bff991804cee24b78b40789f7d5993a24c7907bc7025fd2a70c8d", size = 806705, upload-time = "2026-09-09T21:00:10.768Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/51/37a194df7707f92f33173260ba7b221d4ec376419a53babaec50019a2804/regex-2026.9.10-cp315-cp315t-win32.whl", hash = "sha256:5bef622850cf760154719d4e0d74b0a855962432995168e250069899ae12fe8f", size = 274780, upload-time = "2026-09-09T21:00:14.003Z" },
+    { url = "https://files.pythonhosted.org/packages/77/12/3227a52970d90908b230f15b2c86df903f49ac72c9eedf4f6e8b5bb5e1a7/regex-2026.9.10-cp315-cp315t-win_amd64.whl", hash = "sha256:07b45ba5c94b8fcb30cb6c56a11f715c57533a3017964504322ea52690a27b72", size = 283936, upload-time = "2026-09-09T21:00:16.275Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/bc/c567c5a61671f04d30e83f20b496b465432879196574d051007285576205/regex-2026.9.10-cp315-cp315t-win_arm64.whl", hash = "sha256:f70b9f0e39c2dba1d9da6bf7ef7c377cad7277f8440e9a69be05ede529ff024c", size = 283747, upload-time = "2026-09-09T21:00:19.113Z" },
+]
+
 [[package]]
 name = "requests"
 version = "2.34.2"
@@ -974,6 +1465,48 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
 ]
 
+[[package]]
+name = "safetensors"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" },
+    { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" },
+    { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" },
+    { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" },
+    { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" },
+    { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" },
+    { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" },
+    { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" },
+    { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "84.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
 [[package]]
 name = "sounddevice"
 version = "0.5.6"
@@ -1008,6 +1541,9 @@ dependencies = [
 portaudio = [
     { name = "sounddevice" },
 ]
+triage = [
+    { name = "laya" },
+]
 
 [package.dev-dependencies]
 dev = [
@@ -1018,6 +1554,7 @@ dev = [
 [package.metadata]
 requires-dist = [
     { name = "faster-whisper", specifier = ">=1.1.0" },
+    { name = "laya", marker = "extra == 'triage'", git = "https://github.com/NandhaKishorM/laya.git?rev=970dc8c5f63d7b886a68409493f37d569424f933" },
     { name = "numpy", specifier = ">=1.26" },
     { name = "pywhispercpp", marker = "python_full_version == '3.13.*'", path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" },
     { name = "questionary", specifier = ">=2.0" },
@@ -1025,7 +1562,7 @@ requires-dist = [
     { name = "textual", specifier = ">=0.80" },
     { name = "webrtcvad-wheels", specifier = ">=2.0.14" },
 ]
-provides-extras = ["portaudio"]
+provides-extras = ["portaudio", "triage"]
 
 [package.metadata.requires-dev]
 dev = [
@@ -1033,6 +1570,18 @@ dev = [
     { name = "ruff" },
 ]
 
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
 [[package]]
 name = "textual"
 version = "8.2.8"
@@ -1077,6 +1626,50 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" },
 ]
 
+[[package]]
+name = "torch"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" },
+    { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
+    { name = "filelock" },
+    { name = "fsspec" },
+    { name = "jinja2" },
+    { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+    { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+    { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
+    { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
+    { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
+    { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
+    { name = "setuptools" },
+    { name = "sympy" },
+    { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" },
+    { name = "typing-extensions" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e6/2c/0c9757a1ea1fff8ccc4faff1d1c87e98a69542b5d4b606e50f7518f72e58/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ae530ddd3f3b94248b77f1fd3313c4f3405bd0d17283d505b81574816767133a", size = 127277244, upload-time = "2026-09-02T13:42:57.855Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/30/eb2f6cf77cb8f2d5e7ce4343882a856783521f51b557673d78d1d7bb8661/torch-2.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4dbea8a10d80b10dff2be1ed467d2617b704fe6bf76bbfad4194774c8f6d886b", size = 453993896, upload-time = "2026-09-02T13:43:29.231Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/be/c0c8a845bf00552960d2f1f6a00b6ac9750fd312f55429c3e8c6c0e3aac4/torch-2.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d9e232b6376c62f3090237889fb1cb6887c0ece9f73e6c1052e80fc1481f999", size = 554583496, upload-time = "2026-09-02T13:44:10.199Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/5b/ed9d177f6a133389d5fa89e298f3e2a879b62952aa928ac6ddd04467f225/torch-2.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:6981872df75eb5409c439050d36b67cb57d88b4e11080516e2aa7410f6730705", size = 124096356, upload-time = "2026-09-02T13:43:02.941Z" },
+    { url = "https://files.pythonhosted.org/packages/17/76/bb4770f56cf6d8971671dbcbb7493e5a6a15ad2825f4e359b02c27c38297/torch-2.14.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c1f844f1c750e87df4b68bc3afbc0e2b0c7ef19d7b8f666e48bdcf6a0c4f0056", size = 127303200, upload-time = "2026-09-02T13:43:20.311Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/7b/ec44bacf2c8886b85ba4ca2285e8b09f2dff5d9c99e6a031326954082cb5/torch-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ada340e62591d06a2bcc2d68170f20f45f0b0665d372dc510a8ed7eb3b1d609a", size = 454010251, upload-time = "2026-09-02T13:44:24.927Z" },
+    { url = "https://files.pythonhosted.org/packages/15/71/49399acd41f750a906c686bd23c08a2001ccb8dd25f2971003c2ed89c1dd/torch-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fecffb58f51fd643d213acd68da21cc3fc19bea05a3bc64b4ee55128f47a4963", size = 554620488, upload-time = "2026-09-02T13:44:49.442Z" },
+    { url = "https://files.pythonhosted.org/packages/be/16/9489b137112040f9911d7527e452854f21cc4e499ce0da79864e6a7451a7/torch-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:cad84f41bbdf3dcf333ce394aeeaf25237c4d94fd6b659ba5eb813c829978823", size = 124114011, upload-time = "2026-09-02T13:43:55.034Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/40/0db773452c2a62b37761d3f418acf933d381f9e87077036fb57c2a386c37/torch-2.14.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9d4b1022a5d9b71282ec67ad0d9e7235870096b8a246dc1c32d6ea1fc83dc998", size = 127311393, upload-time = "2026-09-02T13:43:59.607Z" },
+    { url = "https://files.pythonhosted.org/packages/13/36/537fd9da2adad49e7b2bb20741398625bee548493158274e87369a8eed56/torch-2.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:731b9ebdea402b8b1996d4c2ae613b16660e559b19e47bdc45d970568bc91c53", size = 454010525, upload-time = "2026-09-02T13:45:03.719Z" },
+    { url = "https://files.pythonhosted.org/packages/21/f1/39bd13b21f57d1982b7f3ddf663f01c7266e2957714880744eba9e8c8d11/torch-2.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:84bf384779a10c02fc3c6bdbab71a9cb66b0dd93c652d1ed5d6dfc0cb37e5962", size = 554619993, upload-time = "2026-09-02T13:45:28.209Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a8/683d9c44737554b67ca76dd2db4f42258a0f014246cb511293e51e0154bd/torch-2.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0e7cf18cb0d8bd666b6120932e29c7aef3502b61a08b44da4839580c539a7cdb", size = 124113865, upload-time = "2026-09-02T13:44:33.705Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/90/1241e7db5ccc2455f8735bd6b1becfad39916206ad18001c4c0014d139e2/torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:860423e970f2ce02c4476e8e2d1350131b1c5c5a5e4912180e78b50b53241efa", size = 127321431, upload-time = "2026-09-02T13:44:38.236Z" },
+    { url = "https://files.pythonhosted.org/packages/68/f0/bbeba65e3fbb4b8f61ea19cf4ebea9f4268cc6fe64e6f7d38bfb6cc152eb/torch-2.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b985c7defeb8d28691b7513ebaecc64946c5f68ec770e9f4ddba39688864f46a", size = 454027631, upload-time = "2026-09-02T13:45:43.73Z" },
+    { url = "https://files.pythonhosted.org/packages/50/75/8d2b9a7e724759470c209489b79260cac537f091cc9aac8001c8d2bc845c/torch-2.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b2cd92bce63d40bf6fc2e5d840fc2f0063bd180a242daa761cb6088cc2f46e27", size = 554623549, upload-time = "2026-09-02T13:46:04.252Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/1e/a5475c00b0555e686333e6b4036f2213e7cbea021a772ce6f9ced4dcbd2f/torch-2.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:44b044b9f6f633d982839422a57433d6a1da520037fd88e0c8a47efde589b3b8", size = 124110863, upload-time = "2026-09-02T13:45:12.764Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/49/bbaee76337742a42d2c5b0296ea62252567050e1d821bc2283b2e72ba6fb/torch-2.14.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:553aec938d37d77b783bcf801e638cad068870e7643c47eadac21eb180f551ed", size = 127653463, upload-time = "2026-09-02T13:45:17.1Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/6cc7a511bab384fbe8f4b8ddeecdd22e724b2162d6f15076f07a0a7ef15b/torch-2.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cd8cd8f714d511ccdca907282d1da3d9be8322d4e1520b9c3bce39a5c1318a4b", size = 454009927, upload-time = "2026-09-02T13:46:19.637Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/da/0f04fe15fd05bf3f613a359b14acb44c35ae06fe69da0aefa8b5cdb4f9f9/torch-2.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d2526f71e6638133b97b3cd2881ece3df521460a4727030e8ae2a72a7d3ae31d", size = 554580530, upload-time = "2026-09-02T13:46:34.818Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/c3/72ae1f02747b1f012e1975743e48cd608f83095d7f9ce58de78b79248b35/torch-2.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:731784e3914843c6bcc7aba3987ff7610ac57dbbc816a5d6b9b62e04c240a641", size = 124400194, upload-time = "2026-09-02T13:45:53.555Z" },
+]
+
 [[package]]
 name = "tqdm"
 version = "4.70.1"
@@ -1089,6 +1682,59 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
 ]
 
+[[package]]
+name = "transformers"
+version = "5.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "huggingface-hub" },
+    { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+    { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+    { name = "packaging" },
+    { name = "pyyaml" },
+    { name = "regex" },
+    { name = "safetensors" },
+    { name = "tokenizers" },
+    { name = "tqdm" },
+    { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/9e/750649904a065007a838981785b2bd8d9ff26154c6c341ac67d0b7f82c68/transformers-5.17.0.tar.gz", hash = "sha256:a153be279169b55b92d8000bf4af294aed684503d091cca7804da2dd8a9de000", size = 9817878, upload-time = "2026-09-09T15:39:56.886Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e8/d0/c502b60d684adbd98a8dc7d5bb866842772b816ac4354e4608be240041ae/transformers-5.17.0-py3-none-any.whl", hash = "sha256:78ec1ce21579b38dfb83950a0658cd119f87212a2fcfdff478096ce9d6c03801", size = 12295140, upload-time = "2026-09-09T15:39:53.746Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a3/cf/d21c9b1a4d1df9ba3aed218069f24f762a4b9565c5a362bef6f110c08e72/triton-3.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:372285307d4c44ee74cee32de0b4f04bd157e071427e38c6f4ee3e3beb2194f4", size = 226467015, upload-time = "2026-08-28T16:08:05.871Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/9a/2c3d3823726d5ad5359f7065b02b984925158b74611c1e3987f6a37461fb/triton-3.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68988ac85d5e7086baeda0ddc175af9667db7529b3c5e11a5c0601b8bef2200a", size = 247945226, upload-time = "2026-08-28T15:55:43.795Z" },
+    { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" },
+    { url = "https://files.pythonhosted.org/packages/87/4d/4c564374bcdadb166fccbf3e45aee0d4a473f88d341761bd2fefe3b8e8c1/triton-3.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7004666652f500ed854a86988e4b3d69d247188b5d2092b5df1e44f4a954099", size = 226476793, upload-time = "2026-08-28T16:08:30.956Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/59/bf0e9493118bb353ab59a5d6a65db3618d9b314417cc1459f0121e0ec5c9/triton-3.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f6b48d0591929a3867973acac3dccd4e058585f91bfb41022de496c9ffab304", size = 226488654, upload-time = "2026-08-28T16:08:47.141Z" },
+    { url = "https://files.pythonhosted.org/packages/93/d9/08c75f3459f19ad00425b564058e40efa4bcd79b816064cf27499303ea42/triton-3.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:387dae4cb0089a7b6ba1a428ae0782b65c4c58f57d94617cb22ca8593d8ccbca", size = 247972313, upload-time = "2026-08-28T15:56:14.007Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/34/429c5592181cfb7361a0a8e0bff218e7224b726709d75da2472b3e819f70/triton-3.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b84e7d512490ba529111260fa6f7cad8b254a6bb5fbdf41d5ef9a5e57f52d0a", size = 226591133, upload-time = "2026-08-28T16:09:02.271Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/d1/aa8a3e935c37efee7945984fdb64d7e0851bf6d920afd97b2d21f9d23360/triton-3.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74217bb56ed8692759227758e4c4b3bd2d608a209c1a7a081bf361fb4c2c1bf9", size = 248077577, upload-time = "2026-08-28T15:56:24.94Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-doc" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "rich" },
+    { name = "shellingham" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
+]
+
 [[package]]
 name = "typing-extensions"
 version = "4.16.0"