@arnilo/prism 0.7.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 (182) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -11
  3. package/dist/agent-approval.d.ts +15 -2
  4. package/dist/agent-approval.js +5 -1
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +91 -10
  10. package/dist/agent-run-state.d.ts +34 -2
  11. package/dist/agent-run-state.js +68 -6
  12. package/dist/agent-session/helpers.js +20 -1
  13. package/dist/agent-session/session/assemble.js +250 -27
  14. package/dist/agent-session/session/persist.d.ts +27 -0
  15. package/dist/agent-session/session/persist.js +94 -12
  16. package/dist/agent-session/session/provider-round.d.ts +14 -4
  17. package/dist/agent-session/session/provider-round.js +197 -25
  18. package/dist/agent-session/session/tool-round.js +24 -2
  19. package/dist/agent-session/session/types.d.ts +36 -2
  20. package/dist/agent-session/session.d.ts +40 -4
  21. package/dist/agent-session/session.js +78 -5
  22. package/dist/attention-compiler.d.ts +51 -2
  23. package/dist/attention-compiler.js +282 -21
  24. package/dist/cache-helpers.d.ts +4 -2
  25. package/dist/cache-helpers.js +8 -6
  26. package/dist/checkpoint-restore.d.ts +45 -0
  27. package/dist/checkpoint-restore.js +54 -0
  28. package/dist/checkpoints.js +7 -11
  29. package/dist/context-budget.d.ts +2 -1
  30. package/dist/context-budget.js +24 -2
  31. package/dist/contracts-core/agent.d.ts +30 -0
  32. package/dist/contracts-core/attention.d.ts +95 -0
  33. package/dist/contracts-core/content.d.ts +15 -0
  34. package/dist/contracts-core/guardrail-packs.d.ts +41 -0
  35. package/dist/contracts-core/guardrail-packs.js +2 -0
  36. package/dist/contracts-core/loop.d.ts +42 -0
  37. package/dist/contracts-core/provider.d.ts +25 -0
  38. package/dist/contracts-core/run-limits.d.ts +21 -0
  39. package/dist/contracts-core/session.d.ts +23 -5
  40. package/dist/contracts-core/session.js +21 -2
  41. package/dist/contracts-core/usage.d.ts +40 -0
  42. package/dist/contracts-core/usage.js +8 -0
  43. package/dist/contracts-core.d.ts +2 -0
  44. package/dist/contracts-core.js +2 -0
  45. package/dist/contracts-protocol.d.ts +90 -4
  46. package/dist/contracts-run-state.d.ts +82 -6
  47. package/dist/evidence-grounding.d.ts +29 -0
  48. package/dist/evidence-grounding.js +162 -0
  49. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  50. package/dist/guardrail-packs/coding-standard.js +63 -0
  51. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  52. package/dist/guardrail-packs/destructive-commands.js +46 -0
  53. package/dist/guardrail-packs/errors.d.ts +7 -0
  54. package/dist/guardrail-packs/errors.js +9 -0
  55. package/dist/guardrail-packs/index.d.ts +4 -0
  56. package/dist/guardrail-packs/index.js +15 -0
  57. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  58. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  59. package/dist/guardrail-packs/types.d.ts +16 -0
  60. package/dist/guardrail-packs/types.js +2 -0
  61. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  62. package/dist/guardrail-packs/validation-respect.js +53 -0
  63. package/dist/guardrails.d.ts +20 -1
  64. package/dist/guardrails.js +268 -0
  65. package/dist/host-composition.d.ts +13 -0
  66. package/dist/host-composition.js +33 -2
  67. package/dist/index.d.ts +19 -10
  68. package/dist/index.js +11 -6
  69. package/dist/input.d.ts +8 -1
  70. package/dist/input.js +68 -6
  71. package/dist/middleware.d.ts +37 -2
  72. package/dist/middleware.js +41 -0
  73. package/dist/node/session-store-jsonl.js +18 -3
  74. package/dist/observability.js +6 -0
  75. package/dist/provider-events.d.ts +11 -3
  76. package/dist/provider-events.js +62 -4
  77. package/dist/providers/openai-compatible.js +6 -3
  78. package/dist/providers/transport.d.ts +3 -1
  79. package/dist/providers/transport.js +36 -0
  80. package/dist/redaction.js +18 -2
  81. package/dist/run-bundle.d.ts +89 -0
  82. package/dist/run-bundle.js +150 -0
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +46 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +30 -0
  89. package/dist/testing/prefix-stability-conformance.js +104 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/testing/state-concurrency-conformance.js +5 -12
  93. package/dist/tools.d.ts +5 -0
  94. package/dist/tools.js +11 -3
  95. package/dist/usage-estimation.d.ts +29 -0
  96. package/dist/usage-estimation.js +79 -0
  97. package/docs/ag-ui.md +5 -0
  98. package/docs/agent-events.md +68 -1
  99. package/docs/agent-loops.md +33 -0
  100. package/docs/agent-session-runtime.md +5 -3
  101. package/docs/attention-compiler.md +89 -8
  102. package/docs/coding-agent-tools.md +1 -1
  103. package/docs/coding-security.md +1 -0
  104. package/docs/coding-tools.md +0 -1
  105. package/docs/compaction-and-retry.md +1 -1
  106. package/docs/compaction-observational-memory.md +34 -7
  107. package/docs/connected-apps.md +116 -0
  108. package/docs/context-and-skills.md +13 -0
  109. package/docs/core.md +1 -1
  110. package/docs/diagrams.md +6 -6
  111. package/docs/document-reader.md +9 -9
  112. package/docs/documents.md +32 -11
  113. package/docs/durable-runs.md +129 -0
  114. package/docs/embeddings.md +5 -0
  115. package/docs/enterprise-postgres-state.md +4 -0
  116. package/docs/evaluations.md +5 -0
  117. package/docs/execution-timeline.md +84 -1
  118. package/docs/guardrails.md +71 -2
  119. package/docs/history/079-messaging-primitive-review.md +391 -0
  120. package/docs/history/080-messaging-followon-primitive-review.md +234 -0
  121. package/docs/history/081-connected-apps-primitive-review.md +74 -0
  122. package/docs/history/083-prism-work-primitive-review.md +84 -0
  123. package/docs/history/084-primitive-review.md +96 -0
  124. package/docs/history/085-honesty-and-cut-primitive-review.md +91 -0
  125. package/docs/history/README.md +5 -0
  126. package/docs/history/release-handoffs.md +38 -0
  127. package/docs/host-compositions.md +8 -6
  128. package/docs/host-security.md +2 -2
  129. package/docs/index.md +66 -29
  130. package/docs/input-and-prompt-assembly.md +3 -3
  131. package/docs/knowledge-sync.md +4 -0
  132. package/docs/live-testing.md +5 -3
  133. package/docs/mcp-tools.md +1 -0
  134. package/docs/messaging-channel-operations.md +166 -0
  135. package/docs/messaging-channels.md +150 -0
  136. package/docs/middleware-hooks.md +38 -2
  137. package/docs/migrate-to-0.8.md +124 -0
  138. package/docs/migrate-to-0.9.md +210 -0
  139. package/docs/migration.md +43 -0
  140. package/docs/model-registry.md +12 -2
  141. package/docs/multi-agent-patterns.md +25 -2
  142. package/docs/node-jsonl-session-store.md +7 -1
  143. package/docs/observability.md +7 -3
  144. package/docs/openapi-tools.md +1 -1
  145. package/docs/operations.md +1 -3
  146. package/docs/options-index.md +36 -3
  147. package/docs/peer-dependencies.md +6 -6
  148. package/docs/policy-and-audit.md +13 -1
  149. package/docs/postgres-persistence.md +1 -1
  150. package/docs/prefix-stability-conformance.md +93 -0
  151. package/docs/provider-caching.md +4 -4
  152. package/docs/provider-conformance.md +16 -0
  153. package/docs/provider-layer.md +2 -2
  154. package/docs/provider-packages.md +20 -20
  155. package/docs/providers/neuralwatt.md +5 -1
  156. package/docs/public-contracts.md +2 -2
  157. package/docs/rag.md +102 -4
  158. package/docs/release-and-install.md +55 -47
  159. package/docs/run-bundle.md +92 -0
  160. package/docs/runs-and-usage.md +57 -6
  161. package/docs/scoped-agent-memory.md +262 -0
  162. package/docs/server.md +2 -0
  163. package/docs/session-store-conformance.md +1 -2
  164. package/docs/session-stores.md +17 -17
  165. package/docs/sheets.md +9 -9
  166. package/docs/signal-channel.md +112 -0
  167. package/docs/speech.md +5 -1
  168. package/docs/sqlite-persistence.md +1 -1
  169. package/docs/supervisors.md +32 -12
  170. package/docs/telegram-channel.md +157 -0
  171. package/docs/testing.md +2 -2
  172. package/docs/tools.md +17 -0
  173. package/docs/wiki.md +1 -1
  174. package/docs/work-artifacts-and-review.md +1 -1
  175. package/docs/work-connectors.md +9 -9
  176. package/docs/work-sandbox.md +115 -0
  177. package/docs/work-tools.md +38 -16
  178. package/docs/workflows.md +5 -0
  179. package/package.json +9 -3
  180. package/templates/business-worker/manifest.json +2 -1
  181. package/templates/business-worker/src/agent.ts.tmpl +1 -1
  182. package/templates/business-worker/src/tests/agent.test.ts.tmpl +1 -1
