Forráskód Böngészése

Moved to code-owned ledger

Efren Yevale Varela 1 hónapja
szülő
commit
6b256e55d6

+ 2 - 0
config.example.toml

@@ -112,6 +112,8 @@ brief_gate_enabled        = true
 brief_gate_threshold      = 0.5
 brief_max_chars           = 4000
 brief_pending_char_budget = 3000
+ledger_max_facts           = 200
+fact_max_chars             = 240
 device                    = ""
 preload                   = ["multilingual"]
 default_checkpoint        = "multilingual"

+ 17 - 10
docs/CONFIGURATION.md

@@ -136,6 +136,8 @@ brief_gate_enabled   = true                 # fail-open gate: update the brief o
 brief_gate_threshold = 0.5                  # gate threshold; the top signal at/above this p triggers a brief update
 brief_max_chars      = 4000                 # display cap for the brief (truncated, never dropped) when over this
 brief_pending_char_budget = 3000            # pending transcript chars that force an immediate flush of a coalesced update
+ledger_max_facts     = 200                  # cap on code-owned durable facts; oldest are evicted (logged) past this
+fact_max_chars       = 240                  # per-fact character cap; over-long facts are truncated
 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
@@ -230,16 +232,21 @@ the audio files) containing the Laya request/response and the LLM request/respon
 off by default, best-effort (a dump failure never disturbs triage), and gitignored.
 
 `[llm].triage_system_prompt` drives the brief engine: it is the static system message, and the
-user turn carries the pinned current brief (`CURRENT BRIEF:`) plus the new transcript segment
-(`NEW UTTERANCES:`). The engine keeps no conversation history, so the prompt stays flat instead
-of growing and the previous brief is the single source of truth. The model must return the
-updated brief as plain text only, with no JSON and no markdown fences. The model reply is
-bounded by `[triage].extract_max_tokens` (default 3072) and `extract_timeout_s`, and the
-stored/displayed brief is then capped to `brief_max_chars` characters (truncated, never
-dropped). After parsing, a code-side retention guard re-appends any `- ` bullet the model
-dropped from the prior brief, so a fact is never silently lost to a bad re-emit. Because the
-brief engine keeps no history, `[llm].brief_compaction_system_prompt` and the `[llm]`
-compaction settings do not apply to it (they still govern chat/command).
+user turn carries the code-owned fact ledger (`CURRENT LEDGER`), the volatile narrative
+(`CURRENT BRIEF`), and the new transcript segment (`NEW UTTERANCES`). The engine keeps no
+conversation history, so the prompt stays flat instead of growing. The model must return exactly
+two sections: `NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and
+`BRIEF:` (the volatile narrative sections). It never rewrites the ledger. Code owns the ledger:
+it parses the reply, appends new facts with conservative dedupe, evicts oldest past
+`ledger_max_facts` (logged), truncates facts past `fact_max_chars`, and renders the final brief
+as narrative + a `Prior Details:` section. A recorded fact can therefore only disappear via a
+logged cap eviction, never because the model dropped it. The model reply is bounded by
+`[triage].extract_max_tokens` (default 3072) and `extract_timeout_s`, and the rendered brief is
+capped to `brief_max_chars` characters. `NEW FACTS:` is emitted first so a length cutoff loses
+narrative (regenerated each pass) rather than a durable fact; the final fact line is discarded
+when the reply is truncated. Because the brief engine keeps no history,
+`[llm].brief_compaction_system_prompt` and the `[llm]` compaction settings do not apply to it
+(they still govern chat/command).
 
 The gate `brief_gate_enabled` (default true) is fail-open: a turn updates the brief only when
 the top signal's calibrated `p` is at least `brief_gate_threshold` (default 0.5), and any

+ 40 - 30
docs/TRIAGE.md

@@ -79,32 +79,37 @@ settled turn (any mode), or every periodic_s while speech keeps accumulating
      criteria
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
    it drives neither the gate nor the brief.
-5. **Brief update.** The service queues **one** `brief-update` job on the existing LLM
-   lane. The job carries the speaker-labeled pending deltas (the transcripts that arrived
-   since the last accepted update) plus the current signal words under a `Focus:` header; a
-   Laya error still runs with the deltas only. The brief engine is **stateful only in code**:
-   it pins the last accepted brief and sends exactly two messages per call — a static system
-   prompt and ONE user turn containing `CURRENT BRIEF:` (the pinned text) and
-   `NEW UTTERANCES:` (the deltas). No conversation history is replayed, so the prompt stays
-   flat instead of growing, the model never has to disambiguate between stale copies of its
-   own prior briefs, and the previous brief is the single source of truth. The reply is a
-   plain-text brief, not JSON (fences stripped, then truncated to `brief_max_chars`, never
-   dropped). `extract_enabled` is the brief engine's master switch: with it false the panel
-   still classifies and shows signals, but no brief is requested. The accepted brief is
-   emitted as the `content` stage, whose payload key is `brief` (the event carries the merged
-   signals plus `brief`), and, when `[brief].write = true`, snapshotted to disk.
-6. **Retention guard.** Because the model re-emits the whole brief each pass, it can silently
-   drop a previously-recorded fact. After the reply is parsed, the service compares the
-   backend's bullet lines (any `- ` line) against the pinned prior brief by normalized key
-   and **re-appends any bullet that vanished**, under the brief's `Prior Details:` section
-   (or appending that section when absent). Matching ignores the `**New:**` marker and
-   whitespace, so a genuine reword/merge still matches; only a truly absent bullet is
-   restored. The guard is deterministic and lives entirely in code — it does not call the
-   model. It is the guarantee that an important detail is never lost to a bad re-emit.
-7. **No compaction.** The brief engine does not use `ConversationEngine`, so there is no
+5. **Brief update (code-owned ledger).** The service queues **one** `brief-update` job on
+   the existing LLM lane. The job carries the speaker-labeled pending deltas (the transcripts
+   that arrived since the last accepted update) plus the current signal words under a `Focus:`
+   header; a Laya error still runs with the deltas only. The brief engine is **stateless**: it
+   sends exactly two messages per call — a static system prompt and ONE user turn containing
+   `CURRENT LEDGER` (the recorded facts), `CURRENT BRIEF` (the volatile narrative), and
+   `NEW UTTERANCES` (the deltas). No conversation history is replayed, so the prompt stays
+   flat instead of growing and the model never competes with stale copies of its own output.
+   The model returns exactly two sections: `NEW FACTS:` (only genuinely-new durable facts, one
+   per `- ` line, or `none`) and `BRIEF:` (the volatile narrative: Summary, Opportunities,
+   Blockers/Risks, Decisions, Open Questions). `extract_enabled` is the brief engine's master
+   switch: with it false the panel still classifies and shows signals, but no brief is
+   requested. The rendered brief is emitted as the `content` stage, whose payload key is
+   `brief` (the event carries the merged signals plus `brief`), and, when `[brief].write =
+   true`, snapshotted to disk.
+6. **Ledger ownership.** Code — not the model — owns the durable facts. The `FactLedger`
+   appends the reply's `NEW FACTS:` with conservative dedupe (exact normalized match, or a
+   fuzzy near-match only when its **numbers and entities are identical**), evicts the oldest
+   facts past `ledger_max_facts` (logged), and truncates each fact past `fact_max_chars`. It
+   then renders the final brief as the narrative plus a `Prior Details:` section. A recorded
+   fact can therefore only disappear via a logged cap eviction — never because the model
+   dropped it, and never because it wrote prose instead of bullets. The guarantee covers
+   *recorded* facts: a fact the model never lists under `NEW FACTS:` was never recorded. The
+   ledger resets on `clear` and on a new session, alongside the rest of the triage state.
+7. **Truncation safety.** `NEW FACTS:` is emitted before `BRIEF:` so a token-cap cutoff loses
+   narrative (regenerated every pass) rather than a durable fact; the final fact line is
+   discarded when the reply reports `finish_reason == "length"`.
+8. **No compaction.** The brief engine does not use `ConversationEngine`, so there is no
    history to compact and `[llm].brief_compaction_system_prompt` is unused by the brief
    path. `[llm]` compaction settings still govern the chat/command session.
-8. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient
+9. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient
    buffer, drops any pending lane request and any queued `brief-update` job, truncates the
    optional durable brief file, and emits the `cleared` stage. An in-flight result that
    lands after the bump is suppressed, so a stale brief writes neither the pane nor the
@@ -296,18 +301,23 @@ file.
 | `brief_gate_threshold` | `0.5` | Gate threshold for `substantive_update`; the update runs at or above this `p`. |
 | `brief_max_chars` | `4000` | Display cap for the brief; over this it is truncated, never dropped. |
 | `brief_pending_char_budget` | `3000` | Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |
+| `ledger_max_facts` | `200` | Cap on code-owned durable facts; the oldest are evicted (logged) past this. |
+| `fact_max_chars` | `240` | Per-fact character cap; over-long facts are truncated. |
 | `device` | `""` | Empty string means auto, which maps to `None` so Laya picks the checkpoint's default device. Any other value is passed through. |
 | `preload` | `["multilingual"]` | Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. |
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
 | `dump_io` | `false` | Opt-in I/O dump under `triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). |
 
 `[llm].triage_system_prompt` drives the brief engine: it is sent as the static system
