@arnilo/prism 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +11 -11
  3. package/dist/agent-approval.d.ts +11 -2
  4. package/dist/agent-event-source.d.ts +9 -1
  5. package/dist/agent-event-source.js +10 -3
  6. package/dist/agent-loops.js +7 -4
  7. package/dist/agent-run-lifecycle.d.ts +15 -1
  8. package/dist/agent-run-lifecycle.js +63 -6
  9. package/dist/agent-run-state.d.ts +22 -2
  10. package/dist/agent-run-state.js +57 -5
  11. package/dist/agent-session/helpers.js +14 -0
  12. package/dist/agent-session/session/assemble.js +126 -24
  13. package/dist/agent-session/session/persist.d.ts +11 -0
  14. package/dist/agent-session/session/persist.js +37 -11
  15. package/dist/agent-session/session/provider-round.d.ts +14 -4
  16. package/dist/agent-session/session/provider-round.js +185 -19
  17. package/dist/agent-session/session/tool-round.js +20 -1
  18. package/dist/agent-session/session/types.d.ts +25 -2
  19. package/dist/agent-session/session.d.ts +38 -4
  20. package/dist/agent-session/session.js +76 -5
  21. package/dist/attention-compiler.d.ts +51 -2
  22. package/dist/attention-compiler.js +282 -21
  23. package/dist/cache-helpers.d.ts +4 -2
  24. package/dist/cache-helpers.js +8 -6
  25. package/dist/checkpoint-restore.d.ts +45 -0
  26. package/dist/checkpoint-restore.js +54 -0
  27. package/dist/context-budget.d.ts +2 -1
  28. package/dist/context-budget.js +24 -2
  29. package/dist/contracts-core/agent.d.ts +30 -0
  30. package/dist/contracts-core/attention.d.ts +95 -0
  31. package/dist/contracts-core/content.d.ts +10 -0
  32. package/dist/contracts-core/guardrail-packs.d.ts +41 -0
  33. package/dist/contracts-core/guardrail-packs.js +2 -0
  34. package/dist/contracts-core/provider.d.ts +25 -0
  35. package/dist/contracts-core/run-limits.d.ts +19 -0
  36. package/dist/contracts-core/session.d.ts +23 -5
  37. package/dist/contracts-core/session.js +21 -2
  38. package/dist/contracts-core/usage.d.ts +40 -0
  39. package/dist/contracts-core/usage.js +8 -0
  40. package/dist/contracts-core.d.ts +2 -0
  41. package/dist/contracts-core.js +2 -0
  42. package/dist/contracts-protocol.d.ts +76 -2
  43. package/dist/contracts-run-state.d.ts +56 -1
  44. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  45. package/dist/guardrail-packs/coding-standard.js +63 -0
  46. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  47. package/dist/guardrail-packs/destructive-commands.js +46 -0
  48. package/dist/guardrail-packs/errors.d.ts +7 -0
  49. package/dist/guardrail-packs/errors.js +9 -0
  50. package/dist/guardrail-packs/index.d.ts +4 -0
  51. package/dist/guardrail-packs/index.js +15 -0
  52. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  53. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  54. package/dist/guardrail-packs/types.d.ts +16 -0
  55. package/dist/guardrail-packs/types.js +2 -0
  56. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  57. package/dist/guardrail-packs/validation-respect.js +53 -0
  58. package/dist/guardrails.d.ts +20 -1
  59. package/dist/guardrails.js +268 -0
  60. package/dist/index.d.ts +14 -9
  61. package/dist/index.js +9 -6
  62. package/dist/input.d.ts +8 -1
  63. package/dist/input.js +68 -6
  64. package/dist/middleware.d.ts +37 -2
  65. package/dist/middleware.js +41 -0
  66. package/dist/node/session-store-jsonl.js +18 -3
  67. package/dist/observability.js +6 -0
  68. package/dist/provider-events.d.ts +8 -2
  69. package/dist/provider-events.js +60 -2
  70. package/dist/providers/openai-compatible.js +6 -3
  71. package/dist/run-bundle.js +2 -1
  72. package/dist/run-limits.d.ts +11 -1
  73. package/dist/run-limits.js +46 -0
  74. package/dist/session-stores.d.ts +12 -1
  75. package/dist/session-stores.js +21 -4
  76. package/dist/testing/agent-event-source-conformance.js +41 -2
  77. package/dist/testing/prefix-stability-conformance.d.ts +30 -0
  78. package/dist/testing/prefix-stability-conformance.js +104 -0
  79. package/dist/testing/session-store-conformance.d.ts +3 -2
  80. package/dist/testing/session-store-conformance.js +48 -0
  81. package/dist/tools.d.ts +5 -0
  82. package/dist/tools.js +11 -3
  83. package/dist/usage-estimation.d.ts +29 -0
  84. package/dist/usage-estimation.js +79 -0
  85. package/docs/agent-events.md +68 -1
  86. package/docs/agent-session-runtime.md +1 -0
  87. package/docs/attention-compiler.md +89 -8
  88. package/docs/coding-agent-tools.md +1 -1
  89. package/docs/compaction-and-retry.md +1 -1
  90. package/docs/compaction-observational-memory.md +33 -6
  91. package/docs/durable-runs.md +42 -0
  92. package/docs/embeddings.md +5 -0
  93. package/docs/evaluations.md +5 -0
  94. package/docs/execution-timeline.md +78 -1
  95. package/docs/guardrails.md +38 -2
  96. package/docs/index.md +32 -13
  97. package/docs/input-and-prompt-assembly.md +3 -3
  98. package/docs/knowledge-sync.md +4 -0
  99. package/docs/middleware-hooks.md +38 -2
  100. package/docs/migrate-to-0.9.md +210 -0
  101. package/docs/migration.md +13 -0
  102. package/docs/multi-agent-patterns.md +25 -2
  103. package/docs/node-jsonl-session-store.md +7 -1
  104. package/docs/observability.md +7 -3
  105. package/docs/options-index.md +2 -1
  106. package/docs/policy-and-audit.md +13 -1
  107. package/docs/prefix-stability-conformance.md +93 -0
  108. package/docs/provider-caching.md +4 -4
  109. package/docs/provider-conformance.md +16 -0
  110. package/docs/provider-packages.md +20 -20
  111. package/docs/public-contracts.md +2 -2
  112. package/docs/rag.md +101 -3
  113. package/docs/release-and-install.md +39 -37
  114. package/docs/runs-and-usage.md +43 -6
  115. package/docs/scoped-agent-memory.md +262 -0
  116. package/docs/session-store-conformance.md +1 -2
  117. package/docs/session-stores.md +17 -17
  118. package/docs/supervisors.md +32 -12
  119. package/docs/tools.md +17 -0
  120. package/docs/workflows.md +5 -0
  121. package/package.json +5 -1