@@ -0,0 +1,92 @@
1
+ # Effective run bundle snapshots
2
+
3
+ `snapshotRunBundle()` answers one question for a host harness: *what exactly ran?* It projects the inputs a
4
+ run resolves to — prompt contributions, skills, tools, guardrails, loop, limits, model, storage kinds — into
5
+ frozen JSON with one stable digest, so a harness registry can pin the bundle it evaluated and diff a later run
6
+ against it. It is the inspectable half of the durable-run fingerprint: same inputs, named fields, one hash.
7
+
8
+ ## What it does
9
+
10
+ - Reads only in-process configuration: `agent` (plus optional `AgentSessionConfig` and `RunOptions` overrides).
11
+ - Returns frozen JSON with `schemaVersion`, a `sha256:` `digest`, and the durable `fingerprint` it corresponds to.
12
+ - Never opens a socket, never reads a store, never resolves a credential, and never emits a store connection
13
+ string — only its kind and durability.
14
+ - Never emits bodies: prompt and skill instructions are digests, tool parameters are digests.
15
+ - Is synchronous, in-memory, and O(contributions): a 100-tool agent snapshots in well under a millisecond.
16
+
17
+ `digest` is SHA-256 over the canonicalized, redacted snapshot (the `digest` field itself excluded), prefixed
18
+ `sha256:`. Tool parameter schemas go through `canonicalizeJsonSchema()` first, so key order and `required`
19
+ ordering cannot fake a change. Identical configuration produces an identical digest; any listed contribution
20
+ change — a tool, a schema, a skill body, a guardrail revision, a limit, `thinkingLevel`, the loop revision, a
21
+ request policy, a store kind — produces a different one.
22
+
23
+ ## When to use it
24
+
25
+ - Pin the bundle in a harness/eval registry next to the run or timeline id, then diff digests across releases.
26
+ - Explain a durable-resume failure: `fingerprint` is the value compare-and-set against stored run state, and
27
+ the snapshot shows *which* field moved.
28
+ - Feed a release manifest or a support bundle: it is JSON, bounded (512 KiB), and secret-free by construction.
29
+
30
+ Do not use it as a substitute for `inspectHostComposition()` (that inspects a whole composition's readiness,
31
+ sandbox isolation, and credential references) or as a policy decision — it is a report, not a guard.
32
+
33
+ ## Inputs
34
+
35
+ | Input | Purpose |
36
+ | --- | --- |
37
+ | `agent` | The agent to inspect; `agent.config` supplies tools, skills, guardrails, prompt, loop, limits, model. |
38
+ | `config` | Optional `AgentSessionConfig`: its `store` wins over `agent.config.store` for the reported session-store kind. |
39
+ | `run` | Optional `RunOptions`: run-level overrides (`limit`s, `thinkingLevel`, `systemPrompt`, `guardrails`, `loop`, `toolNames`, `attentionCompiler`, `providerRequestPolicies`, `runState.definitionRevision`, `effectStore`). |
40
+ | `memory` | Optional memory store instance. Only its kind and durability are read — never its contents. |
41
+
42
+ ## Output
43
+
44
+ ```ts
45
+ const bundle = snapshotRunBundle({ agent, run: { limits: { maxTurns: 12 }, toolNames: ["search"] } });
46
+
47
+ bundle.schemaVersion; // 1
48
+ bundle.digest; // "sha256:1ddd…" — pin this
49
+ bundle.fingerprint; // agentFingerprint() for durable resume
50
+ bundle.agent; // { id, definitionRevision }
51
+ bundle.systemPrompt; // { disabled, instructionsDigest, contributions: [{ id, mode, source, digest }] }
52
+ bundle.skills; // [{ name, instructionsDigest, toolNames }]
53
+ bundle.tools; // [{ name, schemaDigest, exclusive, effect }] — run.toolNames already applied
54
+ bundle.guardrails; // [{ name, stage, revision }]
55
+ bundle.loop; // { strategy, revision }
56
+ bundle.limits; // resolved ResolvedRunLimits
57
+ bundle.model; // { provider, model }
58
+ bundle.storage; // { sessionStore, checkpoints, effectStore, memory } → { kind, durable }
59
+ ```
60
+
61
+ `tools` is the *effective* set: `RunOptions.toolNames` narrowing is applied, and an unknown name fails closed
62
+ exactly as it would during the run. `storage.*.kind` comes from the store's declared `kind` or constructor name
63
+ and is reduced to a plain token (`[a-z0-9_.-]`, ≤64 chars); anything URL-shaped is reported as `custom`, so a
64
+ connection string can never reach a pinned artifact.
65
+
66
+ ## Example
67
+
68
+ ```ts
69
+ import { createAgent, snapshotRunBundle } from "@arnilo/prism";
70
+
71
+ const agent = createAgent({ model: { provider: "anthropic", model: "claude-sonnet-4-5" }, /* … */ });
72
+ const bundle = snapshotRunBundle({ agent, run: { thinkingLevel: "high" } });
73
+
74
+ const pinned = bundle.digest; // store with the harness artifact
75
+ const next = snapshotRunBundle({ agent: changedAgent });
76
+ if (next.digest !== pinned) reportFields(next, pinned); // hosts diff by field, not by digest alone
77
+ ```
78
+
79
+ ## Redaction and limits
80
+
81
+ Every string field is passed through the host `SecretRedactor` (`RunOptions.redactor` ?? `AgentConfig.redactor`)
82
+ before hashing and before returning, so redaction is part of the pinned digest. The snapshot refuses to exceed
83
+ 512 KiB — a bundle that large is a host wiring bug, not something to retain — and throws `TypeError` rather than
84
+ truncating. There is no network path, no store read, and no credential resolution in this function; a store that
85
+ throws on every method still snapshots fine.
86
+
87
+ ## Related APIs
88
+
89
+ - [`agentFingerprint()`](durable-runs.md): the durable-resume identity this snapshot projects.
90
+ - [`inspectHostComposition()`](host-compositions.md): composition readiness, storage durability, sandbox isolation.
91
+ - [`ExecutionTimeline`](execution-timeline.md): what a run *did*; the snapshot is what it was *configured* with.
92
+ - [`RunRecord`](runs-and-usage.md): the ledger row a snapshotted run leaves behind.
@@ -59,12 +59,61 @@ await session.run("Summarize", {
59
59
  });