-message, and the user turn carries the pinned current brief plus the new segment. The model
-must return the updated brief as plain text only, with no markdown fences and no JSON. The
-reply is bounded by `extract_max_tokens` and `extract_timeout_s`, then truncated to
-`brief_max_chars`. Because the brief engine keeps no conversation history,
-`[llm].brief_compaction_system_prompt` and the `[llm]` compaction settings do not apply to
-it; they remain in effect for the chat/command session.
+message, and the user turn carries the code-owned fact ledger (`CURRENT LEDGER`), the volatile
+narrative (`CURRENT BRIEF`), and the new segment. The model must return exactly two sections:
+`NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and `BRIEF:` (the
+volatile narrative). It never rewrites the ledger. Code appends new facts with conservative
+dedupe, evicts the oldest past `ledger_max_facts`, truncates each past `fact_max_chars`, and
+renders the final brief. The reply is bounded by `extract_max_tokens` and `extract_timeout_s`,
+then the rendered brief is truncated to `brief_max_chars`. Because the brief engine keeps no
+conversation history, `[llm].brief_compaction_system_prompt` and the `[llm]` compaction
+settings do not apply to it; they remain in effect for the chat/command session.
 
 `[brief]` is a separate section that controls durability of the living brief. `write`
 (default `false`) gates disk persistence only: with it false the brief stays in the TUI, and

+ 12 - 26
src/stt_daemon/assistant/llm.py

@@ -490,28 +490,25 @@ class BriefEngine:
         self._lock = threading.RLock()
 
     def reset(self) -> None:
-        """Drop the pinned brief (volatile by design)."""
+        """Invalidate any in-flight reply (the service owns the actual brief state)."""
         with self._lock:
-            self._brief = ""
             self._generation += 1
 
-    def last_reply(self) -> str:
-        """Return the pinned brief, or "" when none has been accepted."""
-        with self._lock:
-            return self._brief
-
     def send(
         self,
         user_text: str,
         on_io: Callable[[dict], None] | None = None,
         finalize: Callable[[str, str], str | None] | None = None,
     ) -> str:
-        """Ask the model to update the pinned brief from one user turn.
-
-        ``user_text`` is the analyzer's pinned prompt (current brief + new
-        utterances). ``finalize`` behaves as on ``ConversationEngine.send``:
-        returning None rejects the turn and leaves the pinned brief untouched;
-        returning a string pins THAT string as the accepted brief.
+        """Ask the model to update the brief from one user turn.
+
+        The engine is stateless with respect to brief content: the caller owns
+        both the volatile narrative and the code-owned ledger, and passes them in
+        via ``user_text``. ``finalize`` behaves as on ``ConversationEngine.send``:
+        returning None rejects the turn; returning a string returns it as the
+        accepted reply. A truncated (``finish_reason == "length"``) reply is still
+        returned so the caller's parser can salvage it, but the caller must not
+        treat a missing section as an empty fact list for a truncated reply.
         """
         with self._lock:
             generation = self._generation
@@ -533,7 +530,6 @@ class BriefEngine:
         )
         reply = response.content
         finish_reason = response.finish_reason
-        truncated = finish_reason == "length"
 
         with self._lock:
             if generation != self._generation:
@@ -544,19 +540,9 @@ class BriefEngine:
             with self._lock:
                 if generation != self._generation:
                     return ""
-                if final is None:
-                    return ""
-                if not truncated:
-                    self._brief = final
-                return final
-
-        with self._lock:
-            if generation != self._generation:
-                return ""
-            if reply and not truncated:
-                self._brief = reply
+                return final or ""
 
-        if truncated and self.notify is not None:
+        if finish_reason == "length" and self.notify is not None:
             self.notify("truncated", message="response truncated")
 
         return reply

+ 1 - 2
src/stt_daemon/assistant/runner.py

@@ -1,7 +1,6 @@
 """Run the assistant process: subscribe to the STT bus and serve the control socket."""
 
 import argparse
-import functools
 import logging
 import signal
 import threading
@@ -42,7 +41,7 @@ def build_triage(cfg, client, on_result=None):
             slot_id=cfg.triage.brief_slot_id,
             notify=None,
         )
-        brief_fn = functools.partial(engine.send, finalize=analyzer.finalize_brief)
+        brief_fn = engine.send
         brief_reset = engine.reset
 
     return (analyzer, lane, brief_fn, brief_reset)

+ 51 - 8
src/stt_daemon/assistant/service.py

@@ -19,6 +19,7 @@ from ..config import Config
 from .executor import ExecutionResult
 from .inference import InferenceJob, InferenceQueue
 from .modes import Mode, ModeMachine
+from .triage import FactLedger
 from .triage_dump import TriageDumper
 
 logger = logging.getLogger(__name__)
@@ -156,6 +157,11 @@ class AssistantService:
         self._brief_outstanding = False
         self._brief_token = 0
         self._last_brief_signals: dict | None = None
+        # Code-owned fact ledger (append-only; the model only proposes new facts).
+        # Owned here so `clear()`/new-session resets and the generation guard both
+        # cover it; `_last_brief_narrative` is the volatile half the model owns.
+        self._fact_ledger: FactLedger | None = None
+        self._last_brief_narrative = ""
         self._chat_queue_full = False
         self._last_triage_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
@@ -168,6 +174,10 @@ class AssistantService:
         self._triage_generation = 0
         self._last_transcript_lang: str | None = None
         self._triage_dump = TriageDumper(bool(getattr(cfg.triage, "dump_io", False)))
+        self._fact_ledger = FactLedger(
+            max_facts=getattr(cfg.triage, "ledger_max_facts", 200),
+            fact_max_chars=getattr(cfg.triage, "fact_max_chars", 240),
+        )
         self._last_laya_request: dict | None = None
 
         # Health
@@ -1073,10 +1083,13 @@ class AssistantService:
         return True
 
     def _current_brief(self) -> str:
-        """Return the last accepted brief text, or "" when none exists yet."""
+        """Render the last accepted brief: volatile narrative + code-owned ledger."""
         with self._lock:
-            brief = (self.last_triage or {}).get("brief")
-        return brief if isinstance(brief, str) else ""
+            narrative = self._last_brief_narrative
+            ledger_text = self._fact_ledger.render() if self._fact_ledger is not None else ""
+        if narrative and ledger_text:
+            return f"{narrative.rstrip()}\n\n{ledger_text}"
+        return narrative or ledger_text
 
     def _run_brief_update(self, generation, signals, ts=None, token=None) -> None:
         if self._triage is None or self._brief_fn is None:
@@ -1121,28 +1134,55 @@ class AssistantService:
                             },
                         )
                 io_hook = _record_io
-            prompt = self._triage.build_pinned_brief_prompt(
-                self._current_brief(), deltas, signals
+            with self._lock:
+                narrative = self._last_brief_narrative
+                ledger_facts = self._fact_ledger.facts() if self._fact_ledger is not None else []
+            prompt = self._triage.build_ledger_prompt(
+                narrative, ledger_facts, deltas, signals
             )
+            finish: list[str] = []
+
+            def _capture(reply: str, finish_reason: str) -> str:
+                finish.append(finish_reason)
+                return reply
+
             if dump:
-                text = self._brief_fn(prompt, on_io=io_hook)
+                text = self._brief_fn(prompt, on_io=io_hook, finalize=_capture)
             else:
-                text = self._brief_fn(prompt)
+                text = self._brief_fn(prompt, finalize=_capture)
             with self._lock:
                 if generation != self._triage_generation:
                     return
             if not text:
                 requeued = True
                 return
-            text = self._triage.restore_missing_bullets(text, self._current_brief())
+            parsed = self._triage.parse_ledger_reply(
+                text, truncated=bool(finish and finish[0] == "length")
+            )
+            if parsed is None:
+                requeued = True
+                return
+            new_narrative, new_facts = parsed
             with self._lock:
                 if generation != self._triage_generation:
                     return
+                if new_narrative:
+                    self._last_brief_narrative = new_narrative
+                added = 0
+                if self._fact_ledger is not None:
+                    added = self._fact_ledger.merge(new_facts)
+                text = self._current_brief()
                 merged = {**(signals or {}), "brief": text}
                 self.last_triage = merged
                 if self._brief_file is not None:
                     self._brief_file.write(text)
                 self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+            self._debug_emit(
+                "triage-ledger",
+                added=added,
+                facts=len(self._fact_ledger) if self._fact_ledger is not None else 0,
+                generation=generation,
+            )
         except Exception as e:  # noqa: BLE001 - a brief failure must not kill the worker
             logger.error("Triage brief error: %s", e)
             requeued = True
@@ -1193,6 +1233,9 @@ class AssistantService:
         self._last_triage_window = ()
         self._triage_next_due = None
         self.triage_history = []
+        if self._fact_ledger is not None:
+            self._fact_ledger.clear()
+        self._last_brief_narrative = ""
         self._brief_token += 1
         self._brief_outstanding = False
         self._last_brief_signals = None

+ 257 - 12
src/stt_daemon/assistant/triage.py

@@ -22,12 +22,24 @@ Public API (consumed by the assistant service layer):
         def normalize(self, payload) -> dict
         def should_escalate(self, signals) -> bool
         def build_brief_prompt(self, deltas, signals) -> str
+        def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str
         def parse_brief(self, text) -> str | None
+        def parse_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
+        def parse_facts(self, block) -> list[str]
         def should_update_brief(self, signals) -> bool
         def finalize_brief(self, text, finish_reason) -> str | None
         def state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
 
+    class FactLedger:
+        def __init__(self, max_facts=200, fact_max_chars=240) -> None
+        def render(self) -> str
+        def merge(self, new_facts) -> int
+        def facts(self) -> list[str]
+        def clear(self) -> None
+        @classmethod
+        def from_brief(cls, brief, ...) -> "FactLedger"
+
 ``cfg`` is duck-typed (attribute access only, never imported): it provides
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``confidence_field`` (default ``"answer_confidence"``),
@@ -38,6 +50,8 @@ Public API (consumed by the assistant service layer):
 
 import json
 import logging
+import re
+import unicodedata
 
 logger = logging.getLogger(__name__)
 
@@ -379,27 +393,110 @@ class TriageAnalyzer:
         parts.append("Return only the updated brief as plain text.")
         return "\n\n".join(parts)
 
-    def build_pinned_brief_prompt(self, current_brief, deltas, signals) -> str:
-        """Single-turn brief prompt: pin the current brief + new utterances.
+    def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str:
+        """Ledger-aware single-turn prompt: volatile narrative + code-owned ledger.
 
-        Unlike ``build_brief_prompt`` this carries the full prior brief inline so
-        the engine can send one user turn with no conversation history, keeping
-        the prompt flat and the previous brief the single source of truth.
+        The model is shown the current narrative (the sections it owns) and the
+        current ledger (facts it must NOT repeat) and asked for the two halves
+        back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
+        so a length cutoff loses narrative (regenerated every pass) rather than a
+        durable fact.
         """
         try:
             body = "\n".join("- " + delta for delta in (deltas or []))
         except Exception:
             body = ""
+        ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
         parts = [
-            "CURRENT BRIEF:\n" + (current_brief or "(none yet)"),
+            "CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
+            "CURRENT BRIEF:\n" + (narrative or "(none yet)"),
             "NEW UTTERANCES:\n" + body,
         ]
         focus = self._signal_lines(signals)
         if focus:
             parts.append("Focus:\n" + "\n".join(focus))
-        parts.append("Return only the updated brief as plain text.")
+        parts.append(
+            "Reply with exactly these two sections and nothing else:\n"
+            "NEW FACTS:\n"
+            "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
+            "BRIEF:\n"
+            "Summary:\n...\nOpportunities:\n...\nBlockers/Risks:\n...\n"
+            "Decisions:\n...\nOpen Questions:\n..."
+        )
         return "\n\n".join(parts)
 
+    def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
+        """Split a ledger reply into ``(narrative, new_facts)``.
+
+        Both halves are optional and fail open: a missing ``NEW FACTS:`` block
+        yields no facts, and a missing ``BRIEF:`` block yields an empty
+        narrative (the caller then keeps the prior narrative). Any ``Prior
+        Details:`` the model volunteers is dropped from the narrative so the
+        code-rendered ledger is never duplicated. When ``truncated`` the reply hit
+        the token cap, so the final fact line may be cut mid-sentence and is
+        discarded. Returns None only when the reply carries no usable text at all.
+        """
+        cleaned = self.parse_brief(text)
+        if cleaned is None:
+            return None
+        facts_at = cleaned.upper().find("NEW FACTS:")
+        brief_at = cleaned.upper().find("BRIEF:")
+        if facts_at == -1 and brief_at == -1:
+            return None
+        if facts_at != -1 and (brief_at == -1 or facts_at < brief_at):
+            facts_end = brief_at if brief_at != -1 else len(cleaned)
+            facts_block = cleaned[facts_at + len("NEW FACTS:"): facts_end]
+            narrative_block = cleaned[brief_at + len("BRIEF:"):] if brief_at != -1 else ""
+        else:
+            facts_block = ""
+            narrative_block = cleaned[brief_at + len("BRIEF:"):]
+        facts = self.parse_facts(facts_block)
+        if truncated and facts:
+            facts = facts[:-1]
+        narrative = self._strip_prior_details(narrative_block).strip()
+        return narrative, facts
+
+    def parse_facts(self, block) -> list[str]:
+        """Extract durable facts from a ``NEW FACTS:`` block; never treats prose as a fact."""
+        if not isinstance(block, str):
+            return []
+        facts = []
+        for line in block.splitlines():
+            stripped = line.strip()
+            if not stripped or stripped.startswith("#"):
+                continue
+            marker = next((m for m in ("- ", "* ", "• ") if stripped.startswith(m)), None)
+            if marker is not None:
+                candidate = stripped[len(marker):]
+            elif stripped[:2].rstrip(".").isdigit() or stripped[:3].rstrip(".").isdigit():
+                candidate = stripped.split(".", 1)[1] if "." in stripped else stripped
+            else:
+                continue
+            candidate = candidate.strip().strip("`").strip()
+            if not candidate or candidate.lower() in ("none", "-"):
+                continue
+            if ":" in candidate and candidate.lower().startswith("new facts"):
+                continue
+            facts.append(candidate)
+        return facts
+
+    @staticmethod
+    def _strip_prior_details(text: str) -> str:
+        """Drop a model-supplied ``Prior Details:`` section from the narrative."""
+        if not isinstance(text, str):
+            return ""
+        heads = (
+            "Summary:", "Opportunities:", "Blockers/Risks:", "Responsibilities:",
+            "Decisions:", "Action Items:", "Deadlines:", "Open Questions:",
+        )
+        at = text.find("Prior Details:")
+        if at == -1:
+            return text
+        rest = text[at + len("Prior Details:"):]
+        cuts = [rest.find(h) for h in heads if rest.find(h) != -1]
+        end = min(cuts) if cuts else len(rest)
+        return (text[:at] + rest[end:]).strip()
+
     @staticmethod
     def _normalize_bullet(text: str) -> str:
         """Collapse a bullet to a comparison key; empty when it carries no words."""