@@ -0,0 +1,262 @@
1
+ # Scoped persistent agent memory — design concept
2
+
3
+ Status: **concept/proposal**. This page describes a system design for workspace-scoped persistent agent memory — durable facts and procedures that are recorded, updated, and used automatically during agentic work. Nothing here is implemented as a package yet; it is the reference description for the approach. Terminology deliberately aligns with the existing Prism memory surfaces ([memory fabric](memory-fabric.md), [observational memory](compaction-observational-memory.md), [working and semantic memory](working-and-semantic-memory.md)) — see [Relationship to existing Prism memory surfaces](#relationship-to-existing-prism-memory-surfaces).
4
+
5
+ ## Problem and goals
6
+
7
+ A host running agents over a workspace — a codebase, a professional practice, a research corpus — wants the agent to accumulate durable knowledge across sessions without manual curation: conventions, environment quirks, proven procedures, corrections, user preferences. The system must:
8
+
9
+ - **Persist** durable facts and procedures per *scope of work* (workspace, codebase, content collection).
10
+ - **Record automatically** — post-task reflection, not user-issued "remember this" commands only.
11
+ - **Stay accurate at scale** — hundreds of accumulated records must not degrade routing, cost, or behavior.
12
+ - **Be auditable** — professional work requires provenance, review, and human override.
13
+
14
+ The canonical live experiment is the Hermes agent; its documented failure modes at scale define the requirements here.
15
+
16
+ ## Case study: Hermes agent
17
+
18
+ Hermes (NousResearch/hermes-agent) is a self-improving personal agent. Its memory architecture:
19
+
20
+ | Layer | Mechanism | Design constraint |
21
+ | --- | --- | --- |
22
+ | Declarative memory | `MEMORY.md` (2,200 chars) + `USER.md` (1,375 chars), injected as a frozen snapshot at session start | Hard capacity: an overflowing write returns an **error that forces consolidation** — never silent growth |
23
+ | Episodic | SQLite FTS5 session search (`session_search`), ~20 ms, no LLM calls | Unlimited, never injected into prompts |
24
+ | Procedural | Skills: markdown `SKILL.md` under `~/.hermes/skills/` (agentskills.io format) | Name + one-line description in the system prompt; full body loaded on demand (`skill_view`) — progressive disclosure |
25
+ | Write loop | Background review agent after each turn, prompt biased toward action: *"most sessions produce at least one skill update"*; bar ≈ 5 tool calls / error recovery / user correction | Patch > edit > create; optional `write_approval` staging gate |
26
+
27
+ What Hermes got right: bounded always-on memory with capacity-forced consolidation, episodic search off the prompt, progressive disclosure for skill bodies, and a background reflection loop instead of user-driven memory commands.
28
+
29
+ ### Documented failure modes at scale
30
+
31
+ - **Catalog inflation (issue [#22620](https://github.com/NousResearch/hermes-agent/issues/22620)):** every skill's name + category + description is injected into the system prompt on *every turn*. 243 skills ≈ 10–15K tokens per API call; a 130-skill setup ≈ 4K tokens/turn. Routing is done by making the model attend over the whole catalog, so prompt cost and selection confusion scale O(N) with library size.
32
+ - **Lazy-loading demand (issue [#2045](https://github.com/NousResearch/hermes-agent/issues/2045)):** 87 bundled skills ≈ 1.5–2K tokens before any user content; users manually prune (73 → 26 in one report).
33
+ - **No metabolism (issue [#12877](https://github.com/NousResearch/hermes-agent/issues/12877)):** skills are add-only. No decay, no invalidation, no usage-based cleanup. The library grows monotonically and useful skills are buried under throwaways.
34
+ - **No write-quality gate (#12877 §1):** ~5 tool calls is enough to mint a permanent "skill." A one-off debugging session becomes procedural knowledge with no consolidation or validation — working memory promoted directly to long-term memory.
35
+ - **Skill islands (#12877 §3):** no composition between skills; the same logic (e.g. a TDD workflow) is duplicated across many skills; association fields are decorative.
36
+ - **No conflict detection (#12877 §4):** only identical *names* are blocked; near-duplicate *functionality* competes for execution and produces chaotic output.
37
+ - **Community-validated fix inside #22620:** usage-decay scoring (`score = uses × exp(−Δdays/30)`) plus a hard character budget for the skill list cut catalog tokens ~70% at zero LLM cost — evidence that decay-weighted routing is the right primitive.
38
+
39
+ **Root cause synthesis:** Hermes solved read-path token economics (progressive disclosure) but left the *routing surface* O(N) in the prompt and gave the *write path* no quality gate, no garbage collection, no deduplication, and no conflict resolution. "Misfiring" is the routing problem: as semantic overlap across hundreds of descriptions grows, selection degrades, and stale or duplicate entries win over correct ones.
40
+
41
+ ## Research basis
42
+
43
+ | System | Contribution taken |
44
+ | --- | --- |
45
+ | **Mem0** ([arXiv 2504.19413](https://doi.org/10.48550/arxiv.2504.19413)) | Write path as extraction + adjudication: an LLM decides ADD / UPDATE / DELETE / NOOP against existing memories. Solves add-only bloat; strong LoCoMo results against MemGPT/A-MEM baselines. |
46
+ | **Zep / Graphiti** ([arXiv 2501.13956](https://arxiv.org/html/2501.13956)) | Temporal validity as a first-class citizen: every fact carries `valid_at`/`invalid_at`; a new fact *closes* the old one rather than duplicating it. Solves fact staleness and contradiction. |
47
+ | **A-MEM** ([arXiv 2502.12110](https://arxiv.org/abs/2502.12110), NeurIPS'25) | Zettelkasten-style memory: notes carry structured attributes (keywords, tags, contextual descriptions), **link generation** to related notes, and **memory evolution** — new notes update the representations of old ones. Solves island isolation. |
48
+ | **Agent Workflow Memory** ([arXiv 2409.07429](https://arxiv.org/abs/2409.07429), ICML'25) | Procedural induction by mining *repeated* sub-routines across trajectories and abstracting out instance-specific context before storage. Fixes "one successful session becomes a skill." |
49
+ | **Generative Agents** ([arXiv 2304.03442](https://arxiv.org/abs/2304.03442)) | Memory-stream retrieval scored by **relevance × recency × importance**, plus periodic **reflection** that synthesizes higher-level insights once accumulated importance crosses a threshold. |
50
+ | **Voyager** ([arXiv 2305.16291](https://arxiv.org/html/2305.16291v2)) | Skill-library precedent: skills retrieved by embedding top-k, and a skill enters the library only after **execution verification** — promotion gates are not new. |
51
+ | **HippoRAG** ([NeurIPS'24](https://proceedings.neurips.cc/paper_files/paper/2024/file/6ddc001d07ca4f319af96a3024f6dbd1-Paper-Conference.pdf)) | Associative multi-hop recall via knowledge graph + personalized PageRank, for "everything connected to this" queries beyond nearest-neighbor retrieval. |
52
+ | **Letta sleep-time compute** ([arXiv 2504.13171](https://arxiv.org/abs/2504.13171)) | Memory consolidation moved off the interaction path: a background pass reorganizes memory between sessions, improving accuracy while cutting per-turn cost. |
53
+ | **Anthropic Agent Skills** ([engineering post](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)) | Progressive disclosure as a three-level spec; the always-loaded layer (name + description) is the routing surface and must stay tiny. |
54
+
55
+ ## Design principles
56
+
57
+ 1. **Memory is a first-class artifact of the workspace.** Versioned with the workspace, reviewable like code, and *scoped*: a record created in scope X is invisible outside X. One silo per workspace; a separate tiny global layer for user preferences (profile ≠ project).
58
+ 2. **Typed memory, one store per type, different physics per type.** Facts are bounded and always-on; episodes are append-only and searchable; notes/insights are linked and evolving; procedures earn permanence through reuse.
59
+ 3. **Write-heavy is the failure mode.** Writes are gated by repetition, adjudication, and conflict detection. The default write action is *no-op*.
60
+ 4. **Reads are query-driven, never catalog-driven.** Routing happens through a retrieval tool, not attention over an injected list. Prompt cost is O(1) in library size.
61
+ 5. **Metabolism, not accumulation.** Usage-decay garbage collection, validity windows, capacity-forced consolidation. Every record can die.
62
+ 6. **Abstention beats misfiring.** Below a relevance floor, the memory returns "nothing relevant." An agent confidently following a *wrong* retrieved record is worse than one starting fresh.
63
+
64
+ ## System description
65
+
66
+ ### Scope model
67
+
68
+ The unit of memory is the **scope**: a workspace root, codebase, project directory, or content collection. Records carry a `scope` binding and are retrieved only within it — scope itself is the largest single precision filter (a query inside `~/work/api-server` never competes with records from other projects). A distinct, optional global scope holds user-profile facts; nothing crosses scope boundaries by default.
69
+
70
+ ### Storage — typed records, not one bucket
71
+
72
+ ```
73
+ <workspace>/.memory/
74
+ facts.md # always-on, hard character budget
75
+ records/*.md # searchable semantic memory: notes, procedures, insights
76
+ episodes.db # SQLite FTS5, append-only session logs
77
+ index.db # FTS5 (+ optional embeddings) over records — derived, rebuildable
78
+ ```
79
+
80
+ Record format — markdown with frontmatter, human-editable, git-diffable, PR-reviewable:
81
+
82
+ ```markdown
83
+ ---
84
+ id: rec_7f3a91c2d0b4
85
+ type: procedure # fact | note | procedure | insight
86
+ scope: ~/work/api-server
87
+ status: candidate # candidate → verified → archived (promotion ladder)
88
+ created: 2026-06-14
89
+ valid: [2026-06-14, ] # validity window; a superseding write closes it
90
+ provenance: session 8a2f, turn 41 # every claim traceable to its source
91
+ uses: 0 # retrieval activations
92
+ last_used: null
93
+ links: [rec_deploy_rollback, rec_pg_pool]
94
+ ---
95
+ ## Deploy without downtime
96
+ 1. ... (steps abstracted from instance specifics)
97
+ ```
98
+
99
+ Rationale: human override and review are non-negotiable for professional work — diffs, blame, and PR review come free. This mirrors the Hermes `journey edit/delete` lesson (users *must* be able to prune) and generalizes it to full version control.
100
+
101
+ **Prism realization.** In the composed Prism stack (next section) the [memory fabric](memory-fabric.md) is the source of truth for records and the session store/observational ledger owns episodes; `<workspace>/.memory/` becomes a **git audit mirror** — a rendered export of fabric notes for diff/review — not a second storage engine. One write path, two views.
102
+
103
+ ### Write path — reflect, adjudicate, gate
104
+
105
+ A background reflection pass runs post-task on the session digest (Hermes's background review; Letta's sleep-time compute), on a cheaper model. Four hard differences from Hermes:
106
+
107
+ 1. **Conservative bias.** The review prompt's prior is *"most sessions update nothing"* — the explicit inversion of Hermes's action-biased prompt. Write triggers: user correction, error→recovery, a technique reused *within* the session, or an explicit "remember this."
108
+ 2. **Adjudication before write** (Mem0). Retrieve the top-k nearest existing records; the writer decides ADD / UPDATE / MERGE / NOOP against them. Duplication is killed at write time, not by later cleanup.
109
+ 3. **Temporal supersession** (Zep/Graphiti). A contradicting fact closes the old record's validity window — never two live records claiming opposite things. Conflict is detected *at write*, not at misfire time.
110
+ 4. **Promotion ladder** (Voyager; #12877 recommendation). Post-task output is a `candidate` note, never a procedure. A candidate becomes `verified` after **N successful reuses** (N ≈ 2–3) logged from actual retrieval→outcome feedback; insights promoted from reflections follow the same gate. Real-but-unproven knowledge lives as a note; only proven repetition earns procedure status. This single gate eliminates most of Hermes's throwaway-skill flood, because throwaways are never reused.
111
+
112
+ After the gate: **link generation** against retrieved neighbors (A-MEM) so records compose instead of islanding, and every record carries provenance back to the session/turn that produced it.
113
+
114
+ ### Read path — where misfiring is prevented
115
+
116
+ - **In the system prompt:** `facts.md` only (bounded, ~1–2K tokens) plus one line — "memory available, N records in this scope." Never the record list. Routing is a tool call, not attention over a catalog: prompt cost stays flat regardless of library size.
117
+ - **`memory_search(query)`** — hybrid retrieval: lexical (FTS5/BM25, milliseconds, $0) plus embeddings when available, reranked by a Generative-Agents-style score extended with usage feedback:
118
+ `score = relevance × exp(−Δdays/τ) × importance × (1 + log uses)`
119
+ The decay term is exactly the mechanism community-measured at ~70% catalog reduction inside Hermes issue #22620.
120
+ - **Activation budget:** top-3 results per query, and an **abstain floor** — below a similarity threshold the tool returns "no relevant memory."
121
+ - **Link traversal:** follow `links:` one hop for associative recall (HippoRAG-lite). No graph database — frontmatter adjacency only.
122
+
123
+ ### Lifecycle — metabolism
124
+
125
+ An idle/nightly consolidation pass (cheap model, off the interaction path):
126
+
127
+ - **Garbage collection by usage decay.** Archive `candidate` records unused for ~30 days; archive `verified` records below a usage-decay threshold. Recommend-then-delete, never silent deletion (git keeps history regardless).
128
+ - **Near-duplicate merge.** Records flagged by embedding similarity above threshold are merged or invalidated.
129
+ - **Promotion/demotion** per the ladder, from logged retrieval→outcome feedback.
130
+ - **Capacity-forced consolidation of `facts.md`.** When a facts write would exceed the budget, the write *fails* with "consolidate first" (the Hermes overflow-error pattern) — the writer must merge or remove entries in the same action. The always-on layer can therefore never rot.
131
+ - **Digest.** A weekly human-readable diff of memory changes (git already provides the bookkeeping).
132
+
133
+ ### Trust boundary
134
+
135
+ - **Injection/exfiltration scanning** of record content before any prompt injection (patterns, invisible Unicode) — Hermes does this for `MEMORY.md`; extend to all records.
136
+ - **Staged approval.** Writes may be staged for human review (`write_approval`-style). Default: off for personal scopes, on for team/professional scopes.
137
+ - **Scope isolation.** Records never leak across workspace roots; the global user layer is opt-in per record.
138
+ - **Provenance on every record.** Any memory-driven decision can be traced to the session and turn that produced the record (same philosophy as observational memory's source-backed ids and the recall path).
139
+
140
+ ## Failure-mode → mechanism map
141
+
142
+ | Hermes failure mode | Mechanism here | Backing |
143
+ | --- | --- | --- |
144
+ | Catalog tokens O(N)/turn | Routing via retrieval tool; never list injection | #22620, #2045; Anthropic three-level disclosure |
145
+ | Wrong entry wins selection | Abstain floor + top-3 budget + decay-weighted scoring | Generative Agents; measured decay fix in #22620 |
146
+ | Throwaway-skill flood | candidate→verified promotion after N reuses | Voyager verification; #12877 §1; AWM repetition mining |
147
+ | Duplicates | Write-time ADD/UPDATE/MERGE/NOOP adjudication | Mem0 |
148
+ | Contradictory live facts | Validity windows; supersede, don't duplicate | Zep/Graphiti |
149
+ | Record islands | Link generation + memory evolution on write | A-MEM; HippoRAG traversal |
150
+ | Unreviewable autonomous writes | Markdown + git + provenance + staged approval | Hermes `journey`/`write_approval` generalized |
151
+ | No cleanup | Usage-decay GC + consolidation pass | Letta sleep-time; #12877 §2 |
152
+
153
+ ## Evaluation
154
+
155
+ Non-negotiable for professional use; memory must earn its complexity:
156
+
157
+ 1. **Task win-rate A/B** — a fixed task suite per scope, run with memory on / off / never-consolidated. If memory does not lift win rate or reduce turns, it ships off by default.
158
+ 2. **Retrieval precision@3** against a hand-labeled query set per workspace; alert on drops — the leading indicator of misfiring.
159
+ 3. **Health metrics** — duplication rate, candidate→verified conversion rate, activation rate (retrieved-and-used / retrieved), and prompt token cost per turn vs. library size (target: flat).
160
+ 4. **LoCoMo-style recall probes** for the episodic layer, the standard benchmark in the Mem0/Zep line.
161
+
162
+ ## Relationship to existing Prism memory surfaces
163
+
164
+ The placement principle: **the scoped layer is a policy and lifecycle layer, not a fifth store.** Mechanism lives in the engines and the fabric; policy lives in the scoped layer. This section concretizes how the layers compose into one memory-management system.
165
+
166
+ ### Layered architecture and ownership
167
+
168
+ | Layer | Surface | Owns | Never does |
169
+ | --- | --- | --- | --- |
170
+ | Raw transcript | [Session stores](session-stores.md) | append-only entries, branches, bounded lexical search | — |
171
+ | Episodic ledger | [Observational memory](compaction-observational-memory.md) | source-backed observations/reflections (12-hex ids, `sourceEntryIds`), exact-id recall and branch pages, optional work-scope index; observer/reflector/dropper workers are the only writers | no semantic retrieval, no wholesale prompt injection, no downstream re-observation |
172
+ | Memory engines | [Working and semantic memory](working-and-semantic-memory.md) | `Embedder`/vector/working-store contracts, consent lifecycle, lineage invalidation, importance, recall scoring | no policy |
173
+ | Durable records | [Memory fabric](memory-fabric.md) | typed notes (`fact`/`procedure`/`file`/`working`/`episode`), validity windows, consolidation folding, linker/evolution workers, five governed tools, file jail, context provider, `forget`/legal hold | no autonomy — every write is an explicit caller decision |
174
+ | Policy + lifecycle | **Scoped memory (this concept)** | conservative post-run writer, promotion ladder, usage-decay GC, abstain floor + activation budget, workspace-root scope identity, git audit mirror | no store, no engine, no context-block type, no second write path |
175
+
176
+ Two invariants carry over unchanged: observational memory stays **episodic** (promotion out of the ledger is an explicit host write — fabric's `promotedFrom` over a closed work scope), and the fabric never widens consent or visibility.
177
+
178
+ ### Ideal composition for a persistent-memory agent
179
+
180
+ ```ts
181
+ // 1. Engines — workspace root becomes the silo identity
182
+ const memory = createMemory({ tenantId: host, resourceId: workspaceRoot, embedder, stores });
183
+ // 2. Durable records — folding, links, evolution on by policy
184
+ const fabric = createMemoryFabric({ memory, observational, consolidate: { threshold: 0.85 },
185
+ linker: { enabled: true }, evolution: { enabled: true } });
186
+ // 3. Episodic ledger per session; work-scope index bound to the workspace
187
+ om.attach(session);
188
+ // 4. Gate fabric tools + workers to this session
189
+ fabric.attach(session);
190
+ // 5. Injection: ONLY the bounded working facts block reaches the prompt
191
+ registries.contextProviders.register("memory-fabric", fabric.createContextProvider());
192
+ const agent = await resolveAgentDefinition(
193
+ { name: "assistant", model, context: ["memory-fabric"], tools: ["memory.recall"] },
194
+ { registries, providerSource });
195
+ // 6. Scoped policy module (host-side, the new part): post-run review,
196
+ // usage logging on recall hits, idle promotion/GC jobs, .memory/ git mirror
197
+ ```
198
+
199
+ End-to-end flow:
200
+
201
+ 1. **During the session** — observational workers record source-backed observations/reflections; compaction renders prepared memory; the agent may call `memory.recall`/`memory.insert` directly; the prompt carries only the bounded facts block.
202
+ 2. **After the run** — the scoped policy reviews the session digest on a cheap model, biased to no-op, and promotes the *generalizable* part through `fabric.remember` as `candidate` notes carrying `sourceEntryIds` provenance (or `reflectionId` for notes derived from a closed-scope reflection). Folding adjudicates duplicates; linker/evolution connect and refine.
203
+ 3. **Next session** — facts block (working notes) always on; everything else via `memory.recall` (`kinds: ["procedure"]` stays opt-in, `asOf` honors validity, `budget` caps tokens). Recall hits log `uses`.
204
+ 4. **Idle/nightly** — promotion job converts candidates with N logged successful reuses to `verified`; usage-decay GC archives the rest; the facts block is consolidated under its budget; the git mirror renders the diff for human review.
205
+
206
+ ### Read/write paths across layers
207
+
208
+ | Write-path step | Owner |
209
+ | --- | --- |
210
+ | Trigger (post-run, noop-biased) | scoped policy |
211
+ | Extraction with provenance | observational reflection → `reflectionId`/`sourceEntryIds` |
212
+ | Adjudicate ADD/UPDATE/supersede | fabric consolidation folding (cosine threshold 0.85) |
213
+ | Close contradicted facts | fabric `validTo` + `supersedes` |
214
+ | Link + evolve neighbors | fabric linker/evolution workers |
215
+ | Status `candidate`, staging/approval | scoped policy |
216
+
217
+ | Read-path step | Owner |
218
+ | --- | --- |
219
+ | Always-on bounded facts | working block via the fabric context provider — the only injected surface |
220
+ | Query-driven records | `memory.recall` (kinds/asOf/budget) + scoped abstain floor, top-3 budget, decay-weighted scoring |
221
+ | Associative recall | `links` traversal (recall hits with `explain.link: true`) |
222
+ | Episodic specifics | observational exact-id recall / `searchConversation` lexical pages |
223
+
224
+ ### Research leverage map
225
+
226
+ How the research findings land on shipped surfaces versus policy added by this concept:
227
+
228
+ | Finding | Source | Shipped in Prism | Added by this concept |
229
+ | --- | --- | --- | --- |
230
+ | Write adjudication (ADD/UPDATE/DELETE/NOOP) | Mem0 | fabric consolidation folding (insert / rewrite / supersede) | MERGE of near-duplicates in the GC pass |
231
+ | Importance weighting | Mem0 | `importance` / `importanceFrom` hook | — |
232
+ | Temporal validity windows | Zep/Graphiti | fabric `validFrom`/`validTo`, recall `asOf` | — |
233
+ | Invalidate, don't duplicate | Zep/Graphiti | supersede fold at write | — |
234
+ | Bi-temporal time (event vs ingestion) | Zep/Graphiti | `tRef` vs `ingestedAt` | — |
235
+ | Link generation | A-MEM | fabric linker worker | — |
236
+ | Memory evolution | A-MEM | fabric evolution worker | — |
237
+ | Relevance × recency × importance scoring | Generative Agents | fabric recall scoring (similarity/recency/importance) | usage feedback `(1 + log uses)` + exp decay |
238
+ | Verify before permanence | Voyager | — | promotion ladder (N reuses) |
239
+ | Mine repeated routines, abstract instance specifics | AWM | — | conservative review writes abstracted candidates |
240
+ | Off-path consolidation | Letta sleep-time | — (idle-job placement) | GC/promotion/facts consolidation on a cheap model |
241
+ | Tiny always-loaded layer, disclosure on demand | Anthropic skills | recall tools + working block only | abstain floor + top-3 activation budget |
242
+ | Hybrid BM25 + cosine + graph walk | Zep/Graphiti | partial: embedding score + link traversal; lexical on branch search | fusing lexical into one recall — open question |
243
+
244
+ ### Deliberate deviations
245
+
246
+ - **Against Mem0's accumulate-and-rank-at-query.** Mem0's current algorithm appends dated variants and ranks at query time. This design sides with Zep: close the validity window at write. Rationale: the scoped layer injects few records, so each must be *the* trusted record; ranking dated variants at retrieval reintroduces the Hermes catalog/misfire problem one layer down.
247
+ - **Beyond both Mem0 and Zep.** Neither gates *procedures* by reuse — that is exactly the Hermes failure (#12877: ~5 tool calls = permanent skill). The promotion ladder is the addition, from Voyager/AWM.
248
+ - **Not adopted (deferred):** graph engines and community detection (frontmatter `links` + one-hop traversal suffice inside a scoped silo); Zep-style episodic subgraph summarization (the observational reflector already does hierarchical episodic summarization with provenance — adopting it would duplicate a shipped layer).
249
+
250
+ ## Non-goals / deferred
251
+
252
+ - No vector database, graph database, or external memory provider in the core design — embeddings only where lexical retrieval measurably fails (inside a scoped silo it mostly will not).
253
+ - No second write path — the scoped policy writes only through the fabric, so folding, consent, and lineage rules apply to every write.
254
+ - No cross-scope federation or sharing; add only when a multi-workspace pattern measurably needs it.
255
+ - No autonomous deletion of human-authored records; GC proposes, humans dispose.
256
+
257
+ ## Open questions
258
+
259
+ - Exact promotion thresholds (N reuses, decay τ, similarity floors) — must be empirically tuned per workload class (coding vs. research vs. professional ops).
260
+ - Whether the global user-profile layer reuses the working-memory store or a separate facts silo.
261
+ - Evaluation harness: reuse the existing evaluations/trajectory tooling vs. a purpose-built memory replay suite.
262
+ - Team-scope semantics: per-user silos sharing one workspace root, or one shared silo with author-attributed records.
@@ -25,8 +25,7 @@ Use this helper when implementing a DB-backed `SessionStore` (for example, the r
25
25
  - session ids remain isolated (`assertSessionStoreConforms` always probes a secondary session)
26
26
  - optional concurrent fork children of the same parent succeed when `exerciseConcurrentParentAppend: true`
27
27
  - optional durable reopen/idempotency survival when `runSessionStoreConformance(..., { exerciseReopen: true })`
28
- - optional `searchSessions` bounds/ownership/empty-page checks when `exerciseSearchSessions: true` (`assertSessionStoreSearchSessions`)
29
- - optional `searchSessions` bounds/ownership/empty-page checks when `exerciseSearchSessions: true` (`assertSessionStoreSearchSessions`)
28
+ - optional `searchSessions` case when `exerciseSearchSessions: true` (`assertSessionStoreSearchSessions`): invalid limit/query/kind rejection, empty-page bounds, limit cap, a written-message round-trip asserting `entryId`/`runId`/`turn`/`snippet` point at the match, one hit per session when a second entry also matches, the `kind` filter excluding non-matching entries, and ownership bounds
30
29
 
31
30
  ## Inputs / request
32
31
 
@@ -24,7 +24,7 @@ import type { SessionStore, SessionEntry } from "@arnilo/prism";
24
24
  | `list(sessionId)` | Return all entries for one session in stored order. Development fallback for branch reads. |
25
25
  | `get?(id)` | Return one entry by id, if present. Optional. |
26
26
  | `readBranchPath?(query)` | Optional DB-friendly branch read. Return one branch's ancestor chain as a `PersistencePage<SessionEntry>` so the runtime can avoid `list(sessionId)`. |
27
- | `searchSessions?(query)` | Optional bounded session search (`SessionSearchQuery` → `PersistencePage<SessionSearchHit>`). SQLite/Postgres implement FTS + metadata filters; memory default is linear; JSONL throws `SessionSearchUnsupportedError`. |
27
+ | `searchSessions?(query)` | Optional bounded session search (`SessionSearchQuery` → `PersistencePage<SessionSearchHit>`). SQLite/Postgres implement FTS + metadata filters; memory and JSONL scan linearly (JSONL re-reads its file per query). |
28
28
 
29
29
  Public helpers:
30
30
 
@@ -110,9 +110,13 @@ Recognize it with `isSessionAppendConflict(error)`, not message text. Built-in s
110
110
  - Branch semantics are parent links plus a leaf id. External UIs should keep branch handles as `(sessionId, leafId)`; RPC exposes an additional `handleId` for active handles.
111
111
  - Development stores can omit `readBranchPath`; the runtime falls back to `list(sessionId)` and the pure in-memory branch walk. Database-backed stores should implement `readBranchPath` so `entries()`, `clone()`, and context rebuild read only the selected ancestor chain.
112
112
 
113
- ## Session search (0.0.11)
113
+ ## Session search
114
114
 
115
- Bounded `SessionIndex` / `searchSessions` lists sessions by optional `workspaceRoot` (`metadata.workspaceRoot`), provider/model, label/summary, time range, ownership, and optional text `query` (FTS on SQLite/Postgres; case-sensitive substring on memory linear). Hits require `sessionId` and may include `leafId` for `checkout`; never credentials or whole transcripts.
115
+ Bounded `SessionIndex` / `searchSessions` lists sessions by optional `workspaceRoot` (`metadata.workspaceRoot`), provider/model, label/summary, time range, ownership, and optional text `query`. SQLite/Postgres run indexed full-text search; the memory and JSONL stores scan linearly (case-sensitive substring, capped by the contract linear caps; JSONL re-reads and parses its file per query, see [Node JSONL session store](node-jsonl-session-store.md)). Hits require `sessionId` and may include `leafId` for `checkout`; never credentials or whole transcripts.
116
+
117
+ When a text `query` matches, the hit points at one matched entry per session (the store's best-ranked match on the indexed SQLite/Postgres paths, the first match in transcript order on the linear memory/JSONL paths): `entryId`, `runId`, and a 1-based `turn` (transcript position, `(timestamp, id)` order) locate it, `score` is the store relevance where the store has an index (higher is better; SQLite bm25 negated, Postgres `ts_rank_cd`; absent on linear stores; a non-discriminative term can legitimately score 0, so test for presence, not `> 0`), and `snippet` is bounded context around the match in that entry. Hits stay ordered by session `updatedAt` with cursor pagination, so hosts rank by `score` client-side when they want relevance order.
118
+
119
+ `SessionSearchQuery.kind` restricts which entry kinds the query may match (one kind or a list; omitted or `"any"` = all). Annotation search is `kind: ["label", "summary", "metadata", "custom"]`; `kind: "label"` without a `query` lists sessions that carry an annotation entry. Unknown kinds fail closed with `TypeError`. Indexed text is transcript message text plus label/summary - tool arguments and tool results are never indexed, so they cannot leak through search.
116
120
 
117
121
  ```ts
118
122
  import { createMemorySessionStore, resolveSessionSearchQuery } from "@arnilo/prism";
@@ -121,32 +125,28 @@ const store = createMemorySessionStore([], { sessionSearchMode: "linear" });
121
125
  const page = await store.searchSessions!({
122
126
  workspaceRoot: "/repo",
123
127
  query: "flake",
128
+ kind: "any",
124
129
  limit: 20,
125
130
  });
131
+ // [{ sessionId, leafId, entryId, runId, turn, score, snippet, ... }] // score is absent on linear stores
126
132
  // Opt out: createMemorySessionStore([], { sessionSearchMode: "unsupported" })
127
133
  // Raise the in-process scan caps for a small but large-query session set (defaults are the contract caps):
128
134
  const wide = createMemorySessionStore([], { search: { maxLinearSessions: 5_000, maxLinearEntries: 50_000 } });
129
135
  ```
130
136
 
131
- Finite caps (defaults / hard): page 20/100; query string 4 KiB/16 KiB; snippet 512 B/4 KiB; cursor 1 KiB/4 KiB; memory linear sessions 1000/5000, entries 10000/50000, bytes 8 MiB/64 MiB; DB FTS candidates 1000/5000. Overflow fails closed via `resolveSessionSearchQuery`. See [Phase 6 evidence](_evidence/review-coverage-2026-07-22-phase-6.md).
132
-
133
- ## Session search (0.0.11)
134
-
135
- Bounded `SessionIndex` / `searchSessions` lists sessions by optional `workspaceRoot` (`metadata.workspaceRoot`), provider/model, label/summary, time range, ownership, and optional text `query` (FTS on SQLite/Postgres; case-sensitive substring on memory linear). Hits require `sessionId` and may include `leafId` for `checkout`; never credentials or whole transcripts.
137
+ The JSONL store exposes the same `searchSessions` contract through the same matcher, with no index:
136
138
 
137
139
  ```ts
138
- import { createMemorySessionStore, resolveSessionSearchQuery } from "@arnilo/prism";
140
+ import { createJsonlSessionStore } from "@arnilo/prism/node/session-store-jsonl";
139
141
 
140
- const store = createMemorySessionStore([], { sessionSearchMode: "linear" });
141
- const page = await store.searchSessions!({
142
- workspaceRoot: "/repo",
143
- query: "flake",
144
- limit: 20,
145
- });
146
- // Opt out: createMemorySessionStore([], { sessionSearchMode: "unsupported" })
142
+ const store = createJsonlSessionStore("./sessions.jsonl");
143
+ const page = await store.searchSessions!({ workspaceRoot: "/repo", query: "flake", limit: 20 });
144
+ // Every query reads and parses the file: O(corpus) time and memory, caps default to the linear caps.
147
145
  ```
148
146
 
149
- Finite caps (defaults / hard): page 20/100; query string 4 KiB/16 KiB; snippet 512 B/4 KiB; cursor 1 KiB/4 KiB; memory linear sessions 1000/5000, entries 10000/50000, bytes 8 MiB/64 MiB; DB FTS candidates 1000/5000. Overflow fails closed via `resolveSessionSearchQuery`. See [Phase 6 evidence](_evidence/review-coverage-2026-07-22-phase-6.md).
147
+ Finite caps (defaults / hard): page 20/100; query string 4 KiB/16 KiB; snippet 512 B/4 KiB; cursor 1 KiB/4 KiB; memory linear sessions 1000/5000, entries 10000/50000, bytes 8 MiB/64 MiB (also the JSONL scan caps); DB FTS candidates 1000/5000. Overflow fails closed via `resolveSessionSearchQuery`.
148
+
149
+ Sizing (plan 095): SQLite FTS5 and the Postgres `tsvector` column are maintained additively at append time (no background job). On the 100k-turn fixture in `scripts/benchmark-scenarios/session-search.mjs` (stored tool output, which is never indexed), the index is 18.8% of transcript page bytes and query p95 is 38 ms (`node scripts/benchmark.mjs --scenario session-search`; ceiling 100 ms). Stores receive already-redacted entries, so the index inherits the same redaction as session reads. Unindexed stores (memory, JSONL) trade that cost for O(corpus) per query — see `examples/session-search.ts` for both paths side by side.
150
150
 
151
151
  ## Security and performance notes
152
152
 
@@ -10,29 +10,49 @@ Use a supervisor when a host or agent must choose a child dynamically. Use `@arn
10
10
 
11
11
  ## Inputs / request
12
12
 
13
- **Option surfaces** — `CreateSupervisorOptions` (ownership, child catalog, hooks, `childEvents`, limits), `SupervisorLimits` / `ResolvedSupervisorLimits` (depth, active children, child events, bytes), `DelegationWaitOptions` (`timeoutMs`, `signal`), `CreateSpawnAgentToolOptions` / `CreateDelegationControlToolOptions` (supervisor, tool name, sync/async mode), `WorktreeChildFactoryOptions` (workspace lifecycle, repository, roots), and `ObserveSupervisorLifecycleOptions` (supervisor, emit, redactor, steps).
13
+ **Option surfaces** — `CreateSupervisorOptions` (ownership, child catalog, hooks, `childEvents`, `childEventSink`, `signal`, limits), `SupervisorLimits` / `ResolvedSupervisorLimits` (depth, active children, child events, bytes, per-second rate), `SupervisorChildPolicy` (lifetime, report, milestone, budget share), `DelegationRequest` (child, input, thread, limits, lifetime, report, milestone, budget share, signal), `DelegationWaitOptions` (`timeoutMs`, `signal`), `SupervisorRunSummary` / `SupervisorChildSummary` (recovery counters), `CreateSpawnAgentToolOptions` / `CreateDelegationControlToolOptions` (supervisor, tool name, sync/async mode), `WorktreeChildFactoryOptions` (workspace lifecycle, repository, roots), and `ObserveSupervisorLifecycleOptions` (supervisor, emit, redactor, steps).
14
14
 
15
15
  | API/field | Meaning |
16
16
  | --- | --- |
17
- | `createSupervisor({ ownership, children })` | Creates one ownership-scoped supervisor. |
18
- | `SupervisorChild.createAgent(context)` | Child-owned factory; receives derived resource/thread IDs, narrowed permission, abort signal, and nested `delegate`. |
19
- | `delegate({ childId, input, threadId?, limits?, signal? })` | Invokes one allow-listed child. Input is text and byte-bounded. |
20
- | `delegateAsync({ childId, input, threadId?, limits?, signal? })` | Starts one local child and returns `{ delegationId, status: "running" }` without waiting for its result. |
21
- | `wait(delegationId)` / `cancel(delegationId)` | Joins one local async child (capped at supervisor timeout) or aborts it. Unknown and foreign IDs share one denial. |
22
- | `createSpawnAgentTool({ supervisor, name? })` | Returns non-exclusive `spawn_agent` tool for a parent model. Its closed schema exposes only host child IDs, input, optional thread ID, and `mode`. |
17
+ | `createSupervisor({ ownership, children, signal? })` | Creates one ownership-scoped supervisor. Aborting `signal` (session end) ends every running child and closes the event stream. |
18
+ | `SupervisorChild.createAgent(context)` / `policy` | Child-owned factory; receives derived resource/thread IDs, narrowed permission, abort signal, and nested `delegate`. `policy` carries host ceilings/defaults for lifetime, report, milestone cadence, and budget share. |
19
+ | `delegate({ childId, input, threadId?, limits?, lifetime?, report?, milestone?, budgetShare?, signal? })` | Invokes one allow-listed child. Input is text and byte-bounded. Sync `delegate()` rejects `lifetime: "session"`. |
20
+ | `delegateAsync({ ... })` | Starts one local child and returns `{ delegationId, status: "running" }`. `lifetime: "session"` detaches the child from the caller signal so it survives caller turns. |
21
+ | `wait(delegationId)` / `cancel(delegationId)` | Joins one local async child (capped at supervisor timeout) or aborts it, session-lifetime included. Unknown and foreign IDs share one denial. |
22
+ | `createSpawnAgentTool({ supervisor, name? })` | Returns non-exclusive `spawn_agent` tool for a parent model. Its closed schema exposes only host child IDs, input, optional thread ID, `mode`, `lifetime`, `report`, `milestone.everyTurns`, and `budgetShare`. |
23
23
  | `createWaitAgentTool` / `createCancelAgentTool` | Return `wait_agent` / `cancel_agent` tools for host-owned async handles. |
24
24
  | `Supervisor.childIds` | Frozen advertised child-id list the spawn tool's schema enum is built from; model arguments cannot extend it. |
25
25
  | `hooks.before` | May reject, modify redacted input, or narrow limits/policy. |
26
26
  | `hooks.after` | Observes redacted terminal summary; failures cannot alter settled result. |
27
- | `limits` | Depth 4/16, active children 4/32, input 64 KiB/1 MiB, steps 8/64, tools 32/256, tokens 20k/1m, timeout 60s/30m, event queue 128/4096, child events/delegation 256/4096, child-event bytes 32 KiB/256 KiB default/hard. Over-cap `delegate()` throws `SupervisorLimitError` before incrementing `activeChildren`. Hook rejection and timeout decrement the count exactly once (no leaked timers). |
27
+ | `limits` | Depth 4/16, active children 4/32, input 64 KiB/1 MiB, steps 8/64, tools 32/256, tokens 20k/1m, timeout 60s/30m, event queue 128/4096, child events/delegation 256/4096, child-event bytes 32 KiB/256 KiB, child events/second 10/1000 default/hard. Over-cap `delegate()` throws `SupervisorLimitError` before incrementing `activeChildren`. Hook rejection and timeout decrement the count exactly once (no leaked timers). |
28
28
 
29
29
  ## Outputs / response / events
30
30
 
31
- `delegate()` returns the child's `AgentRunResult` or throws its `AgentRunError`/a supervisor denial or limit error. `delegateAsync()` returns a local running handle; `wait()` returns its result (or `{ status: "cancelled" }` after `cancel()`), and stays idempotent while its terminal record is retained (bounded by `limits.maxQueuedEvents`; an evicted or foreign id returns the same non-enumerating error). `subscribe()` emits bounded `delegation_started`, `delegation_finished`, `delegation_rejected`, and `delegation_error` metadata events. Graceful close drains already-queued terminal events before the iterator completes (same core multiplexer contract). Hosts may project those events through observability `handleDelegation()` using the parent Prism run ID; no OpenTelemetry dependency enters this package.
31
+ `delegate()` returns the child's `AgentRunResult` or throws its `AgentRunError`/a supervisor denial or limit error. A failure (an error or a run-limit death) publishes `child_failed` before the terminal `delegation_error`: the redacted `reason`, the terminal `status` and `stopReason`, the plan-086/087 `RunLimitBreach` (`limit`, `maximum`, `observed`) in `limit` when a configured ceiling fired, and terminal `usage`. Host cancels, policy denials, and hook rejections are not failures and never emit it. `delegateAsync()` returns a local running handle; `wait()` returns its result (or `{ status: "cancelled" }` after `cancel()`), and stays idempotent while its terminal record is retained (bounded by `limits.maxQueuedEvents`; an evicted or foreign id returns the same non-enumerating error). `subscribe()` emits bounded `delegation_started`, `delegation_finished`, `delegation_rejected`, and `delegation_error` metadata events, plus the opt-in child-event family below. Aborting `CreateSupervisorOptions.signal` aborts every running child (session- and task-lifetime) and closes the stream. Hosts routing child events onto a parent session stream pass `childEventSink`; it receives the identical payload the supervisor stream carries — a redacted, capped, rate-coalesced `AgentEvent` tagged with `child: { childId, delegationId, depth }` (contract type `ChildEventOrigin`) — so a parent subscriber can route it with `event.child` and no per-type handling. Hosts may project the lifecycle events through observability `handleDelegation()` using the parent Prism run ID; no OpenTelemetry dependency enters this package.
32
+
33
+ ### Child lifetime, reporting, and budget share
34
+
35
+ Every `SupervisorChild` may carry a `policy` of host ceilings/defaults; a `DelegationRequest` may only narrow them (report is clamped to the ceiling, `everyTurns` can only be raised, budget share takes the lower value, and session lifetime is denied unless the host enabled it). Defaults are exactly the 0.8 behavior: `lifetime: "task"`, `report: "on-complete"`, no milestone, no share.
36
+
37
+ - **Lifetime.** `task` children stay linked to the caller signal. `session` children must be started with `delegateAsync` (`spawn_agent` routes them to it automatically): they detach from the caller and ancestor-child signals, keep running across parent turns, hold an `activeChildren` slot until they end, and stop on `cancel(delegationId)` or when `CreateSupervisorOptions.signal` aborts.
38
+ - **Report.** `on-complete` publishes nothing per turn (the default). `milestones` publishes `child_milestone` (`turn`, redacted `childEvent`) when `milestone.everyTurns` divides the child turn or a host `milestone.predicate` matches; with neither configured it reports the milestone event subset. `stream` publishes every per-turn provider/tool/turn event as `delegation_child_event` — never per-token `message_delta` or full `message_*` payloads; the supervisor-wide `childEvents: true` does the same for children without their own policy.
39
+ - **Budget share.** `budgetShare` (0, 1] scales the inherited `maxSteps`/`maxToolCalls`/`maxTokens`/`timeoutMs` before `narrowSupervisorLimits` clamps them, so a child can never exceed its parent or host limits. It is a fraction of the *inherited supervisor limits*, not of live parent-run usage (the tool boundary carries no parent budget snapshot).
40
+ - **Caps and rate.** Projected child events pass the supervisor `redactor` first, are capped by `limits.maxChildEventsPerDelegation` and `limits.maxChildEventBytes`, and are rate-coalesced to `limits.maxChildEventsPerSecond` (default 10/s per child, floor 1/s window). A capped child publishes one `delegation_child_events_capped`; coalescing publishes `delegation_child_events_coalesced` with the dropped count when a window closes or the pump stops, so gaps are never silent. Size the rate to the host event loop: 10/s per child is trivial for a UI; raise it only for a child whose tool events are the UI.
32
41
 
33
42
  ### Child event passthrough (opt-in)
34
43
 
35
- `createSupervisor({ childEvents: true })` projects a redacted, size-capped **milestone** subset of child `AgentEvent`s onto the same stream as `delegation_child_event` (tagged `childId`, `delegationId`, `depth`). v1 covers run start/finish/`suspended`/`denied` and tool-execution started/finished/error/blocked — not per-token `message_delta`. Default off: the stream is byte-identical to today (no subscribe, no allocation). Caps: `limits.maxChildEventsPerDelegation` (256/4096) and `limits.maxChildEventBytes` (32 KiB/256 KiB); exceeding either drops further child events and emits one `delegation_child_events_capped` marker (never throws). Events pass through the supervisor `redactor` before emission. Children never receive supervisor internals or store/subscription access. Resume-path rebuilds (`resumeNestedRun`) attach the same pump to the rebuilt child session, so a delegation that suspended for approval keeps projecting milestones after the root run resumes; counters restart per pump, so each attempt gets the full cap.
44
+ `createSupervisor({ childEvents: true })` raises the default report ceiling to `stream` for children without their own `policy.report`: it projects redacted, size-capped child `AgentEvent`s onto the same stream as `delegation_child_event` (tagged `childId`, `delegationId`, `depth`). Covered: run start/finish/`suspended`/`denied`, tool-execution started/finished/error/blocked, and turn/provider-turn started/finished — not per-token `message_delta` or full `message_*` payloads. Default off: the stream is byte-identical to today (no subscribe, no allocation). Per-child `policy.report` is authoritative; a model request can only lower it. Caps: `limits.maxChildEventsPerDelegation` (256/4096), `limits.maxChildEventBytes` (32 KiB/256 KiB), and `limits.maxChildEventsPerSecond` (10/1000); exceeding count/bytes drops further child events and emits one `delegation_child_events_capped` marker, exceeding the rate coalesces into `delegation_child_events_coalesced` (never throws). Events pass through the supervisor `redactor` before emission. Children never receive supervisor internals or store/subscription access. Resume-path rebuilds (`resumeNestedRun`) replay the persisted report/every-turns/share policy onto the rebuilt child session, so a delegation that suspended for approval keeps projecting milestones after the root run resumes; counters restart per pump, so each attempt gets the full cap.
45
+
46
+ ### Recovery telemetry
47
+
48
+ `summary()` returns one frozen row per allow-listed child — `{ childId, attempts, retries, failures, failureRadius, outcome }` — maintained incrementally (O(1) per delegation, O(children) to read) and cumulative for the supervisor's lifetime, so a host can diff snapshots per root run or watch a long-lived supervisor without host-side aggregation.
49
+
50
+ - `outcome` is `idle` before the first delegation, `running` while any is live, otherwise the `delegation_finished.status` vocabulary (`succeeded`/`failed`/`aborted`/`suspended`/`denied`) or `rejected` for a hook denial. Resuming a suspended run updates the outcome but is not a new attempt.
51
+ - `attempts` counts started delegations, hook rejections included. `retries` counts attempts started after a `failed`/`aborted` outcome — the recovery re-dispatch metric.
52
+ - `failures` counts delegations that died on an error or a limit; host cancels, denials, and hook rejections are excluded.
53
+ - `failureRadius` is the blast radius of the child's most recent failure: task-lifetime descendant delegations still live at that moment. An unrelated or already-finished child is not counted, and a session-lifetime child is detached from the failed subtree by design.
54
+
55
+ Failure attribution is the same object the `child_failed` event carries, so a host that only keeps the summary and one that only keeps events read the same taxonomy.
36
56
 
37
57
  ## Request/response example
38
58
 
@@ -63,7 +83,7 @@ const result = await supervisor.delegate({ childId: "research", input: "Check so
63
83
 
64
84
  ## Model-facing spawn tool
65
85
 
66
- `createSpawnAgentTool({ supervisor })` turns the same host-owned child allow-list into non-exclusive `spawn_agent` tool calls, so independent calls use the parent session's `toolConcurrency`. The schema has only `childId`, `input`, optional `threadId`, and `mode: "sync" | "async"` (default `sync`); unknown children fail closed as standard tool errors before delegation. Model arguments cannot supply child tools, identity, scopes, or higher limits. Async returns only a local `{ delegationId, status: "running" }` handle. Install `wait_agent` once per handle for wait-all, or `cancel_agent` to abort it; cancellation is terminally reported by `wait_agent`. Parent-run abort propagates to running children. Handles are in-process, ownership-scoped, and bounded — they do not survive host restart.
86
+ `createSpawnAgentTool({ supervisor })` turns the same host-owned child allow-list into non-exclusive `spawn_agent` tool calls, so independent calls use the parent session's `toolConcurrency`. The schema has only `childId`, `input`, optional `threadId`, `mode: "sync" | "async"` (default `sync`), `lifetime: "task" | "session"`, `report`, `milestone.everyTurns`, and `budgetShare`; unknown children and malformed policy args fail closed as standard tool errors before delegation. Model arguments cannot supply child tools, identity, scopes, predicate functions, or higher limits. Async returns only a local `{ delegationId, status: "running" }` handle; a `lifetime: "session"` spawn is always async. Install `wait_agent` once per handle for wait-all, or `cancel_agent` to abort it (also the explicit end for a session-lifetime child); cancellation is terminally reported by `wait_agent`. Parent-run abort propagates to running task-lifetime children only. Handles are in-process, ownership-scoped, and bounded — they do not survive host restart.
67
87
 
68
88
  ```ts
69
89
  import { createAgent } from "@arnilo/prism";
@@ -109,7 +129,7 @@ Supervisors propagate parent `identity` and `effectStore` to every child agent/r
109
129
  - [A2A interoperability](a2a.md): separate remote protocol boundary. `A2ATaskLifecycle` adapts host durable agent/workflow state directly; it does not route A2A execution through local supervisor child planning.
110
130
  - [Workflows](workflows.md): preferred deterministic orchestration.
111
131
  - [Coding workspaces](coding-workspaces.md): opt-in per-child worktree isolation via `createWorktreeChildFactory`.
112
- - [Coding agent tools](coding-agent-tools.md): opt-in `observeSupervisorLifecycle` bridges supervisor `delegation_*` events to coding `subagent_started` / `subagent_stopped` for host timelines.
132
+ - [Coding agent tools](coding-agent-tools.md): opt-in `observeSupervisorLifecycle` bridges supervisor `delegation_*` events to coding `subagent_started` / `subagent_stopped` for host timelines; `supervisor.summary()` covers recovery counters (`retries`, `failures`, `failureRadius`) that the lifecycle bridge does not carry.
113
133
  - Examples: [`examples/autonomous-coding-loop.ts`](../examples/autonomous-coding-loop.ts) — per-child models, factory returns `Agent`; [`examples/spawn-agent-tool.ts`](../examples/spawn-agent-tool.ts) — two model-requested explore children in one tool turn.
114
134
  - [Working and semantic memory](working-and-semantic-memory.md): child scope construction.
115
135
  - [Host security](host-security.md): permission and credential boundaries.
package/docs/tools.md CHANGED
@@ -159,6 +159,23 @@ await session.run(input, { toolNames: ["web_search"] });
159
159
 
160
160
  Scope the active `ToolRegistry` (or declarative `AgentDefinition.tools`) at agent construction. `PermissionPolicy` / `RunOptions.validate` still fail closed at dispatch; `toolNames` only intersects that host-active set.
161
161
 
162
+ ### Per-turn tool narrowing
163
+
164
+ `toolNarrowing` on `AgentConfig` / `RunOptions` (run wins) is an optional host callback invoked at `loopCtx.assemble` before each provider turn. It receives `{ turn, lastAssistantText?, toolIds }` and must return a subset of the run grant (`toolIds`). Unknown or extra names are dropped (restrictive-only); the runtime emits `tool_narrowing_clamped` with the dropped names and continues with the clamped set. A throw fails the turn — no partial schema is sent.
165
+
166
+ This is not a middleware hook and not `RunOptions.tools`. `filterTools` on the run snapshot preserves run order, so identical consecutive subsets keep schema bytes identical (prompt-cache prefixes stay stable). Changing the subset rewrites tool schemas; pair with `toolsDisclosure: "search"` when the run set is large.
167
+
168
+ Tools hidden this turn are not in the provider schema. They stay callable-by-name only when `allowHiddenToolCalls: true` (default off); otherwise dispatch blocks them with `tool_denied`. Each provider turn records `metadata.tools: { count, idsHash }` (hash of names in request order; no args) — see [Agent events](agent-events.md).
169
+
170
+ ```ts
171
+ const agent = createAgent({
172
+ model, provider, tools,
173
+ toolNarrowing: async ({ turn, lastAssistantText, toolIds }) =>
174
+ plane === "knowledge" ? toolIds.filter((id) => id.startsWith("wiki.")) : toolIds,
175
+ });
176
+ await session.run(input, { toolNarrowing: async ({ toolIds }) => toolIds.slice(0, 4) });
177
+ ```
178
+
162
179
  ### Artifact-loop tools
163
180
 
164
181
  `generate-validate-revise` treats provider tools as inert by default. Set `loop.toolCalls: "bounded"` and `RunOptions.limits.maxToolRounds` only when an artifact needs a host-owned lookup before its next candidate. Each response with one-or-more calls consumes one shared round, dispatches calls sequentially through this exact `dispatchToolCall()` path, persists assistant-call then result transcript rows, and skips artifact parsing/validation for that response. A post-limit call executes nothing; the loop emits `artifact_failed` with `metadata.reason: "tool_round_limit"`. Tools do not consume `maxRevisions`, and tool schemas/context never grant authority.
package/docs/workflows.md CHANGED
@@ -88,11 +88,16 @@ All workflow limits and runtime `concurrency` reject non-safe integers, zero, ne
88
88
  | `runId` | Caller-supplied id; otherwise generated (`wfr_…`) |
89
89
  | `resume` | For suspended runs: `{ decision: "approve" | "deny", input?, expectedVersion }`; version is mandatory for an exact-once CAS claim |
90
90
  | `validateResume` | Host validator for resume input; required when `suspend()` declares `resumeSchema` |
91
+ | `metadata` | Sidecar map (`Record<string, unknown>`) persisted on the checkpoint value; a resume that does not re-state it keeps the recorded map |
92
+ | `restoreHooks` | External-state restore hooks (`CheckpointRestoreHook`) run sequentially on every resume before the scheduler writes; the first failure/timeout throws `CheckpointRestoreError` (`ERR_PRISM_CHECKPOINT_RESTORE`) and leaves the checkpoint untouched |
93
+ | `restoreHookTimeoutMs` | Per-hook restore ceiling in ms; defaults to `DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS` (10 s) |
91
94
  | `validateState` | Host validator for every initial/restored/updated state; required when workflow declares `state.schema` |
92
95
  | `initialState` | Optional host initial state override; nested workflows receive parent state automatically |
93
96
 
94
97
  A function node returns `suspend({ reason, data?, resumeSchema? })` to persist `status: "suspended"`. Its next invocation receives `ctx.resume` only after an approved resume. `resumeWorkflow(workflow, { runId }, options)` validates schema/version/ownership/`definitionHash`, claims the checkpoint before node execution, and continues the suspended node. Denial persists terminal `denied` status without invoking it. Existing failed/aborted checkpoint resume remains available without a human decision.
95
98
 
99
+ Restore hooks make the resume all-or-nothing across layers: workflow checkpoints carry the host's `metadata` (git commit, document version, workspace fingerprint), `restoreHooks` put each recorded layer back, and only when every hook succeeds does the scheduler claim the checkpoint and continue. Each hook receives `{ workflowId, runId, version, status, metadata, checkpoint }` and an `AbortSignal`; the successful run's `workflow_resumed` event carries `restore: { hooks: [{ hook, durationMs }], durationMs }`. No hooks ⇒ no hook call and no `restore` field.
100
+
96
101
  > **Contract — resume-aware nodes.** After an approved resume, the **same** node's `execute` is re-invoked with `ctx.resume`. Returning `suspend(...)` unconditionally re-suspends silently; downstream nodes never run. Branch on `ctx.resume`:
97
102
  >
98
103
  > ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent harness for AI providers, agents, sessions, and tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,6 +50,10 @@
50
50
  "types": "./dist/testing/compaction-conformance.d.ts",
51
51
  "default": "./dist/testing/compaction-conformance.js"
52
52
  },
53
+ "./testing/prefix-stability-conformance": {
54
+ "types": "./dist/testing/prefix-stability-conformance.d.ts",
55
+ "default": "./dist/testing/prefix-stability-conformance.js"
56
+ },
53
57
  "./testing/tool-conformance": {
54
58
  "types": "./dist/testing/tool-conformance.d.ts",
55
59
  "default": "./dist/testing/tool-conformance.js"