60
60
  ```
61
61
 
62
- Defaults are the unconfigured fence (OWASP LLM10): turns 16, provider attempts 24, tool rounds 8, tool calls 32, wall time 120 seconds, request and response bytes 8 MiB each, input tokens 40,000, output tokens 10,000, total tokens 50,000. Hard process ceilings exist only for request/response bytes (64 MiB each), so a bug cannot OOM the host through a giant provider frame; those two axes reject `null` and are charged **per frame** (request payload, provider event), not as a run-lifetime sum — a 2 MiB prompt sent forty times is 2 MiB frames, not an 80 MiB parse. Snapshots still report the cumulative `requestBytes`/`responseBytes` counters for telemetry. Every other axis is host policy (0.5.4): omit a key for the default, set a positive safe integer sized to the workload, or set `null` to disable the axis — overnight sessions raise turns/wall/tokens, and a disabled wall still honors `RunOptions.signal`. Resolution stays narrowing-only: `RunOptions.limits` may lower `AgentConfig.limits`, `null` acts as +Infinity (agent 16 + run `null` → 16), and a raised/disabled `maxTurns` lifts an omitted `maxProviderAttempts` (default 24) to at least `maxTurns` so attempts cannot undercut turns; explicitly set attempts values are lifted only when both are finite. Cumulative token counters are billed usage across the whole run, not the context window (`contextBudget` governs window compaction). For production, prefer an explicit `maxCost`: cost needs a finite non-negative amount plus one currency, and when cost is limited, absent, non-finite, or mixed-currency provider cost fails closed. Vendors that omit usage charge zero to the token counters (local/Ollama report none), so a configured `maxCost` is the fail-closed envelope for usage-less vendors.
62
+ Defaults are the unconfigured fence (OWASP LLM10): turns 16, provider attempts 24, tool rounds 8, tool calls 32, wall time 120 seconds, request and response bytes 8 MiB each, input tokens 40,000, output tokens 10,000, total tokens 50,000. Hard process ceilings exist only for request/response bytes (64 MiB each), so a bug cannot OOM the host through a giant provider frame; those two axes reject `null` and are charged **per frame** (request payload, provider event), not as a run-lifetime sum — a 2 MiB prompt sent forty times is 2 MiB frames, not an 80 MiB parse. Snapshots still report the cumulative `requestBytes`/`responseBytes` counters for telemetry. Every other axis is host policy (0.5.4): omit a key for the default, set a positive safe integer sized to the workload, or set `null` to disable the axis — overnight sessions raise turns/wall/tokens, and a disabled wall still honors `RunOptions.signal`. Resolution stays narrowing-only: `RunOptions.limits` may lower `AgentConfig.limits`, `null` acts as +Infinity (agent 16 + run `null` → 16), and a raised/disabled `maxTurns` lifts an omitted `maxProviderAttempts` (default 24) to at least `maxTurns` so attempts cannot undercut turns; explicitly set attempts values are lifted only when both are finite. Cumulative token counters are billed usage across the whole run, not the context window (`contextBudget` governs window compaction). For production, prefer an explicit `maxCost`: cost needs a finite non-negative amount plus one currency, and when cost is limited, absent, non-finite, or mixed-currency provider cost fails closed. Vendors that omit usage charge their
63
+ labeled estimate (or zero with `usageEstimation: "off"`) to the token counters and never a
64
+ price, so a configured `maxCost` stays the fail-closed envelope for usage-less vendors.
63
65
 
64
- Prism charges turns before assembly, provider attempts before generation, request bytes per request payload, response bytes per provider event (each frame must fit the byte cap on its own), tool rounds before a batch, tool calls before dispatch, and usage before another turn. A breach stops new work, aborts active work through the run signal, emits exactly one redacted `run_limit_exceeded` event/ledger row, and throws `AgentRunError` with `result.limit` (`limit`, `maximum`, `observed`, optional `currency`). Provider-reported token/cost totals arrive after generation, so that completed provider turn can be the unavoidable overshoot boundary.
66
+ Prism charges turns before assembly, provider attempts before generation, request bytes per request payload, response bytes per provider event (each frame must fit the byte cap on its own), tool rounds before a batch, tool calls before dispatch, and usage before another turn. A breach stops new work, aborts active work through the run signal, emits exactly one redacted `run_limit_exceeded` event/ledger row, and throws `AgentRunError` with `result.limit` (`limit`, `maximum`, `observed`, optional `currency`). Just before the terminal `error`, the run also emits one `budget_exhausted` attribution event — the axis that fired, run counters at exhaustion, the three closest other axes, and hashes of the last ten dispatched tool calls ([Agent events § Run limit events](agent-events.md#run-limit-events)). Provider-reported token/cost totals arrive after generation, so that completed provider turn can be the unavoidable overshoot boundary.
65
67
 
66
68
  `createRunLimitTracker()` and `resolveRunLimits()` are public for adapters that need the same validation and accounting semantics. Workflow agent nodes forward `RunWorkflowOptions.limits`; supervisor delegation narrows its step/tool/token/timeout budget into core limits; MCP tool calls use a per-call tracker.
67
69
 
70
+ ## Token estimation (provider reports no usage)
71
+
72
+ When a provider reports no usage, `estimateMessageTokens(messages, modelFamily)` returns a labeled `TokenEstimate` instead of a silent zero. An estimate is never provider truth: reported usage always wins and is never overwritten. The array form reuses the same message flattening as budget accounting and adds the family's per-message chat-template overhead; the single-message form `estimateMessageTokens(message)` remains the numeric budget heuristic used by `contextBudget`.
73
+
74
+ ```ts
75
+ import { estimateMessageTokens, MODEL_FAMILY_TOKENS, resolveModelFamily } from "@arnilo/prism";
76
+
77
+ const estimate = estimateMessageTokens(messages, "claude-sonnet-4.5"); // model id, provider id, or family name
78
+ // { tokens: 41_200, confidence: "medium", lowConfidence: false }
79
+ ```
80
+
81
+ `MODEL_FAMILY_TOKENS` holds the chars/token ratio, per-message overhead, and confidence label per family (`anthropic`, `openai`, `google`, `deepseek`, `openrouter-generic`, `mistral`, `unknown`). `resolveModelFamily(modelId)` maps a model id or provider id to a table key; unmatched input resolves to `unknown`, whose row is the most conservative (highest estimated token count) and carries `confidence: "low"` / `lowConfidence: true`. Estimates are heuristics, not tokenizers: prose, fenced code, and CJK content are weighted separately, and every calibrated family is `confidence: "medium"` because Prism ships no real tokenizer. The estimator is pure — no network, no I/O, and no content retention.
82
+
83
+ ### Automatic fallback (`AgentConfig.usageEstimation`)
84
+
85
+ `usageEstimation` is `"fallback"` (default) or `"off"`. With the default, a provider turn that reports no usage records one labeled estimate at the existing usage seam — no adapter changes:
86
+
87
+ - the `provider_turn_finished.usage` carries `{ inputTokens, estimated: true, confidence }`, and its `budgets.inputTokens`/`runInputUsed` use that estimate, so the attention axes and run limits from plans 086/087 work on non-reporting models;
88
+ - ledger `appendUsage` rows (`scope: "provider_turn"` and the `run_total` aggregate) and `AgentRunResult.usage` keep `estimated: true` (plus `confidence`) — a billing surface can always tell an estimate from a report;
89
+ - estimates are **never priced**: the cost catalog is not consulted, and estimated usage carries no `cost`/`currency`, so a `maxCost` limit still fails closed instead of blocking on invented numbers;
90
+ - `"off"` leaves absent usage absent — no ledger row, no run total, never a zero.
91
+
92
+ The estimate covers the turn's own request: messages plus tool declarations and context blocks, using the model id's family table.
93
+
94
+ ### `session.contextMeter()`
95
+
96
+ One state read for host UIs (Clay's token meter, Synapta's model-router budgets):
97
+
98
+ ```ts
99
+ const meter = session.contextMeter();
100
+ // { inputTokens: 43_000, source: "estimated", inputCap: 200_000, runInputBudget: 500_000, usedRatio: 0.215 }
101
+ ```
102
+
103
+ `inputTokens` is the latest provider turn's input tokens — `source: "reported"` when the provider reported them, `"estimated"` when they are the labeled fallback (or, before any provider turn in the session, an estimate of stored history, so a fresh non-reporting model still shows a working meter). `inputCap` is resolved exactly like `provider_turn_finished.budgets.inputCap` (model window minus output reserve minus `attentionCompiler.reserveTokens`), `runInputBudget` is `RunLimits.maxInputTokens` while a run is active, and `usedRatio` is `inputTokens / inputCap`. Cap/budget/ratio are omitted when the model or run cannot derive them. The meter is never billing and never rewrites reported usage; `compact()` drops the pre-compaction reading so the next read re-estimates.
104
+
105
+ ## Clean stops and stop reasons
106
+
107
+ A run can end without an error but also without the model finishing its thought: a host `RunOptions.turnPolicy.stop`, a `turnPolicy.maxTurns` cap, or a loop ceiling. `AgentRunResult.stopReason` names that outcome — `"host_policy"` for a host policy stop, `"turn_limit"`, `"token_limit"`, or `"refusal"` for loop ceilings — with `turnPolicy.stop`'s own string in `stopDetail`. A natural end carries neither field, so hosts that only care about "did it stop early?" check truthiness. The same values ride the emitted `agent_finished` event (as `finishReason`/`stopDetail`), the finish `RunRecord`, and the projected [Execution Timeline](execution-timeline.md).
108
+
109
+ A `host_policy` stop is terminal for the run yet resumable: with `runState: { checkpointPolicy: "every-turn" }` the stopped state keeps its frontier, and `resumeAgentRun(..., { decision: "continue" })` picks the loop up at the boundary. Every other terminal state is final. See [Agent loops § Turn policy](agent-loops.md#turn-policy).
110
+
111
+ ## Provider failure classes
112
+
113
+ Provider-originated failures carry advisory `ErrorInfo.failureClass` on the failed `AgentRunResult`, terminal `RunRecord`, error events, and any `ToolResult.error` that already carries that `ErrorInfo`. Values are `"quota"`, `"auth"`, `"rate_limited"`, `"transient"`, `"permanent"`, and `"unknown"`. The classifier uses an already-captured HTTP status plus bounded error body: quota-shaped `429` responses (for example `GoUsageLimitError`) are `"quota"`; other `429` values are `"rate_limited"`; `401`/`403` are `"auth"`; `5xx` and known network codes such as `ECONNRESET` are `"transient"`; other `4xx` values are `"permanent"`; anything else is `"unknown"`.
114
+
115
+ This field is outcome metadata, not a retry control. Existing retry policy, attempt limits, and fail-closed behavior continue to use `ErrorInfo.code` exactly as before. Prism records no provider headers or response bodies beyond the existing redacted error message.
116
+
68
117
  ## Durable run state
69
118
 
70
119
  `RunOptions.runState` writes a bounded, versioned checkpoint only at a safe interruption boundary. Its counters and absolute deadline resume with the run, while transcript history stays in `SessionStore` by session/leaf reference. `AgentRunResult.runState` exposes only redacted identity/status/version data; `interruption` excludes tool arguments. See [Agent/session runtime](agent-session-runtime.md#durable-interruption).
@@ -86,6 +135,8 @@ The adapter receives these record shapes:
86
135
  | `status` | `queued` \| `running` \| `suspended` \| `denied` \| `succeeded` \| `failed` \| `aborted`. |
87
136
  | `startedAt` / `finishedAt` | ISO timestamps. |
88
137
  | `abortReason` | Set when status is `aborted`. |
138
+ | `stopReason` | Why the loop stopped cleanly instead of reaching a natural end: `host_policy` (`RunOptions.turnPolicy.stop`), `turn_limit`, `token_limit`, or `refusal`. Absent on a natural end. |
139
+ | `stopDetail` | Host stop detail from `turnPolicy.stop` (≤256 bytes, redacted). |
89
140
  | `error` | `ErrorInfo` when status is `failed`. |
90
141
  | `tenantId` / `accountId` / `userId` | From active ownership scope. |
91
142
 
@@ -122,7 +173,7 @@ The adapter receives these record shapes:
122
173
  | `runId` / `sessionId` / `entryId` | Correlation ids. |
123
174
  | `scope` | `provider_turn` for billable source rows; `run_total` for the aggregate. Never sum both scopes. |
124
175
  | `turn` / `attempt` | Provider-turn attribution; absent on `run_total`. |
125
- | `usage` | `Usage` shape: input/output/total/cache tokens, cost, currency. |
176
+ | `usage` | `Usage` shape: input/output/total/cache tokens, cost, currency. Cache fields stay absent when provider does not report them; an explicit provider zero remains `0`. |
126
177
  | `recordedAt` | ISO timestamp. |
127
178
 
128
179
  ## Cost/catalog freshness (host adapter)
@@ -258,7 +309,7 @@ const ledger: RunLedger = {
258
309
 
259
310
  const agent = createAgent({
260
311
  model: { provider: "mock", model: "demo" },
261
- provider: createMockProvider([providerTextDelta("Hello"), providerDone()]),
312
+ provider: createMockProvider([providerTextDelta("Hello"), providerDone({ inputTokens: 1_000, cacheReadTokens: 800 })]),
262
313
  runLedger: ledger,
263
314
  ownership: { tenantId: "tenant_a", accountId: "account_a" },
264
315
  idempotencyKey: "agent-key",
@@ -276,7 +327,7 @@ console.log(runs.at(-1)?.status); // succeeded
276
327
  const billable = usageRows.filter((row) => row.scope === "provider_turn");
277
328
  const aggregate = usageRows.find((row) => row.scope === "run_total");
278
329
  console.log(cacheUsageReport(aggregate?.usage));
279
- // { cacheReadTokens: 0, cacheWriteTokens: 0, ... } when provider usage is present
330
+ // { cacheReadTokens: 800, hitRate: 0.8 } — cacheWriteTokens stays absent when unreported
280
331
  ```