@@ -427,11 +524,12 @@ class TriageAnalyzer:
     def restore_missing_bullets(self, new_brief, prior_brief) -> str:
         """Re-append any prior bullet that the model dropped from ``new_brief``.
 
-        Deterministic retention guard: a previously-recorded fact must never
-        vanish because the model re-emitted the document without it. Bullets
-        the new brief merely reworded still match by normalized key, so only a
-        genuinely absent bullet is restored. Restoration appends under the
-        brief's final ``Prior Details:`` section, or at the end when absent.
+        Deterministic retention guard kept as a belt-and-braces layer beneath the
+        code-owned ledger: a previously-recorded bullet must never vanish because
+        the model re-emitted the document without it. Bullets the new brief merely
+        reworded still match by normalized key, so only a genuinely absent bullet
+        is restored. Restoration appends under the brief's final ``Prior
+        Details:`` section, or at the end when absent.
         """
         if not isinstance(new_brief, str) or not new_brief.strip():
             return new_brief
@@ -598,3 +696,150 @@ class TriageAnalyzer:
             return
         self._state = state
         self._reason = reason if isinstance(reason, str) else str(reason)
+
+
+def _accents_folded(text: str) -> str:
+    """Casefold, strip combining accents, and collapse to plain words."""
+    decomposed = unicodedata.normalize("NFKD", str(text))
+    without_marks = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
+    return " ".join(without_marks.casefold().split())
+
+
+def _dedupe_key(text: str) -> str:
+    """Accent- and punctuation-insensitive key for near-verbatim comparisons."""
+    return " ".join(t for t in re.split(r"[^0-9a-z$%]+", _accents_folded(text)) if t)
+
+
+def _number_tokens(text: str) -> frozenset:
+    """Digit-bearing tokens (currency/percent stripped) for strict dedupe checks."""
+    tokens = set()
+    for raw in _accents_folded(text).split():
+        digits = "".join(ch for ch in raw if ch.isdigit())
+        if digits:
+            tokens.add(digits)
+    return frozenset(tokens)
+
+
+def _entity_tokens(text: str) -> frozenset:
+    """Capitalized (proper-noun) tokens, accent-folded, for strict dedupe checks."""
+    tokens = set()
+    for raw in str(text).split():
+        stripped = raw.strip(".,;:!?()[]\"'")
+        if stripped[:1].isupper() and stripped[1:].islower():
+            tokens.add(_accents_folded(stripped))
+    return frozenset(tokens)
+
+
+class FactLedger:
+    """Code-owned, append-only ledger of durable facts.
+
+    The model proposes new facts per pass; this class owns dedupe, the size cap,
+    eviction, and rendering, so a recorded fact can only disappear via a logged,
+    deterministic eviction (never because the model dropped it). Pure and
+    stdlib-only, matching the analyzer.
+
+    Dedupe biasses toward *keeping* a fact: an exact normalized match is dropped,
+    and a fuzzy near-match is dropped only when its number and entity tokens are
+    identical. A redundant bullet is cosmetic; a false merge would silently lose a
+    fact and break the retention guarantee.
+    """
+
+    def __init__(self, max_facts: int = 200, fact_max_chars: int = 240) -> None:
+        self.max_facts = max(1, int(max_facts))
+        self.fact_max_chars = max(1, int(fact_max_chars))
+        self._facts: list[str] = []
+
+    def __len__(self) -> int:
+        return len(self._facts)
+
+    def facts(self) -> list[str]:
+        """Return a copy of the recorded facts, in insertion order."""
+        return list(self._facts)
+
+    def clear(self) -> None:
+        """Drop every recorded fact."""
+        self._facts = []
+
+    def render(self) -> str:
+        """Render the ledger as a ``Prior Details:`` section ("" when empty)."""
+        if not self._facts:
+            return ""
+        return "Prior Details:\n" + "\n".join("- " + fact for fact in self._facts)
+
+    def merge(self, new_facts) -> int:
+        """Append genuinely-new facts, dedupe, then evict oldest over the cap.
+
+        Returns the number of facts added. Eviction is oldest-first and logged;
+        a fact added in this call is never evicted by this call.
+        """
+        added = 0
+        for raw in new_facts or []:
+            fact = self._clean(raw)
+            if fact is None:
+                continue
+            if self._is_duplicate(fact):
+                continue
+            self._facts.append(fact)
+            added += 1
+        if added:
+            self._evict()
+        return added
+
+    def _clean(self, raw):
+        if not isinstance(raw, str):
+            return None
+        fact = " ".join(raw.strip().split())
+        if not fact:
+            return None
+        if len(fact) > self.fact_max_chars:
+            fact = fact[: self.fact_max_chars].rstrip()
+        return fact or None
+
+    @staticmethod
+    def _tokens(text: str) -> list[str]:
+        normalized = _accents_folded(text)
+        return [t for t in re.split(r"[^0-9a-z$%]+", normalized) if t]
+
+    def _is_duplicate(self, fact: str) -> bool:
+        key = _dedupe_key(fact)
+        cand_tokens = self._tokens(fact)
+        cand_numbers = _number_tokens(fact)
+        cand_entities = _entity_tokens(fact)
+        for existing in self._facts:
+            if _dedupe_key(existing) == key:
+                return True
+            if cand_numbers != _number_tokens(existing):
+                continue
+            if cand_entities != _entity_tokens(existing):
+                continue
+            existing_tokens = self._tokens(existing)
+            if not cand_tokens or not existing_tokens:
+                continue
+            overlap = len(set(cand_tokens) & set(existing_tokens)) / len(set(cand_tokens))
+            if overlap >= 0.85:
+                return True
+        return False
+
+    def _evict(self) -> None:
+        overflow = len(self._facts) - self.max_facts
+        if overflow <= 0:
+            return
+        evicted = self._facts[:overflow]
+        self._facts = self._facts[overflow:]
+        logger.info(
+            "fact ledger at cap %d; evicted %d oldest fact(s): %s",
+            self.max_facts,
+            len(evicted),
+            "; ".join(evicted),
+        )
+
+    @classmethod
+    def from_brief(cls, brief, max_facts: int = 200, fact_max_chars: int = 240):
+        """Rebuild a ledger by harvesting ``- `` bullets from an existing brief."""
+        ledger = cls(max_facts=max_facts, fact_max_chars=fact_max_chars)
+        if isinstance(brief, str):
+            for line in brief.splitlines():
+                stripped = line.strip()
+                if stripped.startswith("- "):
+                    ledger.merge([stripped[2:]])
+        return ledger

