Explorar o código

Moved to code-owned ledger

Efren Yevale Varela hai 1 mes
pai
achega
6b256e55d6

+ 2 - 0
config.example.toml

@@ -112,6 +112,8 @@ brief_gate_enabled        = true
 brief_gate_threshold      = 0.5
 brief_gate_threshold      = 0.5
 brief_max_chars           = 4000
 brief_max_chars           = 4000
 brief_pending_char_budget = 3000
 brief_pending_char_budget = 3000
+ledger_max_facts           = 200
+fact_max_chars             = 240
 device                    = ""
 device                    = ""
 preload                   = ["multilingual"]
 preload                   = ["multilingual"]
 default_checkpoint        = "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_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_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
 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)
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
@@ -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.
 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
 `[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 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
 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
      criteria
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
    it drives neither the gate nor the brief.
    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
    history to compact and `[llm].brief_compaction_system_prompt` is unused by the brief
    path. `[llm]` compaction settings still govern the chat/command session.
    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
    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
    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
    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_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_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. |
 | `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. |
 | `device` | `""` | Empty string means auto, which maps to `None` so Laya picks the checkpoint's default device. Any other value is passed through. |
 | `preload` | `["multilingual"]` | Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. |
 | `preload` | `["multilingual"]` | Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. |
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
 | `dump_io` | `false` | Opt-in I/O dump under `triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). |
 | `dump_io` | `false` | Opt-in I/O dump under `triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). |
 
 
 `[llm].triage_system_prompt` drives the brief engine: it is sent as the static system
 `[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`
 `[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
 (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()
         self._lock = threading.RLock()
 
 
     def reset(self) -> None:
     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:
         with self._lock:
-            self._brief = ""
             self._generation += 1
             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(
     def send(
         self,
         self,
         user_text: str,
         user_text: str,
         on_io: Callable[[dict], None] | None = None,
         on_io: Callable[[dict], None] | None = None,
         finalize: Callable[[str, str], str | None] | None = None,
         finalize: Callable[[str, str], str | None] | None = None,
     ) -> str:
     ) -> 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:
         with self._lock:
             generation = self._generation
             generation = self._generation
@@ -533,7 +530,6 @@ class BriefEngine:
         )
         )
         reply = response.content
         reply = response.content
         finish_reason = response.finish_reason
         finish_reason = response.finish_reason
-        truncated = finish_reason == "length"
 
 
         with self._lock:
         with self._lock:
             if generation != self._generation:
             if generation != self._generation:
@@ -544,19 +540,9 @@ class BriefEngine:
             with self._lock:
             with self._lock:
                 if generation != self._generation:
                 if generation != self._generation:
                     return ""
                     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")
             self.notify("truncated", message="response truncated")
 
 
         return reply
         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."""
 """Run the assistant process: subscribe to the STT bus and serve the control socket."""
 
 
 import argparse
 import argparse
-import functools
 import logging
 import logging
 import signal
 import signal
 import threading
 import threading
@@ -42,7 +41,7 @@ def build_triage(cfg, client, on_result=None):
             slot_id=cfg.triage.brief_slot_id,
             slot_id=cfg.triage.brief_slot_id,
             notify=None,
             notify=None,
         )
         )
-        brief_fn = functools.partial(engine.send, finalize=analyzer.finalize_brief)
+        brief_fn = engine.send
         brief_reset = engine.reset
         brief_reset = engine.reset
 
 
     return (analyzer, lane, brief_fn, brief_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 .executor import ExecutionResult
 from .inference import InferenceJob, InferenceQueue
 from .inference import InferenceJob, InferenceQueue
 from .modes import Mode, ModeMachine
 from .modes import Mode, ModeMachine
+from .triage import FactLedger
 from .triage_dump import TriageDumper
 from .triage_dump import TriageDumper
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -156,6 +157,11 @@ class AssistantService:
         self._brief_outstanding = False
         self._brief_outstanding = False
         self._brief_token = 0
         self._brief_token = 0
         self._last_brief_signals: dict | None = None
         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._chat_queue_full = False
         self._last_triage_state: tuple[str, str] | None = None
         self._last_triage_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
         self.last_triage: dict | None = None
@@ -168,6 +174,10 @@ class AssistantService:
         self._triage_generation = 0
         self._triage_generation = 0
         self._last_transcript_lang: str | None = None
         self._last_transcript_lang: str | None = None
         self._triage_dump = TriageDumper(bool(getattr(cfg.triage, "dump_io", False)))
         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
         self._last_laya_request: dict | None = None
 
 
         # Health
         # Health
@@ -1073,10 +1083,13 @@ class AssistantService:
         return True
         return True
 
 
     def _current_brief(self) -> str:
     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:
         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:
     def _run_brief_update(self, generation, signals, ts=None, token=None) -> None:
         if self._triage is None or self._brief_fn is None:
         if self._triage is None or self._brief_fn is None:
@@ -1121,28 +1134,55 @@ class AssistantService:
                             },
                             },
                         )
                         )
                 io_hook = _record_io
                 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:
             if dump:
-                text = self._brief_fn(prompt, on_io=io_hook)
+                text = self._brief_fn(prompt, on_io=io_hook, finalize=_capture)
             else:
             else:
-                text = self._brief_fn(prompt)
+                text = self._brief_fn(prompt, finalize=_capture)
             with self._lock:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     return
             if not text:
             if not text:
                 requeued = True
                 requeued = True
                 return
                 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:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     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}
                 merged = {**(signals or {}), "brief": text}
                 self.last_triage = merged
                 self.last_triage = merged
                 if self._brief_file is not None:
                 if self._brief_file is not None:
                     self._brief_file.write(text)
                     self._brief_file.write(text)
                 self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
                 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
         except Exception as e:  # noqa: BLE001 - a brief failure must not kill the worker
             logger.error("Triage brief error: %s", e)
             logger.error("Triage brief error: %s", e)
             requeued = True
             requeued = True
@@ -1193,6 +1233,9 @@ class AssistantService:
         self._last_triage_window = ()
         self._last_triage_window = ()
         self._triage_next_due = None
         self._triage_next_due = None
         self.triage_history = []
         self.triage_history = []
+        if self._fact_ledger is not None:
+            self._fact_ledger.clear()
+        self._last_brief_narrative = ""
         self._brief_token += 1
         self._brief_token += 1
         self._brief_outstanding = False
         self._brief_outstanding = False
         self._last_brief_signals = None
         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 normalize(self, payload) -> dict
         def should_escalate(self, signals) -> bool
         def should_escalate(self, signals) -> bool
         def build_brief_prompt(self, deltas, signals) -> str
         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_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 should_update_brief(self, signals) -> bool
         def finalize_brief(self, text, finish_reason) -> str | None
         def finalize_brief(self, text, finish_reason) -> str | None
         def state(self) -> tuple[str, str]
         def state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
         def refresh(self, state: str, reason: str = "") -> None
 
 
+    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
 ``cfg`` is duck-typed (attribute access only, never imported): it provides
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``questions_file``, ``state_char_budget`` (default 3000),
 ``confidence_field`` (default ``"answer_confidence"``),
 ``confidence_field`` (default ``"answer_confidence"``),
@@ -38,6 +50,8 @@ Public API (consumed by the assistant service layer):
 
 
 import json
 import json
 import logging
 import logging
+import re
+import unicodedata
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -379,27 +393,110 @@ class TriageAnalyzer:
         parts.append("Return only the updated brief as plain text.")
         parts.append("Return only the updated brief as plain text.")
         return "\n\n".join(parts)
         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:
         try:
             body = "\n".join("- " + delta for delta in (deltas or []))
             body = "\n".join("- " + delta for delta in (deltas or []))
         except Exception:
         except Exception:
             body = ""
             body = ""
+        ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
         parts = [
         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,
             "NEW UTTERANCES:\n" + body,
         ]
         ]
         focus = self._signal_lines(signals)
         focus = self._signal_lines(signals)
         if focus:
         if focus:
             parts.append("Focus:\n" + "\n".join(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)
         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
     @staticmethod
     def _normalize_bullet(text: str) -> str:
     def _normalize_bullet(text: str) -> str:
         """Collapse a bullet to a comparison key; empty when it carries no words."""
         """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:
     def restore_missing_bullets(self, new_brief, prior_brief) -> str:
         """Re-append any prior bullet that the model dropped from ``new_brief``.
         """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():
         if not isinstance(new_brief, str) or not new_brief.strip():
             return new_brief
             return new_brief
@@ -598,3 +696,150 @@ class TriageAnalyzer:
             return
             return
         self._state = state
         self._state = state
         self._reason = reason if isinstance(reason, str) else str(reason)
         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_gate_threshold: float = 0.5
     brief_max_chars: int = 4000
     brief_max_chars: int = 4000
     brief_pending_char_budget: int = 3000
     brief_pending_char_budget: int = 3000
+    ledger_max_facts: int = 200
+    fact_max_chars: int = 240
     device: str = ""
     device: str = ""
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     default_checkpoint: str = "multilingual"
     default_checkpoint: str = "multilingual"
@@ -570,6 +572,12 @@ def load_config(path: str | None = None) -> Config:
                 brief_pending_char_budget=triage_data.get(
                 brief_pending_char_budget=triage_data.get(
                     "brief_pending_char_budget", cfg.triage.brief_pending_char_budget
                     "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),
                 device=triage_data.get("device", cfg.triage.device),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 preload=list(triage_data.get("preload", cfg.triage.preload)),
                 default_checkpoint=triage_data.get(
                 default_checkpoint=triage_data.get(

+ 5 - 2
tests/test_assistant_integration.py

@@ -491,7 +491,8 @@ class _EngineClient:
     """
     """
 
 
     def __init__(
     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",
         chat_reply: str = "chat reply",
     ) -> None:
     ) -> None:
         self.calls: list[dict] = []
         self.calls: list[dict] = []
@@ -516,6 +517,8 @@ class _EngineClient:
         if is_brief and self.brief_error:
         if is_brief and self.brief_error:
             raise RuntimeError("llm down")
             raise RuntimeError("llm down")
         content = self.brief_reply if is_brief else self.chat_reply
         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")
         return SimpleNamespace(content=content, finish_reason="stop")
 
 
     def brief_calls(self) -> list[dict]:
     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.chat_calls()] == [7]
     assert [call["slot_id"] for call in client.brief_calls()] == [5]
     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):
 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"
         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(
         self.prompt_calls.append(
             (tuple(deltas), dict(signals) if isinstance(signals, dict) else signals)
             (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)
         body = "\n".join("- " + d for d in deltas)
         return (
         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):
     def parse_brief(self, text):
         if self._parsed is _AUTO:
         if self._parsed is _AUTO:
@@ -679,6 +682,12 @@ def _ok_reply(text):
     return "ok"
     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(
 def _triage_service(
     triage=None,
     triage=None,
     lane=None,
     lane=None,
@@ -711,7 +720,7 @@ def _triage_service(
         time_fn=clock,
         time_fn=clock,
         triage=triage,
         triage=triage,
         laya_lane=lane,
         laya_lane=lane,
-        brief_fn=brief_fn,
+        brief_fn=_wrap_brief_fn(brief_fn),
         brief_reset=brief_reset,
         brief_reset=brief_reset,
         brief_file=brief_file,
         brief_file=brief_file,
         chat_fn=chat_fn,
         chat_fn=chat_fn,
@@ -721,6 +730,33 @@ def _triage_service(
     return service, emitted, clock, triage, lane
     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):
 def _triage_events(emitted):
     return [e for e in emitted if e["type"] == "triage"]
     return [e for e in emitted if e["type"] == "triage"]
 
 
@@ -1471,57 +1507,56 @@ def _seed_brief(service, *deltas):
     return service._brief_token
     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._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)
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
 
     assert service.last_triage is not None
     assert service.last_triage is not None
     brief = service.last_triage["brief"]
     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._brief_outstanding = True
 
 
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
     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] = []
     seen: list[str] = []
     service, _, _, _, _ = _triage_service(
     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._pending_brief = ["Unknown: hello there"]
     service._brief_outstanding = True
     service._brief_outstanding = True
 
 
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
     service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
 
 
     assert len(seen) == 1
     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]
     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._pending_brief == []
     assert service._brief_outstanding is False
     assert service._brief_outstanding is False
 
 
-
 def test_empty_brief_requeues_deltas():
 def test_empty_brief_requeues_deltas():
     """A `""` reply keeps signals, re-buffers the deltas, and clears the flag."""
     """A `""` reply keeps signals, re-buffers the deltas, and clears the flag."""
     service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: "")
     service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: "")
@@ -1648,11 +1682,11 @@ def test_deltas_arriving_during_call_survive_and_flush():
 
 
     appended: list[int] = []
     appended: list[int] = []
 
 
-    def _brief(prompt):
+    def _brief(prompt, on_io=None, finalize=None):
         if not appended:
         if not appended:
             appended.append(1)
             appended.append(1)
             service.handle_event(_transcript(clock, "during call"))
             service.handle_event(_transcript(clock, "during call"))
-        return "first brief"
+        return _brief_reply("first brief")
 
 
     service._brief_fn = _brief
     service._brief_fn = _brief
     service._run_brief_update(0, signals, clock(), token)
     service._run_brief_update(0, signals, clock(), token)
@@ -2111,12 +2145,12 @@ def test_tick_heals_triage_panel_after_lane_ready():
         lane.stop()
         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."""
     """Brief fake that mimics LlmClient.chat's on_io phases."""
     if on_io is not None:
     if on_io is not None:
         on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
         on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
         on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
         on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
-    return "BRIEF"
+    return _brief_reply("BRIEF")
 
 
 
 
 def _run_triage_flow(service, emitted, clock, lane, payload):
 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["laya"]["error"] is None
     assert data["llm"]["request"]["url"] == "http://llm/v1"
     assert data["llm"]["request"]["url"] == "http://llm/v1"
     assert data["llm"]["request"]["body"] == {
     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"]["response"] == {"content": "parsed"}
     assert data["llm"]["error"] is None
     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._triage_dump = TriageDumper(True, directory=str(tmp_path))
     service.cfg.triage.dump_io = True
     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).
         # Simulate a clear landing mid-LLM-call (after the request phase fired).
         service._triage_generation += 1
         service._triage_generation += 1
         if on_io is not None:
         if on_io is not None:
             on_io({"phase": "request", "url": "http://llm/v1", "body": {}})
             on_io({"phase": "request", "url": "http://llm/v1", "body": {}})
             on_io({"phase": "response", "payload": {"c": 1}, "error": None})
             on_io({"phase": "response", "payload": {"c": 1}, "error": None})
-        return "RAW"
+        return _brief_reply("RAW")
 
 
     service._brief_fn = stale_brief
     service._brief_fn = stale_brief
     service._pending_brief = ["Unknown: hello"]
     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] = []
     appended: list[int] = []
 
 
-    def _brief(prompt, on_io=None):
+    def _brief(prompt, on_io=None, finalize=None):
         if not appended:
         if not appended:
             appended.append(1)
             appended.append(1)
             service.handle_event(_transcript(clock, "during call"))
             service.handle_event(_transcript(clock, "during call"))
         if on_io is not None:
         if on_io is not None:
             on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
             on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
             on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
             on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
-        return "BRIEF"
+        return _brief_reply("BRIEF")
 
 
     service._brief_fn = _brief
     service._brief_fn = _brief
     clock.advance(1.0)
     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 len(both) == 1
     assert coalesced[0]["ts"] != flushed_records[0]["ts"]
     assert coalesced[0]["ts"] != flushed_records[0]["ts"]
     assert flushed_records[0]["llm"]["request"]["body"] == {
     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_gate_threshold == 0.5
     assert cfg.triage.brief_max_chars == 4000
     assert cfg.triage.brief_max_chars == 4000
     assert cfg.triage.brief_pending_char_budget == 3000
     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.device == ""
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.preload == ["multilingual"]
     assert cfg.triage.default_checkpoint == "multilingual"
     assert cfg.triage.default_checkpoint == "multilingual"
@@ -746,6 +748,23 @@ periodic_s = 12.5
     assert cfg.triage.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():
 def test_brief_defaults():
     """The [brief] section defaults to no on-disk writing."""
     """The [brief] section defaults to no on-disk writing."""
     with tempfile.TemporaryDirectory() as tmpdir:
     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"
     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"))
     client, calls = _make_client(lambda _p: _reply(content="BRIEF v1"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", max_tokens=99)
     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
     assert len(calls) == 1
     messages = calls[0]["body"]["messages"]
     messages = calls[0]["body"]["messages"]
     assert messages[0] == {"role": "system", "content": "SYS"}
     assert messages[0] == {"role": "system", "content": "SYS"}
     assert len(messages) == 2
     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
     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
         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"))
     client, _ = _make_client(lambda _p: _reply(content="raw"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
-    engine._brief = "prior"
 
 
     assert engine.send("q", finalize=lambda reply, finish: None) == ""
     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"))
     client, _ = _make_client(lambda _p: _reply(content="raw"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
 
 
     result = engine.send("q", finalize=lambda reply, finish: "CLEANED: " + reply)
     result = engine.send("q", finalize=lambda reply, finish: "CLEANED: " + reply)
 
 
     assert result == "CLEANED: raw"
     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"))
     client, _ = _make_client(lambda _p: _reply(content="half", finish="length"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
     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"))
     client, _ = _make_client(lambda _p: _reply(content="BRIEF"))
     engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
     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():
 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 import events
 from stt_daemon.assistant import triage
 from stt_daemon.assistant import triage
 from stt_daemon.assistant.service import AssistantService
 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.config import Config
 from stt_daemon.tui.state import ViewState
 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.")
         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())
     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"],
         ["Alice: let's ship Friday"],
         _signals(noul_p=0.8),
         _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 "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("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
     assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
     assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
     assert "churn_risk" in prompt
     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())
     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 "CURRENT BRIEF:\n(none yet)" in prompt
     assert "NEW UTTERANCES:\n- solo" in prompt
     assert "NEW UTTERANCES:\n- solo" in prompt
     assert "Focus:" not in prompt
     assert "Focus:" not in prompt
@@ -858,6 +862,10 @@ def test_end_to_end_triage_flow():
         emitted.append({"type": type_, **fields})
         emitted.append({"type": type_, **fields})
 
 
     analyzer = TriageAnalyzer(cfg.triage)
     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(
     service = AssistantService(
         cfg,
         cfg,
         _FakeSubscriber(),
         _FakeSubscriber(),
@@ -866,7 +874,7 @@ def test_end_to_end_triage_flow():
         chat_fn=lambda text: "reply",
         chat_fn=lambda text: "reply",
         triage=analyzer,
         triage=analyzer,
         laya_lane=lane,
         laya_lane=lane,
-        brief_fn=lambda prompt: prompts.append(prompt) or "BRIEF-TEXT",
+        brief_fn=brief_fn,
     )
     )
 
 
     def triage_events():
     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"]
     content_events = [e for e in triage_events() if e["stage"] == "content"]
     assert len(content_events) == 1
     assert len(content_events) == 1
     merged = content_events[0]["triage"]
     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["escalated"] is True  # signals preserved under the brief
     assert merged["order"] == triage_events()[1]["triage"]["order"]
     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
     analyzer.refresh("ok")  # the lane callback does this in production
     view.ingest({"type": "status", **service.status()})
     view.ingest({"type": "status", **service.status()})
     assert view.triage is not None
     assert view.triage is not None
-    assert view.triage["brief"] == "BRIEF-TEXT"
+    assert view.triage["brief"] == "Summary: brief"
     lines = view.triage_lines()
     lines = view.triage_lines()
     assert lines
     assert lines
 
 
@@ -968,3 +976,115 @@ def test_end_to_end_triage_flow():
     assert plain.status()["triage_state"] == "disabled"
     assert plain.status()["triage_state"] == "disabled"
     assert plain.clear() is True
     assert plain.clear() is True
     assert [e for e in emitted2 if e["type"] == "triage"] == []
     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",
+    ]