281
332
 
282
333
  ## Extension and configuration notes
@@ -296,7 +347,7 @@ console.log(cacheUsageReport(aggregate?.usage));
296
347
  - Adapters should treat appends as ordered within a `runId`: event and tool-call rows preserve emission order because the runtime serializes event ledger appends through one promise chain (concurrency 1), drains pending appends before writing the final `RunRecord`, and propagates append failures by rejecting run completion.
297
348
  - Billing queries must filter `scope = "provider_turn"`; presentation queries normally read the single `run_total`. `UsageQuery.scope`, `turn`, and `attempt` are explicit filters.
298
349
  - Adapters that need upsert semantics can use `RunRecord.id` (== `runId`) as the stable key.
299
- - Use `cacheUsageReport(record.usage, model)` for cache diagnostics from normalized usage. It works when a provider reports `cacheReadTokens` without `cacheWriteTokens`; missing write tokens are reported as `0`, and unavailable hit rate/savings stay `undefined`.
350
+ - Use `cacheUsageReport(record.usage, model)` for cache diagnostics from normalized usage. It reports `cacheReadTokens` without `cacheWriteTokens` when that is all a provider supplies; neither token field nor hit rate is fabricated as zero. `provider_turn_finished.metadata.cache` carries that same per-attempt report, while `ExecutionTimeline.cacheHitRate` is the input-token-weighted run aggregate.
300
351
  - **Provider-specific telemetry is package-owned.** Core `Usage` carries token counts and `cost`/`currency`; it has no energy or detailed cost-breakdown fields. Providers that surface extra telemetry (e.g. `@arnilo/prism-providers/neuralwatt` exposes `neuralWattEventsWithTelemetry()`, `parseNeuralWattComment()`, and `mapNeuralWattTelemetry()` for `: energy`/`: cost` SSE comments and non-streaming top-level fields) keep that data in package-specific helpers/types. Telemetry never enters `RunLedger` usage rows unless the host explicitly copies it in; it carries usage/cost numbers only — never prompts, API keys, or headers. Account-level quota is likewise package-owned: `@arnilo/prism-providers/neuralwatt` exports an explicit `getNeuralWattQuota()` helper that the host calls on demand (never during generation); NeuralWatt rate-limits that endpoint to 1 request per second per customer, so the caller owns throttling.