+ 8 - 0
src/stt_daemon/config.py

@@ -192,6 +192,8 @@ class TriageConfig:
     brief_gate_threshold: float = 0.5
     brief_max_chars: int = 4000
     brief_pending_char_budget: int = 3000
+    ledger_max_facts: int = 200
+    fact_max_chars: int = 240
     device: str = ""
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     default_checkpoint: str = "multilingual"
@@ -570,6 +572,12 @@ def load_config(path: str | None = None) -> Config:
                 brief_pending_char_budget=triage_data.get(
                     "brief_pending_char_budget", cfg.triage.brief_pending_char_budget
                 ),
+                ledger_max_facts=triage_data.get(
+                    "ledger_max_facts", cfg.triage.ledger_max_facts
+                ),
+                fact_max_chars=triage_data.get(
+                    "fact_max_chars", cfg.triage.fact_max_chars
+                ),
                 device=triage_data.get("device", cfg.triage.device),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 default_checkpoint=triage_data.get(

+ 5 - 2
tests/test_assistant_integration.py

@@ -491,7 +491,8 @@ class _EngineClient:
     """
 
     def __init__(
-        self, *, brief_reply: str = "BRIEF", brief_error: bool = False,
+        self, *, brief_reply: str = "NEW FACTS:\nnone\n\nBRIEF:\nE2E BRIEF",
+        brief_error: bool = False,
         chat_reply: str = "chat reply",
     ) -> None:
         self.calls: list[dict] = []
@@ -516,6 +517,8 @@ class _EngineClient:
         if is_brief and self.brief_error:
             raise RuntimeError("llm down")
         content = self.brief_reply if is_brief else self.chat_reply
+        if is_brief and content and "BRIEF:" not in content and "NEW FACTS:" not in content:
+            content = f"NEW FACTS:\nnone\n\nBRIEF:\n{content}"
         return SimpleNamespace(content=content, finish_reason="stop")
 
     def brief_calls(self) -> list[dict]:
@@ -661,7 +664,7 @@ def test_two_engines_emit_signals_then_brief_and_chat_works(tmp_path):
 
     assert [call["slot_id"] for call in client.chat_calls()] == [7]
     assert [call["slot_id"] for call in client.brief_calls()] == [5]
-    assert client.brief_calls()[0]["user"].startswith("CURRENT BRIEF:\n(none yet)")
+    assert client.brief_calls()[0]["user"].startswith("CURRENT LEDGER")
 
 
 def test_slotless_engines_omit_id_slot(tmp_path):

+ 84 - 48
tests/test_assistant_service.py

@@ -603,20 +603,23 @@ class FakeTriage:
         )
         return "BRIEF-PROMPT"
 
-    def build_pinned_brief_prompt(self, current_brief, deltas, signals):
+    def build_ledger_prompt(self, narrative, ledger, deltas, signals):
         self.prompt_calls.append(
             (tuple(deltas), dict(signals) if isinstance(signals, dict) else signals)
         )
+        ledger_text = "\n".join("- " + f for f in ledger) or "(none yet)"
         body = "\n".join("- " + d for d in deltas)
         return (
-            f"CURRENT BRIEF:\n{current_brief or '(none yet)'}\n\nNEW UTTERANCES:\n{body}"
+            f"CURRENT LEDGER:\n{ledger_text}\n\n"
+            f"CURRENT BRIEF:\n{narrative or '(none yet)'}\n\n"
+            f"NEW UTTERANCES:\n{body}"
         )
 
-    def extract_bullets(self, brief):
-        return _real_analyzer.extract_bullets(brief)
+    def parse_ledger_reply(self, text, truncated=False):
+        return _real_analyzer.parse_ledger_reply(text, truncated=truncated)
 
-    def restore_missing_bullets(self, new_brief, prior_brief):
-        return _real_analyzer.restore_missing_bullets(new_brief, prior_brief)
+    def parse_facts(self, block):
+        return _real_analyzer.parse_facts(block)
 
     def parse_brief(self, text):
         if self._parsed is _AUTO:
@@ -679,6 +682,12 @@ def _ok_reply(text):
     return "ok"
 
 
+def _brief_reply(narrative="", facts=None):
+    """Build a ledger-format reply the analyzer's parser can consume."""
+    body = "\n".join("- " + fact for fact in (facts or [])) or "none"
+    return f"NEW FACTS:\n{body}\n\nBRIEF:\n{narrative}"
+
+
 def _triage_service(
     triage=None,
     lane=None,
@@ -711,7 +720,7 @@ def _triage_service(
         time_fn=clock,
         triage=triage,
         laya_lane=lane,
-        brief_fn=brief_fn,
+        brief_fn=_wrap_brief_fn(brief_fn),
         brief_reset=brief_reset,
         brief_file=brief_file,
         chat_fn=chat_fn,
@@ -721,6 +730,33 @@ def _triage_service(
     return service, emitted, clock, triage, lane
 
 
+def _wrap_brief_fn(brief_fn):
+    """Adapt a simple `prompt -> text` stub to the engine's call signature.
+
+    A plain string return is treated as the volatile narrative half of a
+    ledger reply, so existing stubs keep reading naturally.
+    """
+    if brief_fn is None:
+        return None
+
+    def wrapped(prompt, on_io=None, finalize=None):
+        kwargs = {}
+        if on_io is not None:
+            kwargs["on_io"] = on_io
+        try:
+            result = brief_fn(prompt, **kwargs)
+        except TypeError:
+            result = brief_fn(prompt)
+        if finalize is not None:
+            result = finalize(result, "stop")
+        is_ledger = "BRIEF:" in result or "NEW FACTS:" in result
+        if isinstance(result, str) and result and not is_ledger:
+            result = _brief_reply(narrative=result)
+        return result
+
+    return wrapped
+
+
 def _triage_events(emitted):
     return [e for e in emitted if e["type"] == "triage"]
 
@@ -1471,57 +1507,56 @@ def _seed_brief(service, *deltas):
     return service._brief_token
 
 
-def test_retention_guard_reappends_fact_dropped_by_model():
-    """A prior bullet the model omitted is deterministically restored."""
-    prior = (
-        "Summary: rolling\n\nPrior Details:\n"
-        "- The budget was $305 million.\n"
-        "- Sense could find prey.\n"
-    )
-    new = "Summary: newer\n\nPrior Details:\n- Sense could find prey.\n"
-    service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: new)
-    service.last_triage = {**_escalated_payload(), "brief": prior}
-    service._pending_brief = ["Unknown: more"]
+def test_ledger_records_fact_and_survives_model_reemit():
+    """A recorded fact persists even when a later reply omits it."""
+    first = _brief_reply("Summary: rolling", ["The budget was $305 million."])
+    second = _brief_reply("Summary: newer", [])
+    replies = iter([first, second])
+    service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: next(replies))
+
+    service._pending_brief = ["Unknown: one"]
     service._brief_outstanding = True
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
+    service._pending_brief = ["Unknown: two"]
+    service._brief_outstanding = True
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
     assert service.last_triage is not None
     brief = service.last_triage["brief"]
-    assert "- Sense could find prey." in brief
-    assert "- The budget was $305 million." in brief
-    assert brief.count("- Sense could find prey.") == 1
-
-
-def test_retention_guard_leaves_complete_brief_unchanged():
-    """A brief that already carries every prior bullet is stored verbatim."""
-    prior = "Prior Details:\n- fact one\n- fact two\n"
-    new = "Summary: newer\n\nPrior Details:\n- fact one\n- fact two\n"
-    service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: new)
-    service.last_triage = {**_escalated_payload(), "brief": prior}
-    service._pending_brief = ["Unknown: more"]
+    assert "The budget was $305 million." in brief
+    assert service._fact_ledger.facts() == ["The budget was $305 million."]
+
+
+def test_ledger_dedupes_repeated_fact():
+    """The same fact proposed twice is recorded once."""
+    reply = _brief_reply("Summary: s", ["Sense could find prey.", "sense could find prey"])
+    service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: reply)
+    service._pending_brief = ["Unknown: one"]
     service._brief_outstanding = True
 
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
-    assert service.last_triage is not None
-    assert service.last_triage["brief"] == new
+    assert service._fact_ledger.facts() == ["Sense could find prey."]
 
 
-def test_pinned_prompt_carries_prior_brief_and_new_utterances():
-    """The brief request hands the model the pinned current brief + new deltas."""
+def test_ledger_prompt_carries_ledger_and_narrative():
+    """The brief request hands the model the ledger, narrative, and new deltas."""
     seen: list[str] = []
     service, _, _, _, _ = _triage_service(
-        brief_fn=lambda prompt: seen.append(prompt) or "UPDATED"
+        brief_fn=lambda prompt: seen.append(prompt) or _brief_reply("UPDATED")
     )
-    service.last_triage = {**_escalated_payload(), "brief": "PRIOR BRIEF TEXT"}
+    service._fact_ledger.merge(["PRIOR FACT"])
+    service._last_brief_narrative = "PRIOR NARRATIVE"
     service._pending_brief = ["Unknown: hello there"]
     service._brief_outstanding = True
 
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
     assert len(seen) == 1
-    assert seen[0].startswith("CURRENT BRIEF:\nPRIOR BRIEF TEXT")
+    assert "CURRENT LEDGER:" in seen[0]
+    assert "- PRIOR FACT" in seen[0]
+    assert "CURRENT BRIEF:\nPRIOR NARRATIVE" in seen[0]
     assert "NEW UTTERANCES:\n- Unknown: hello there" in seen[0]
 
 
@@ -1547,7 +1582,6 @@ def test_brief_merges_content_and_writes_file():
     assert service._pending_brief == []
     assert service._brief_outstanding is False
 
-
 def test_empty_brief_requeues_deltas():
     """A `""` reply keeps signals, re-buffers the deltas, and clears the flag."""
     service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: "")
@@ -1648,11 +1682,11 @@ def test_deltas_arriving_during_call_survive_and_flush():
 
     appended: list[int] = []
 
-    def _brief(prompt):
+    def _brief(prompt, on_io=None, finalize=None):
         if not appended:
             appended.append(1)
             service.handle_event(_transcript(clock, "during call"))