301
352
  - **Governed provider lifecycle and reservation reconciliation.** For invocation-level accounting outside of or in addition to `RunLedger`, wrap providers with `createGovernedProvider` or `router.createGovernedProvider` from `@arnilo/prism-core/governance/model-router`. The adapter handles atomic admission reservations, bounds streaming, and guarantees explicit settlement: missing actual usage on an interrupted or EOF stream is committed as reserved liability (`unknownUsage: true`) rather than zero, avoiding budget leakages or unmetered oversubscriptions. See [Model routing](model-routing.md).
302
353
  - **Aggregate task/tenant accounting across all paid work.** Complex agent tasks often span retries, model fallbacks, delegated children, background compactions, embedding jobs, and paid tools. Passing `taskId` and `kind` (`"generation" | "embedding" | "compaction" | "tool"`) coordinates all related calls under a single atomic task-level reservation and budget scope. Committed usage decomposes into separate `byModel` and `byKind` attributions (`router.readBudget({ identity, taskId })`) while preventing double-charging across parent/child boundaries or replayed events. Long-running holds can be safely renewed via `router.renewBudget({ ... })` before expiry without prematurely releasing live liability. See [Model routing](model-routing.md) and [Enterprise PostgreSQL state](enterprise-postgres-state.md).
@@ -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.
package/docs/server.md CHANGED
@@ -264,6 +264,8 @@ A2A routes are not added to `createPrismHandler()`. Install `@arnilo/prism-core/
264
264
  - [MCP client and server exposure](mcp-tools.md): selected MCP capabilities and web-standard MCP transport.
265
265
  - [Host security guide](host-security.md): remote-boundary checklist.
266
266
  - [A2A interoperability](a2a.md): separately mounted A2A 1.0 handler/client.
267
+ - [Telegram channel](telegram-channel.md): separately mounted Telegram Web `Request`/`Response` webhook handler; host owns fixed HTTPS route and TLS.
268
+ - [Signal channel (experimental)](signal-channel.md): private Unix-socket manual receive for an externally supervised signal-cli daemon; host owns socket, account, accepted-use policy and supervision.
267
269
  - [Obscura browser engine](obscura.md): optional binary-backed generic tools for hosted agents.
268
270
  - [Conversations](conversations.md): durable user-scoped conversation service, replay, branches, export, deletion.
269
271
  - [Work artifacts and review](work-artifacts-and-review.md): durable artifact review service, revisions, approvals, authorized expiring delivery links.
@@ -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