-        return "first brief"
+        return _brief_reply("first brief")
 
     service._brief_fn = _brief
     service._run_brief_update(0, signals, clock(), token)
@@ -2111,12 +2145,12 @@ def test_tick_heals_triage_panel_after_lane_ready():
         lane.stop()
 
 
-def _dump_brief(prompt, on_io=None):
+def _dump_brief(prompt, on_io=None, finalize=None):
     """Brief fake that mimics LlmClient.chat's on_io phases."""
     if on_io is not None:
         on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
         on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
-    return "BRIEF"
+    return _brief_reply("BRIEF")
 
 
 def _run_triage_flow(service, emitted, clock, lane, payload):
@@ -2157,7 +2191,8 @@ def test_dump_writes_full_laya_and_llm_io(tmp_path):
     assert data["laya"]["error"] is None
     assert data["llm"]["request"]["url"] == "http://llm/v1"
     assert data["llm"]["request"]["body"] == {
-        "prompt": "CURRENT BRIEF:\n(none yet)\n\nNEW UTTERANCES:\n- Unknown: hello"
+        "prompt": "CURRENT LEDGER:\n(none yet)\n\nCURRENT BRIEF:\n(none yet)\n\n"
+        "NEW UTTERANCES:\n- Unknown: hello"
     }
     assert data["llm"]["response"] == {"content": "parsed"}
     assert data["llm"]["error"] is None
@@ -2283,13 +2318,13 @@ def test_dump_brief_cleared_mid_flight_writes_nothing(tmp_path):
     service._triage_dump = TriageDumper(True, directory=str(tmp_path))
     service.cfg.triage.dump_io = True
 
-    def stale_brief(prompt, on_io=None):
+    def stale_brief(prompt, on_io=None, finalize=None):
         # Simulate a clear landing mid-LLM-call (after the request phase fired).
         service._triage_generation += 1
         if on_io is not None:
             on_io({"phase": "request", "url": "http://llm/v1", "body": {}})
             on_io({"phase": "response", "payload": {"c": 1}, "error": None})
-        return "RAW"
+        return _brief_reply("RAW")
 
     service._brief_fn = stale_brief
     service._pending_brief = ["Unknown: hello"]
@@ -2348,14 +2383,14 @@ def test_dump_coalesced_update_writes_laya_only_then_llm_only_flush(tmp_path):
 
     appended: list[int] = []
 
-    def _brief(prompt, on_io=None):
+    def _brief(prompt, on_io=None, finalize=None):
         if not appended:
             appended.append(1)
             service.handle_event(_transcript(clock, "during call"))
         if on_io is not None:
             on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
             on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
-        return "BRIEF"
+        return _brief_reply("BRIEF")
 
     service._brief_fn = _brief
     clock.advance(1.0)
@@ -2380,7 +2415,8 @@ def test_dump_coalesced_update_writes_laya_only_then_llm_only_flush(tmp_path):
     assert len(both) == 1
     assert coalesced[0]["ts"] != flushed_records[0]["ts"]
     assert flushed_records[0]["llm"]["request"]["body"] == {
-        "prompt": "CURRENT BRIEF:\nBRIEF\n\nNEW UTTERANCES:\n- Unknown: during call"
+        "prompt": "CURRENT LEDGER:\n(none yet)\n\nCURRENT BRIEF:\nBRIEF\n\n"
+        "NEW UTTERANCES:\n- Unknown: during call"
     }
 
 

+ 19 - 0
tests/test_config.py

@@ -570,6 +570,8 @@ def test_triage_defaults():
     assert cfg.triage.brief_gate_threshold == 0.5
     assert cfg.triage.brief_max_chars == 4000
     assert cfg.triage.brief_pending_char_budget == 3000
+    assert cfg.triage.ledger_max_facts == 200
+    assert cfg.triage.fact_max_chars == 240
     assert cfg.triage.device == ""
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.default_checkpoint == "multilingual"
@@ -746,6 +748,23 @@ periodic_s = 12.5
     assert cfg.triage.periodic_s == 12.5
 
 
+def test_triage_ledger_keys_are_parsed():
+    """The [triage] ledger cap keys are applied from TOML."""
+    config_toml = """
+[triage]
+ledger_max_facts = 42
+fact_max_chars = 111
+"""
+    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.ledger_max_facts == 42
+    assert cfg.triage.fact_max_chars == 111
+
+
 def test_brief_defaults():
     """The [brief] section defaults to no on-disk writing."""
     with tempfile.TemporaryDirectory() as tmpdir:

+ 27 - 18
tests/test_llm.py

@@ -948,19 +948,17 @@ def test_chat_session_is_engine_with_chat_prompt():
     assert session.last_reply() == "hello"
 
 
-def test_brief_engine_pins_single_turn_without_history():
+def test_brief_engine_sends_static_system_and_single_user_turn():
     client, calls = _make_client(lambda _p: _reply(content="BRIEF v1"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", max_tokens=99)
 
-    assert engine.last_reply() == ""
-    assert engine.send("CURRENT BRIEF:\n(none yet)\n\nNEW UTTERANCES:\n- one") == "BRIEF v1"
+    assert engine.send("CURRENT LEDGER:\n(none yet)") == "BRIEF v1"
 
-    assert engine.last_reply() == "BRIEF v1"
     assert len(calls) == 1
     messages = calls[0]["body"]["messages"]
     assert messages[0] == {"role": "system", "content": "SYS"}
     assert len(messages) == 2
-    assert messages[1]["content"].startswith("CURRENT BRIEF:")
+    assert messages[1]["content"].startswith("CURRENT LEDGER:")
     assert calls[0]["body"]["max_tokens"] == 99
 
 
@@ -976,42 +974,53 @@ def test_brief_engine_second_call_still_sends_two_messages():
         assert len(call["body"]["messages"]) == 2
 
 
-def test_brief_engine_finalize_none_leaves_pinned_brief():
+def test_brief_engine_is_stateless_between_calls():
+    client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+
+    engine.send("first")
+    engine.send("second")
+
+    assert calls[0]["body"]["messages"][1]["content"] == "first"
+    assert calls[1]["body"]["messages"][1]["content"] == "second"
+
+
+def test_brief_engine_finalize_none_returns_empty():
     client, _ = _make_client(lambda _p: _reply(content="raw"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
-    engine._brief = "prior"
 
     assert engine.send("q", finalize=lambda reply, finish: None) == ""
-    assert engine.last_reply() == "prior"
 
 
-def test_brief_engine_finalize_transform_is_pinned():
+def test_brief_engine_finalize_transform_is_returned():
     client, _ = _make_client(lambda _p: _reply(content="raw"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
 
     result = engine.send("q", finalize=lambda reply, finish: "CLEANED: " + reply)
 
     assert result == "CLEANED: raw"
-    assert engine.last_reply() == "CLEANED: raw"
 
 
-def test_brief_engine_length_truncation_does_not_pin():
+def test_brief_engine_length_reply_is_returned_with_finish_reason():
     client, _ = _make_client(lambda _p: _reply(content="half", finish="length"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+    seen: list[str] = []
 
-    assert engine.send("q", finalize=lambda reply, finish: reply) == "half"
-    assert engine.last_reply() == ""
+    result = engine.send("q", finalize=lambda reply, finish: seen.append(finish) or reply)
 
+    assert result == "half"
+    assert seen == ["length"]
 
-def test_brief_engine_reset_drops_pinned_brief():
+
+def test_brief_engine_reset_invalidates_in_flight_reply():
     client, _ = _make_client(lambda _p: _reply(content="BRIEF"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
-    engine.send("q")
-    assert engine.last_reply() == "BRIEF"
 
-    engine.reset()
+    def finalize(reply, finish):
+        engine.reset()
+        return reply
 
-    assert engine.last_reply() == ""
+    assert engine.send("q", finalize=finalize) == ""
 
 
 def test_brief_engine_passes_slot_and_io_hook():

+ 132 - 12
tests/test_triage.py

@@ -10,7 +10,7 @@ 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.assistant.triage import FactLedger, TriageAnalyzer, load_questions
 from stt_daemon.config import Config
 from stt_daemon.tui.state import ViewState
 
@@ -406,25 +406,29 @@ def test_build_brief_prompt_no_focus_without_signals(tmp_path):
         assert prompt.endswith("Return only the updated brief as plain text.")
 
 
-def test_build_pinned_brief_prompt_inlines_current_brief(tmp_path):
+def test_build_ledger_prompt_inlines_ledger_narrative_and_deltas(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
-    prompt = analyzer.build_pinned_brief_prompt(
-        "Summary: shipped\n\nPrior Details:\n- fact one",
+    prompt = analyzer.build_ledger_prompt(
+        "Summary: shipped\n\nOpportunities: expand",
+        ["fact one", "fact two"],
         ["Alice: let's ship Friday"],
         _signals(noul_p=0.8),
     )
-    assert prompt.startswith("CURRENT BRIEF:\nSummary: shipped")
-    assert "- fact one" in prompt
+    assert "- fact one" in prompt and "- fact two" in prompt
+    assert "CURRENT LEDGER" in prompt
+    assert "Summary: shipped" in prompt
     assert "NEW UTTERANCES:\n- Alice: let's ship Friday" in prompt
+    assert prompt.index("CURRENT LEDGER") < prompt.index("CURRENT BRIEF:")
     assert prompt.index("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
     assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
     assert "churn_risk" in prompt
-    assert prompt.endswith("Return only the updated brief as plain text.")
+    assert "NEW FACTS:" in prompt and "BRIEF:" in prompt
 
 
-def test_build_pinned_brief_prompt_none_brief_is_placeholder(tmp_path):
+def test_build_ledger_prompt_empty_ledger_and_narrative(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
-    prompt = analyzer.build_pinned_brief_prompt(None, ["solo"], None)
+    prompt = analyzer.build_ledger_prompt("", [], ["solo"], None)
+    assert "CURRENT LEDGER" in prompt and "(none yet)" in prompt
     assert "CURRENT BRIEF:\n(none yet)" in prompt
     assert "NEW UTTERANCES:\n- solo" in prompt
     assert "Focus:" not in prompt
@@ -858,6 +862,10 @@ def test_end_to_end_triage_flow():
         emitted.append({"type": type_, **fields})
 
     analyzer = TriageAnalyzer(cfg.triage)
+    def brief_fn(prompt, on_io=None, finalize=None):
+        prompts.append(prompt)
+        return "NEW FACTS:\nnone\n\nBRIEF:\nSummary: brief\n"
+
     service = AssistantService(
         cfg,
         _FakeSubscriber(),
@@ -866,7 +874,7 @@ def test_end_to_end_triage_flow():
         chat_fn=lambda text: "reply",
         triage=analyzer,
         laya_lane=lane,
-        brief_fn=lambda prompt: prompts.append(prompt) or "BRIEF-TEXT",
+        brief_fn=brief_fn,
     )
 
     def triage_events():
@@ -921,7 +929,7 @@ def test_end_to_end_triage_flow():
     content_events = [e for e in triage_events() if e["stage"] == "content"]
     assert len(content_events) == 1
     merged = content_events[0]["triage"]
-    assert merged["brief"] == "BRIEF-TEXT"
+    assert merged["brief"] == "Summary: brief"
     assert merged["escalated"] is True  # signals preserved under the brief
     assert merged["order"] == triage_events()[1]["triage"]["order"]
 
@@ -932,7 +940,7 @@ def test_end_to_end_triage_flow():
     analyzer.refresh("ok")  # the lane callback does this in production
     view.ingest({"type": "status", **service.status()})
     assert view.triage is not None
-    assert view.triage["brief"] == "BRIEF-TEXT"
+    assert view.triage["brief"] == "Summary: brief"
     lines = view.triage_lines()
     assert lines
 
@@ -968,3 +976,115 @@ def test_end_to_end_triage_flow():
     assert plain.status()["triage_state"] == "disabled"
     assert plain.clear() is True
     assert [e for e in emitted2 if e["type"] == "triage"] == []
+
+
+def test_ledger_merge_dedupes_exact_and_punctuation_variants():
+    ledger = FactLedger()
+    assert ledger.merge(["The budget was $305 million.", "Sense could find prey."]) == 2
+    assert ledger.merge(["sense could find prey", "The budget was $305 million"]) == 0
+    assert ledger.facts() == [
+        "The budget was $305 million.",
+        "Sense could find prey.",
+    ]
+
+
+def test_ledger_merge_keeps_near_match_with_different_number():
+    ledger = FactLedger()
+    ledger.merge(["Sold 1.9 million units."])
+    assert ledger.merge(["Sold 10 million units."]) == 1
+    assert len(ledger) == 2
+
+
+def test_ledger_merge_keeps_near_match_with_different_entity():
+    ledger = FactLedger()
+    ledger.merge(["Marcus Smith directed the game."])
+    assert ledger.merge(["Mike Daly directed the game."]) == 1
+    assert len(ledger) == 2
+
+
+def test_ledger_merge_ignores_empty_and_non_string():
+    ledger = FactLedger()
+    assert ledger.merge(["", "   ", None, 123]) == 0
+    assert ledger.render() == ""
+
+
+def test_ledger_render_has_prior_details_section():
+    ledger = FactLedger()
+    ledger.merge(["fact one", "fact two"])
+    assert ledger.render() == "Prior Details:\n- fact one\n- fact two"
+
+
+def test_ledger_evicts_oldest_over_cap_and_never_new():
+    ledger = FactLedger(max_facts=2)
+    ledger.merge(["alpha", "beta"])
+    ledger.merge(["gamma"])
+    assert ledger.facts() == ["beta", "gamma"]
+
+
+def test_ledger_truncates_overlong_fact():
+    ledger = FactLedger(fact_max_chars=10)
+    ledger.merge(["x" * 50])
+    assert ledger.facts() == ["x" * 10]
+
+
+def test_ledger_clear_empties():
+    ledger = FactLedger()
+    ledger.merge(["fact"])
+    ledger.clear()
+    assert ledger.facts() == []
+    assert ledger.render() == ""
+
+
+def test_ledger_from_brief_harvests_bullets():
+    ledger = FactLedger.from_brief("Summary: s\n\nPrior Details:\n- one\n- two\n")
+    assert ledger.facts() == ["one", "two"]
+
+
+def test_parse_ledger_reply_splits_narrative_and_facts(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    narrative, facts = analyzer.parse_ledger_reply(
+        "NEW FACTS:\n- budget is $305m\n- sense finds prey\n\nBRIEF:\nSummary: ok"
+    )
+    assert facts == ["budget is $305m", "sense finds prey"]
+    assert narrative == "Summary: ok"
+
+
+def test_parse_ledger_reply_drops_trailing_fact_when_truncated(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    _, facts = analyzer.parse_ledger_reply(
+        "NEW FACTS:\n- complete fact\n- half a fac", truncated=True
+    )
+    assert facts == ["complete fact"]
+
+
+def test_parse_ledger_reply_missing_facts_block(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    narrative, facts = analyzer.parse_ledger_reply("BRIEF:\nSummary: only narrative")
+    assert facts == []
+    assert narrative == "Summary: only narrative"
+
+
+def test_parse_ledger_reply_strips_model_prior_details(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    narrative, _ = analyzer.parse_ledger_reply(
+        "NEW FACTS:\nnone\n\nBRIEF:\nSummary: s\n\nPrior Details:\n- model echo\n"
+    )
+    assert "model echo" not in narrative
+    assert "Summary: s" in narrative
+
+
+def test_parse_ledger_reply_none_on_unusable(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_ledger_reply("") is None
+    assert analyzer.parse_ledger_reply("just some prose with no sections") is None
+
+
+def test_parse_facts_ignores_prose_and_handles_markers(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    block = "- bullet fact\n* star fact\n• dot fact\n1. numbered fact\nplain prose\nnone"
+    assert analyzer.parse_facts(block) == [
+        "bullet fact",
+        "star fact",
+        "dot fact",
+        "numbered fact",
+    ]