@graphorin/memory 0.6.0 → 0.7.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 (227) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +24 -7
  3. package/dist/conflict/index.js +0 -1
  4. package/dist/conflict/pipeline.js +0 -1
  5. package/dist/conflict/pipeline.js.map +1 -1
  6. package/dist/conflict/stages/stage3-heuristic-regex.js +0 -1
  7. package/dist/conflict/stages/stage3-heuristic-regex.js.map +1 -1
  8. package/dist/conflict/types.d.ts +1 -1
  9. package/dist/conflict/types.js.map +1 -1
  10. package/dist/consolidator/index.js +2 -0
  11. package/dist/consolidator/index.js.map +1 -1
  12. package/dist/consolidator/lock.js +7 -1
  13. package/dist/consolidator/lock.js.map +1 -1
  14. package/dist/consolidator/phases/deep.js +19 -12
  15. package/dist/consolidator/phases/deep.js.map +1 -1
  16. package/dist/consolidator/phases/induce.d.ts.map +1 -1
  17. package/dist/consolidator/phases/induce.js +2 -3
  18. package/dist/consolidator/phases/induce.js.map +1 -1
  19. package/dist/consolidator/phases/reflect.js +35 -8
  20. package/dist/consolidator/phases/reflect.js.map +1 -1
  21. package/dist/consolidator/phases/standard.js +199 -28
  22. package/dist/consolidator/phases/standard.js.map +1 -1
  23. package/dist/consolidator/reconcile.js +16 -15
  24. package/dist/consolidator/reconcile.js.map +1 -1
  25. package/dist/consolidator/runtime.d.ts.map +1 -1
  26. package/dist/consolidator/runtime.js +129 -47
  27. package/dist/consolidator/runtime.js.map +1 -1
  28. package/dist/consolidator/scheduler.d.ts +17 -3
  29. package/dist/consolidator/scheduler.d.ts.map +1 -1
  30. package/dist/consolidator/scheduler.js.map +1 -1
  31. package/dist/consolidator/types.d.ts +30 -1
  32. package/dist/consolidator/types.d.ts.map +1 -1
  33. package/dist/consolidator/types.js +10 -0
  34. package/dist/consolidator/types.js.map +1 -1
  35. package/dist/context-engine/compaction/clear-tool-results.d.ts +1 -1
  36. package/dist/context-engine/compaction/clear-tool-results.js +1 -1
  37. package/dist/context-engine/compaction/clear-tool-results.js.map +1 -1
  38. package/dist/context-engine/compaction/compactor.d.ts +1 -0
  39. package/dist/context-engine/compaction/compactor.d.ts.map +1 -1
  40. package/dist/context-engine/compaction/compactor.js +32 -8
  41. package/dist/context-engine/compaction/compactor.js.map +1 -1
  42. package/dist/context-engine/compaction/hooks/types.d.ts +1 -1
  43. package/dist/context-engine/compaction/index.d.ts +1 -0
  44. package/dist/context-engine/compaction/templates/marker.d.ts +33 -0
  45. package/dist/context-engine/compaction/templates/marker.d.ts.map +1 -0
  46. package/dist/context-engine/compaction/templates/marker.js +34 -0
  47. package/dist/context-engine/compaction/templates/marker.js.map +1 -0
  48. package/dist/context-engine/compaction/templates/summary-9-section.d.ts +1 -1
  49. package/dist/context-engine/compaction/templates/summary-9-section.d.ts.map +1 -1
  50. package/dist/context-engine/compaction/templates/summary-9-section.js +4 -3
  51. package/dist/context-engine/compaction/templates/summary-9-section.js.map +1 -1
  52. package/dist/context-engine/engine.d.ts +7 -176
  53. package/dist/context-engine/engine.d.ts.map +1 -1
  54. package/dist/context-engine/engine.js +0 -2
  55. package/dist/context-engine/engine.js.map +1 -1
  56. package/dist/context-engine/index.d.ts +8 -49
  57. package/dist/context-engine/io-types.d.ts +144 -0
  58. package/dist/context-engine/io-types.d.ts.map +1 -0
  59. package/dist/context-engine/locale-packs/types.d.ts +1 -1
  60. package/dist/context-engine/locale-packs/types.js.map +1 -1
  61. package/dist/context-engine/privacy-filter.d.ts +1 -2
  62. package/dist/context-engine/privacy-filter.d.ts.map +1 -1
  63. package/dist/context-engine/privacy-filter.js +1 -2
  64. package/dist/context-engine/privacy-filter.js.map +1 -1
  65. package/dist/errors/index.d.ts +2 -2
  66. package/dist/errors/index.js +2 -2
  67. package/dist/errors/index.js.map +1 -1
  68. package/dist/facade.d.ts +43 -52
  69. package/dist/facade.d.ts.map +1 -1
  70. package/dist/facade.js +5 -3
  71. package/dist/facade.js.map +1 -1
  72. package/dist/graph/entity-resolver.js +1 -1
  73. package/dist/graph/entity-resolver.js.map +1 -1
  74. package/dist/index.d.ts +17 -15
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -7
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal/envelope.js +57 -0
  79. package/dist/internal/envelope.js.map +1 -0
  80. package/dist/internal/injection-heuristics.js +0 -0
  81. package/dist/internal/injection-heuristics.js.map +1 -1
  82. package/dist/internal/llm-json.js +40 -0
  83. package/dist/internal/llm-json.js.map +1 -0
  84. package/dist/internal/storage-adapter.d.ts +23 -10
  85. package/dist/internal/storage-adapter.d.ts.map +1 -1
  86. package/dist/memory-interface.d.ts +131 -0
  87. package/dist/memory-interface.d.ts.map +1 -0
  88. package/dist/package.js +6 -0
  89. package/dist/package.js.map +1 -0
  90. package/dist/search/iterative.d.ts.map +1 -1
  91. package/dist/search/iterative.js +2 -3
  92. package/dist/search/iterative.js.map +1 -1
  93. package/dist/search/query-transform.d.ts.map +1 -1
  94. package/dist/search/query-transform.js +2 -3
  95. package/dist/search/query-transform.js.map +1 -1
  96. package/dist/tiers/episodic-memory.js +3 -3
  97. package/dist/tiers/episodic-memory.js.map +1 -1
  98. package/dist/tiers/index.d.ts +2 -2
  99. package/dist/tiers/insight-memory.js +1 -1
  100. package/dist/tiers/insight-memory.js.map +1 -1
  101. package/dist/tiers/procedural-memory.d.ts +1 -1
  102. package/dist/tiers/procedural-memory.js +2 -2
  103. package/dist/tiers/procedural-memory.js.map +1 -1
  104. package/dist/tiers/semantic-memory.d.ts +58 -4
  105. package/dist/tiers/semantic-memory.d.ts.map +1 -1
  106. package/dist/tiers/semantic-memory.js +103 -20
  107. package/dist/tiers/semantic-memory.js.map +1 -1
  108. package/dist/tools/block-tools.d.ts +19 -57
  109. package/dist/tools/block-tools.d.ts.map +1 -1
  110. package/dist/tools/block-tools.js.map +1 -1
  111. package/dist/tools/fact-tools.d.ts +53 -214
  112. package/dist/tools/fact-tools.d.ts.map +1 -1
  113. package/dist/tools/fact-tools.js +3 -2
  114. package/dist/tools/fact-tools.js.map +1 -1
  115. package/dist/tools/recall-tools.d.ts +34 -175
  116. package/dist/tools/recall-tools.d.ts.map +1 -1
  117. package/dist/tools/recall-tools.js +5 -4
  118. package/dist/tools/recall-tools.js.map +1 -1
  119. package/dist/tools/runbook-tools.d.ts +15 -58
  120. package/dist/tools/runbook-tools.d.ts.map +1 -1
  121. package/dist/tools/runbook-tools.js.map +1 -1
  122. package/dist/tools/types.d.ts +1 -1
  123. package/package.json +18 -17
  124. package/src/conflict/index.ts +29 -0
  125. package/src/conflict/locale-packs/en.ts +142 -0
  126. package/src/conflict/locale-packs/index.ts +17 -0
  127. package/src/conflict/locale-packs/types.ts +138 -0
  128. package/src/conflict/pipeline.ts +371 -0
  129. package/src/conflict/stages/helpers.ts +65 -0
  130. package/src/conflict/stages/stage1-exact-dedup.ts +40 -0
  131. package/src/conflict/stages/stage2-embedding-three-zone.ts +54 -0
  132. package/src/conflict/stages/stage3-heuristic-regex.ts +51 -0
  133. package/src/conflict/stages/stage4-subject-predicate.ts +46 -0
  134. package/src/conflict/stages/stage5-defer-to-deep.ts +34 -0
  135. package/src/conflict/types.ts +263 -0
  136. package/src/consolidator/budget.ts +312 -0
  137. package/src/consolidator/decay.ts +246 -0
  138. package/src/consolidator/dlq.ts +61 -0
  139. package/src/consolidator/errors.ts +87 -0
  140. package/src/consolidator/idempotency.ts +27 -0
  141. package/src/consolidator/index.ts +269 -0
  142. package/src/consolidator/lock.ts +141 -0
  143. package/src/consolidator/noise-filter.ts +235 -0
  144. package/src/consolidator/phases/deep.ts +308 -0
  145. package/src/consolidator/phases/induce.ts +423 -0
  146. package/src/consolidator/phases/learned-context.ts +233 -0
  147. package/src/consolidator/phases/light.ts +166 -0
  148. package/src/consolidator/phases/reflect.ts +504 -0
  149. package/src/consolidator/phases/standard.ts +1002 -0
  150. package/src/consolidator/reconcile.ts +273 -0
  151. package/src/consolidator/runtime.ts +1049 -0
  152. package/src/consolidator/scheduler.ts +182 -0
  153. package/src/consolidator/triggers.ts +134 -0
  154. package/src/consolidator/types.ts +622 -0
  155. package/src/context-engine/annotations.ts +161 -0
  156. package/src/context-engine/auto-recall.ts +92 -0
  157. package/src/context-engine/compaction/clear-tool-results.ts +232 -0
  158. package/src/context-engine/compaction/compactor.ts +403 -0
  159. package/src/context-engine/compaction/hooks/reanchor-persona-block.ts +55 -0
  160. package/src/context-engine/compaction/hooks/reanchor-pinned-facts.ts +66 -0
  161. package/src/context-engine/compaction/hooks/reanchor-project-rules.ts +57 -0
  162. package/src/context-engine/compaction/hooks/reanchor-recent-results.ts +116 -0
  163. package/src/context-engine/compaction/hooks/types.ts +68 -0
  164. package/src/context-engine/compaction/index.ts +60 -0
  165. package/src/context-engine/compaction/templates/marker.ts +33 -0
  166. package/src/context-engine/compaction/templates/summary-9-section.ts +223 -0
  167. package/src/context-engine/compaction/thresholds.ts +107 -0
  168. package/src/context-engine/compaction/types.ts +255 -0
  169. package/src/context-engine/engine.ts +970 -0
  170. package/src/context-engine/index.ts +118 -0
  171. package/src/context-engine/io-types.ts +162 -0
  172. package/src/context-engine/locale-packs/en.ts +107 -0
  173. package/src/context-engine/locale-packs/index.ts +19 -0
  174. package/src/context-engine/locale-packs/resolver.ts +197 -0
  175. package/src/context-engine/locale-packs/types.ts +177 -0
  176. package/src/context-engine/metadata.ts +114 -0
  177. package/src/context-engine/preambles/inbound-en.ts +19 -0
  178. package/src/context-engine/privacy-filter.ts +190 -0
  179. package/src/context-engine/templates/base-en.ts +19 -0
  180. package/src/context-engine/templates/composer.ts +91 -0
  181. package/src/context-engine/token-budget.ts +319 -0
  182. package/src/context-engine/token-counter.ts +157 -0
  183. package/src/errors/index.ts +273 -0
  184. package/src/facade.ts +750 -0
  185. package/src/graph/entity-resolver.ts +364 -0
  186. package/src/graph/index.ts +24 -0
  187. package/src/index.ts +259 -0
  188. package/src/internal/contextualize.ts +173 -0
  189. package/src/internal/embedder-binding.ts +53 -0
  190. package/src/internal/envelope.ts +70 -0
  191. package/src/internal/id.ts +23 -0
  192. package/src/internal/injection-heuristics.ts +0 -0
  193. package/src/internal/llm-json.ts +38 -0
  194. package/src/internal/spans.ts +35 -0
  195. package/src/internal/storage-adapter.ts +963 -0
  196. package/src/memory-interface.ts +158 -0
  197. package/src/migration/embedder-migration.ts +237 -0
  198. package/src/migration/index.ts +13 -0
  199. package/src/search/explain.ts +123 -0
  200. package/src/search/fit-weights.ts +109 -0
  201. package/src/search/graph-ppr.ts +38 -0
  202. package/src/search/index.ts +55 -0
  203. package/src/search/iterative.ts +658 -0
  204. package/src/search/query-transform.ts +285 -0
  205. package/src/search/rrf.ts +225 -0
  206. package/src/search/trust.ts +55 -0
  207. package/src/search/types.ts +50 -0
  208. package/src/tiers/episodic-memory.ts +463 -0
  209. package/src/tiers/index.ts +50 -0
  210. package/src/tiers/insight-memory.ts +208 -0
  211. package/src/tiers/procedural-memory.ts +482 -0
  212. package/src/tiers/semantic-memory.ts +1729 -0
  213. package/src/tiers/session-memory.ts +271 -0
  214. package/src/tiers/shared-memory.ts +62 -0
  215. package/src/tiers/working-memory.ts +368 -0
  216. package/src/tools/block-tools.ts +172 -0
  217. package/src/tools/fact-tools.ts +473 -0
  218. package/src/tools/index.ts +109 -0
  219. package/src/tools/recall-tools.ts +303 -0
  220. package/src/tools/runbook-tools.ts +107 -0
  221. package/src/tools/taint.ts +39 -0
  222. package/src/tools/types.ts +37 -0
  223. package/dist/conflict/locale-packs/index.js +0 -4
  224. package/dist/context-engine/compaction/index.js +0 -10
  225. package/dist/context-engine/index.d.ts.map +0 -1
  226. package/dist/context-engine/index.js +0 -25
  227. package/dist/context-engine/locale-packs/index.js +0 -4
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `InsightMemory` - the thin, read-only surface over the reflection
3
+ * insights the consolidator synthesizes (P1-1). Insights are *derived*
4
+ * (quarantined + `provenance: 'reflection'`), carry mandatory citations,
5
+ * and are retrieval-ranked **below the primary facts they cite**. The
6
+ * write + salience-management paths live in the consolidator's
7
+ * reflection pass; this tier only lists / searches for the validation /
8
+ * inspector path.
9
+ *
10
+ * Search is FTS-only - insights are a soft inspector surface, not
11
+ * primary recall, so no embedder is wired here.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type { Fact, Insight, MemoryHit, SessionScope, Tracer } from '@graphorin/core';
17
+ import { QuarantinePromotionRefusedError } from '../errors/index.js';
18
+ import { detectMemoryInjection } from '../internal/injection-heuristics.js';
19
+ import { withMemorySpan } from '../internal/spans.js';
20
+ import type { MemoryStoreAdapter } from '../internal/storage-adapter.js';
21
+
22
+ /** Per-call options accepted by {@link InsightMemory.search}. */
23
+ export interface InsightSearchOptions {
24
+ readonly topK?: number;
25
+ /**
26
+ * Include quarantined insights (P1-4). Defaults to `false`. Since
27
+ * reflection-synthesized insights *always* land quarantined, set this
28
+ * `true` to surface them for the validation / inspector path - never
29
+ * for auto-recall fed back into the model.
30
+ */
31
+ readonly includeQuarantined?: boolean;
32
+ readonly signal?: AbortSignal;
33
+ }
34
+
35
+ /** Per-call options accepted by {@link InsightMemory.list}. */
36
+ export interface InsightListOptions {
37
+ readonly limit?: number;
38
+ /** Include quarantined insights (validation / inspector path). */
39
+ readonly includeQuarantined?: boolean;
40
+ }
41
+
42
+ /**
43
+ * `InsightMemory` - list / search reflection insights. A no-op (returns
44
+ * empty) when the storage adapter does not expose the optional
45
+ * `insights` surface.
46
+ *
47
+ * @stable
48
+ */
49
+ export class InsightMemory {
50
+ readonly #store: MemoryStoreAdapter;
51
+ readonly #tracer: Tracer;
52
+
53
+ constructor(args: { store: MemoryStoreAdapter; tracer: Tracer }) {
54
+ this.#store = args.store;
55
+ this.#tracer = args.tracer;
56
+ }
57
+
58
+ /** FTS keyword search over insight text. */
59
+ async search(
60
+ scope: SessionScope,
61
+ query: string,
62
+ opts: InsightSearchOptions = {},
63
+ ): Promise<ReadonlyArray<MemoryHit<Insight>>> {
64
+ return withMemorySpan(
65
+ this.#tracer,
66
+ 'memory.search.insight',
67
+ scope,
68
+ { 'memory.search.query_length': query.length },
69
+ async (span) => {
70
+ const store = this.#store.insights;
71
+ if (store === undefined) {
72
+ span.setAttributes({ 'memory.search.insight.count': 0 });
73
+ return [];
74
+ }
75
+ const hits = await store.search(scope, query, {
76
+ topK: opts.topK ?? 10,
77
+ ...(opts.includeQuarantined !== undefined
78
+ ? { includeQuarantined: opts.includeQuarantined }
79
+ : {}),
80
+ });
81
+ // MCON-16: retrieval reinforces (ExpeL) - a recalled insight gains
82
+ // +1 salience, offsetting the per-reflection-pass -1 decay, so
83
+ // used insights persist while unused ones slide to the prune
84
+ // floor. Best-effort bookkeeping: never break the read path.
85
+ for (const hit of hits) {
86
+ try {
87
+ await store.bumpSalience(hit.record.id, 1, 'retrieval');
88
+ } catch {
89
+ // Advisory only.
90
+ }
91
+ }
92
+ span.setAttributes({ 'memory.search.insight.count': hits.length });
93
+ return hits;
94
+ },
95
+ );
96
+ }
97
+
98
+ /** Most-recent insights for the scope (newest first). */
99
+ async list(scope: SessionScope, opts: InsightListOptions = {}): Promise<ReadonlyArray<Insight>> {
100
+ return withMemorySpan(this.#tracer, 'memory.read.insight', scope, {}, async (span) => {
101
+ const store = this.#store.insights;
102
+ if (store === undefined) {
103
+ span.setAttributes({ 'memory.read.insight.count': 0 });
104
+ return [];
105
+ }
106
+ const out = await store.list(scope, {
107
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
108
+ ...(opts.includeQuarantined !== undefined
109
+ ? { includeQuarantined: opts.includeQuarantined }
110
+ : {}),
111
+ });
112
+ span.setAttributes({ 'memory.read.insight.count': out.length });
113
+ return out;
114
+ });
115
+ }
116
+
117
+ /** Lookup a single insight by id. */
118
+ async get(id: string): Promise<Insight | null> {
119
+ const store = this.#store.insights;
120
+ if (store === undefined || typeof store.get !== 'function') return null;
121
+ return store.get(id);
122
+ }
123
+
124
+ /**
125
+ * Promote a quarantined insight out of quarantine (MCON-2). Mirrors
126
+ * {@link SemanticMemory.validate}: re-derives the injection verdict from the
127
+ * stored text and **refuses** promotion of an injection-flagged insight
128
+ * unless an operator passes `{ force: true }` from a trusted context.
129
+ */
130
+ async validate(
131
+ scope: SessionScope,
132
+ insightId: string,
133
+ reason?: string,
134
+ options?: { readonly force?: boolean },
135
+ ): Promise<void> {
136
+ await withMemorySpan(
137
+ this.#tracer,
138
+ 'memory.write.insight',
139
+ scope,
140
+ { 'memory.insight.action': 'validate', 'memory.insight.insight_id': insightId },
141
+ async (span) => {
142
+ const store = this.#store.insights;
143
+ if (store === undefined || typeof store.setStatus !== 'function') {
144
+ throw new TypeError(
145
+ '[graphorin/memory] InsightMemory.validate(...) requires a storage adapter that implements `insights.setStatus(id, status)`. ' +
146
+ 'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via InsightMemoryStoreExt.',
147
+ );
148
+ }
149
+ const force = options?.force === true;
150
+ const existing = typeof store.get === 'function' ? await store.get(insightId) : null;
151
+ if (existing !== null && !force) {
152
+ const injection = detectMemoryInjection(existing.text);
153
+ if (injection.flagged) {
154
+ span.setAttributes({ 'memory.insight.validate.refused': true });
155
+ throw new QuarantinePromotionRefusedError(insightId, injection.markers);
156
+ }
157
+ }
158
+ span.setAttributes({ 'memory.insight.validate.forced': force });
159
+ await store.setStatus(insightId, 'active', reason, scope);
160
+ },
161
+ );
162
+ }
163
+ }
164
+
165
+ /** Tie-break margin keeping a capped insight strictly below its cited fact. */
166
+ const RANK_CAP_EPSILON = 1e-6;
167
+
168
+ /**
169
+ * Enforce the P1-1 rank ceiling: an insight may never outrank a primary
170
+ * fact **it cites**. For each insight hit, if any fact it cites is
171
+ * present in `factHits`, its score is lowered to strictly below that
172
+ * cited fact's score - so concatenating the two lists and sorting by
173
+ * score descending can never place the insight above the evidence it
174
+ * was synthesized from. Insights whose cited facts are absent from
175
+ * `factHits` are returned unchanged; this is a relative, not a global,
176
+ * cap (per the execution plan: "never outrank the primary facts they
177
+ * cite").
178
+ *
179
+ * Pure + deterministic - does not mutate its inputs.
180
+ *
181
+ * @stable
182
+ */
183
+ export function capInsightsBelowFacts(
184
+ factHits: ReadonlyArray<MemoryHit<Fact>>,
185
+ insightHits: ReadonlyArray<MemoryHit<Insight>>,
186
+ ): ReadonlyArray<MemoryHit<Insight>> {
187
+ if (factHits.length === 0 || insightHits.length === 0) return insightHits;
188
+ const factScoreById = new Map<string, number>();
189
+ for (const hit of factHits) {
190
+ const prev = factScoreById.get(hit.record.id);
191
+ if (prev === undefined || hit.score > prev) factScoreById.set(hit.record.id, hit.score);
192
+ }
193
+ return insightHits.map((hit) => {
194
+ let citedMax = Number.NEGATIVE_INFINITY;
195
+ for (const id of hit.record.cites) {
196
+ const score = factScoreById.get(id);
197
+ if (score !== undefined && score > citedMax) citedMax = score;
198
+ }
199
+ // No cited fact present in the fused set, or already below it ⇒ keep.
200
+ if (citedMax === Number.NEGATIVE_INFINITY || hit.score < citedMax) return hit;
201
+ const capped = citedMax - RANK_CAP_EPSILON;
202
+ return {
203
+ ...hit,
204
+ score: capped,
205
+ signals: Object.freeze({ ...(hit.signals ?? {}), 'rank-cap': capped }),
206
+ };
207
+ });
208
+ }
@@ -0,0 +1,482 @@
1
+ import type {
2
+ MemoryHit,
3
+ MemoryOwner,
4
+ MemoryProvenance,
5
+ MemoryStatus,
6
+ Rule,
7
+ RunState,
8
+ Sensitivity,
9
+ SessionScope,
10
+ Tracer,
11
+ } from '@graphorin/core';
12
+ import {
13
+ type InducedProcedure,
14
+ runWorkflowInduction,
15
+ type Trajectory,
16
+ trajectoryFromRunState,
17
+ type WorkflowInducer,
18
+ } from '../consolidator/phases/induce.js';
19
+ import {
20
+ ProcedureInductionNotConfiguredError,
21
+ QuarantinePromotionRefusedError,
22
+ } from '../errors/index.js';
23
+ import { newMemoryId } from '../internal/id.js';
24
+ import { detectMemoryInjection } from '../internal/injection-heuristics.js';
25
+ import { withMemorySpan } from '../internal/spans.js';
26
+ import type { MemoryStoreAdapter } from '../internal/storage-adapter.js';
27
+
28
+ /** Default priority for an *induced* procedure - below the author default
29
+ * (50) because an induced, still-quarantined procedure is provisional. */
30
+ const INDUCED_PRIORITY = 40;
31
+
32
+ /**
33
+ * Author-time rule payload accepted by {@link ProceduralMemory.define}.
34
+ *
35
+ * @stable
36
+ */
37
+ export interface RuleInput {
38
+ readonly text: string;
39
+ /**
40
+ * Free-form predicate evaluated by {@link ProceduralMemory.activate}.
41
+ * The predicate language is intentionally narrow in v0.1: either
42
+ * the literal string `'always'`, or a `'topic=...'` / `'tag=...'`
43
+ * shorthand. Custom predicates should be expressed as a callable
44
+ * matched in `activate(...)`'s `customMatchers` argument.
45
+ */
46
+ readonly condition?: string;
47
+ /** Default `'public'` per DEC-126 - rules are NOT user data. */
48
+ readonly sensitivity?: Sensitivity;
49
+ readonly priority?: number;
50
+ readonly tags?: ReadonlyArray<string>;
51
+ /**
52
+ * Optional structured workflow payload (P2-2). Usually set by
53
+ * {@link ProceduralMemory.induce}, but accepted here so an author can
54
+ * round-trip a hand-written procedure. See {@link Rule.steps}.
55
+ */
56
+ readonly steps?: ReadonlyArray<string>;
57
+ /** Variable names abstracted into {@link RuleInput.steps} (P2-2). */
58
+ readonly variables?: ReadonlyArray<string>;
59
+ /** Verifiable success criteria stored with the procedure (P2-2). */
60
+ readonly successCriteria?: ReadonlyArray<string>;
61
+ /** Principal dimension (D3). Absent ⇒ NULL (treated `'user'`). */
62
+ readonly owner?: MemoryOwner;
63
+ }
64
+
65
+ /**
66
+ * Options for {@link ProceduralMemory.induce}.
67
+ *
68
+ * @stable
69
+ */
70
+ export interface InduceOptions {
71
+ /** Sensitivity of the stored procedure. Default `'internal'`. */
72
+ readonly sensitivity?: Sensitivity;
73
+ /** Priority of the stored procedure. Default `INDUCED_PRIORITY` (40). */
74
+ readonly priority?: number;
75
+ }
76
+
77
+ /**
78
+ * Predicate context passed to {@link ProceduralMemory.activate}.
79
+ *
80
+ * @stable
81
+ */
82
+ export interface RuleActivationContext {
83
+ readonly topic?: string;
84
+ readonly tags?: ReadonlyArray<string>;
85
+ }
86
+
87
+ /**
88
+ * `ProceduralMemory` - standing orders activated when the agent's
89
+ * current context matches the rule's predicate. The activation rules
90
+ * are deterministic so the agent runtime + ContextEngine can render
91
+ * the active set into the system prompt every step.
92
+ *
93
+ * P2-2 adds {@link ProceduralMemory.induce}: distil a reusable workflow
94
+ * from a successful agent trajectory and store it **quarantined** (it must
95
+ * not drive actions until validated). Quarantined procedures are excluded
96
+ * from {@link ProceduralMemory.activate} but remain visible to
97
+ * {@link ProceduralMemory.list}.
98
+ *
99
+ * @stable
100
+ */
101
+ export class ProceduralMemory {
102
+ readonly #store: MemoryStoreAdapter;
103
+ readonly #tracer: Tracer;
104
+ /** Opt-in workflow inducer (P2-2). `null` ⇒ {@link induce} throws. */
105
+ readonly #inducer: WorkflowInducer | null;
106
+ /**
107
+ * Promotion-by-demonstrated-success threshold (MCON-2 part 4).
108
+ * `null` ⇒ outcomes are counted but never auto-promote.
109
+ */
110
+ readonly #promoteAfterSuccesses: number | null;
111
+
112
+ constructor(args: {
113
+ store: MemoryStoreAdapter;
114
+ tracer: Tracer;
115
+ inducer?: WorkflowInducer | null;
116
+ promoteAfterSuccesses?: number | null;
117
+ }) {
118
+ this.#store = args.store;
119
+ this.#tracer = args.tracer;
120
+ this.#inducer = args.inducer ?? null;
121
+ const k = args.promoteAfterSuccesses;
122
+ this.#promoteAfterSuccesses =
123
+ typeof k === 'number' && Number.isFinite(k) && k >= 1 ? Math.floor(k) : null;
124
+ }
125
+
126
+ /**
127
+ * Record the outcome of one demonstrated reuse of a procedure
128
+ * (MCON-2 part 4). A success increments the rule's persistent
129
+ * `successCount`; when `procedurePromotion.afterSuccesses` is
130
+ * configured and a QUARANTINED procedure reaches the threshold it is
131
+ * promoted through {@link validate} - the injection gate still
132
+ * applies, so a flagged text refuses promotion (surfaced as
133
+ * `refused: true`) no matter how many successes accumulate.
134
+ * Failures are observed but not persisted (no negative counter yet).
135
+ *
136
+ * Callers decide what "success" means - typically
137
+ * `checkSuccessCriteria(...)` over the procedure's stored
138
+ * `successCriteria`, or an operator's judgement.
139
+ *
140
+ * @stable
141
+ */
142
+ async recordOutcome(
143
+ scope: SessionScope,
144
+ ruleId: string,
145
+ succeeded: boolean,
146
+ ): Promise<{
147
+ readonly successCount: number;
148
+ readonly promoted: boolean;
149
+ readonly refused: boolean;
150
+ }> {
151
+ return withMemorySpan(
152
+ this.#tracer,
153
+ 'memory.write.procedural',
154
+ scope,
155
+ { 'memory.procedural.action': 'record-outcome', 'memory.procedural.rule_id': ruleId },
156
+ async (span) => {
157
+ const store = this.#store.procedural;
158
+ if (!succeeded || typeof store.recordSuccess !== 'function') {
159
+ span.setAttributes({ 'memory.procedural.outcome.counted': false });
160
+ return { successCount: 0, promoted: false, refused: false };
161
+ }
162
+ const successCount = await store.recordSuccess(ruleId);
163
+ span.setAttributes({ 'memory.procedural.outcome.success_count': successCount });
164
+ const threshold = this.#promoteAfterSuccesses;
165
+ if (threshold === null || successCount < threshold) {
166
+ return { successCount, promoted: false, refused: false };
167
+ }
168
+ const existing = (await store.list(scope)).find((rule) => rule.id === ruleId) ?? null;
169
+ if (existing === null || existing.status !== 'quarantined') {
170
+ return { successCount, promoted: false, refused: false };
171
+ }
172
+ try {
173
+ await this.validate(
174
+ scope,
175
+ ruleId,
176
+ `demonstrated-success: ${successCount} >= ${threshold}`,
177
+ );
178
+ span.setAttributes({ 'memory.procedural.outcome.promoted': true });
179
+ return { successCount, promoted: true, refused: false };
180
+ } catch (err) {
181
+ if (err instanceof QuarantinePromotionRefusedError) {
182
+ span.setAttributes({ 'memory.procedural.outcome.refused': true });
183
+ return { successCount, promoted: false, refused: true };
184
+ }
185
+ throw err;
186
+ }
187
+ },
188
+ );
189
+ }
190
+
191
+ /** Persist a rule. Returns the stored record. */
192
+ async define(scope: SessionScope, input: RuleInput): Promise<Rule> {
193
+ return withMemorySpan(this.#tracer, 'memory.write.procedural', scope, {}, async (span) => {
194
+ const now = new Date().toISOString();
195
+ const rule: Rule = {
196
+ id: newMemoryId('rule'),
197
+ kind: 'procedural',
198
+ userId: scope.userId,
199
+ ...(scope.sessionId !== undefined ? { sessionId: scope.sessionId } : {}),
200
+ ...(scope.agentId !== undefined ? { agentId: scope.agentId } : {}),
201
+ sensitivity: input.sensitivity ?? 'public',
202
+ text: input.text,
203
+ ...(input.condition !== undefined ? { condition: input.condition } : {}),
204
+ priority: input.priority ?? 50,
205
+ ...(input.tags !== undefined ? { tags: Object.freeze([...input.tags]) } : {}),
206
+ ...(input.steps !== undefined ? { steps: Object.freeze([...input.steps]) } : {}),
207
+ ...(input.variables !== undefined
208
+ ? { variables: Object.freeze([...input.variables]) }
209
+ : {}),
210
+ ...(input.successCriteria !== undefined
211
+ ? { successCriteria: Object.freeze([...input.successCriteria]) }
212
+ : {}),
213
+ ...(input.owner !== undefined ? { owner: input.owner } : {}),
214
+ createdAt: now,
215
+ updatedAt: now,
216
+ };
217
+ await this.#store.procedural.add(rule);
218
+ span.setAttributes({
219
+ 'memory.procedural.priority': rule.priority,
220
+ 'memory.procedural.text_length': input.text.length,
221
+ });
222
+ return rule;
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Induce a reusable procedure (P2-2) from a successful agent trajectory
228
+ * and store it **quarantined** + `provenance: 'induction'` (P1-4). Returns
229
+ * the stored {@link Rule}, or `null` when the trajectory was unsuccessful /
230
+ * empty or the inducer produced nothing inducible.
231
+ *
232
+ * Throws {@link ProcedureInductionNotConfiguredError} when no inducer was
233
+ * configured (`createMemory({ procedureInduction: { provider } })`).
234
+ */
235
+ async induce(
236
+ scope: SessionScope,
237
+ trajectory: Trajectory,
238
+ opts: InduceOptions = {},
239
+ ): Promise<Rule | null> {
240
+ const inducer = this.#inducer;
241
+ if (inducer === null) throw new ProcedureInductionNotConfiguredError();
242
+ return withMemorySpan(
243
+ this.#tracer,
244
+ 'memory.write.procedural',
245
+ scope,
246
+ { 'memory.procedural.action': 'induce' },
247
+ async (span) => {
248
+ const induced = await runWorkflowInduction(trajectory, inducer, {});
249
+ if (induced === null) {
250
+ span.setAttributes({ 'memory.procedural.induced': 0 });
251
+ return null;
252
+ }
253
+ const now = new Date().toISOString();
254
+ const rule: Rule = {
255
+ id: newMemoryId('rule'),
256
+ kind: 'procedural',
257
+ userId: scope.userId,
258
+ ...(scope.sessionId !== undefined ? { sessionId: scope.sessionId } : {}),
259
+ ...(scope.agentId !== undefined ? { agentId: scope.agentId } : {}),
260
+ // Induced from a run ⇒ user-derived, so 'internal' (not the public
261
+ // default for author-defined rules).
262
+ sensitivity: opts.sensitivity ?? 'internal',
263
+ text: renderProcedureText(induced),
264
+ priority: opts.priority ?? INDUCED_PRIORITY,
265
+ steps: Object.freeze([...induced.steps]),
266
+ variables: Object.freeze([...induced.variables]),
267
+ successCriteria: Object.freeze([...induced.successCriteria]),
268
+ // Highest-poisoning-risk write in the system - procedures drive
269
+ // actions, so it lands quarantined + provenance-tagged (P1-4).
270
+ provenance: 'induction' satisfies MemoryProvenance,
271
+ status: 'quarantined' satisfies MemoryStatus,
272
+ // D3: an induced procedure is the agent's own inference.
273
+ owner: 'agent',
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ };
277
+ await this.#store.procedural.add(rule);
278
+ span.setAttributes({
279
+ 'memory.procedural.induced': 1,
280
+ 'memory.procedural.steps': induced.steps.length,
281
+ 'memory.procedural.variables': induced.variables.length,
282
+ });
283
+ return rule;
284
+ },
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Convenience over {@link induce}: distil the {@link Trajectory} from a
290
+ * completed {@link RunState} (the agent's already-emitted run state) and
291
+ * induce a procedure. The success signal is `status === 'completed'`.
292
+ */
293
+ async induceFromRun(
294
+ scope: SessionScope,
295
+ run: RunState,
296
+ opts: InduceOptions = {},
297
+ ): Promise<Rule | null> {
298
+ return this.induce(scope, trajectoryFromRunState(run), opts);
299
+ }
300
+
301
+ /** Soft-delete a rule. */
302
+ async remove(scope: SessionScope, ruleId: string, reason?: string): Promise<void> {
303
+ await withMemorySpan(
304
+ this.#tracer,
305
+ 'memory.write.procedural',
306
+ scope,
307
+ { 'memory.procedural.action': 'remove', 'memory.procedural.rule_id': ruleId },
308
+ async () => {
309
+ await this.#store.procedural.remove(ruleId, reason);
310
+ },
311
+ );
312
+ }
313
+
314
+ /** List every active (non-deleted) rule for the supplied scope. */
315
+ async list(scope: SessionScope): Promise<ReadonlyArray<Rule>> {
316
+ return withMemorySpan(this.#tracer, 'memory.read.procedural', scope, {}, async (span) => {
317
+ const out = await this.#store.procedural.list(scope);
318
+ span.setAttributes({ 'memory.read.procedural.count': out.length });
319
+ return out;
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Return the rules active under `context`. Rules without a
325
+ * `condition` are always active; the bundled predicate vocabulary
326
+ * supports the literals `'always'`, `'topic=<topic>'`, and
327
+ * `'tag=<tag>'`. Anything outside that grammar is treated as
328
+ * always-active so callers do not silently lose rules.
329
+ *
330
+ * **Quarantined procedures are excluded** (P1-4 / P2-2): an induced
331
+ * procedure must not drive actions until validated, so activation - which
332
+ * feeds the system prompt - never surfaces it.
333
+ */
334
+ async activate(
335
+ scope: SessionScope,
336
+ context: RuleActivationContext = {},
337
+ ): Promise<ReadonlyArray<Rule>> {
338
+ const rules = await this.list(scope);
339
+ return rules
340
+ .filter((rule) => rule.status !== 'quarantined' && predicateMatches(rule, context))
341
+ .sort((a, b) => b.priority - a.priority);
342
+ }
343
+
344
+ /**
345
+ * Runbook content search (D3): "find the procedure for this task" -
346
+ * lexical recall over rule text, as opposed to predicate
347
+ * {@link activate}. Returns **whole validated procedures** (the full
348
+ * {@link Rule} incl. steps / variables / success criteria) so a match
349
+ * can be followed file-style rather than re-synthesized from
350
+ * fragments. Quarantined (unvalidated induced) procedures are
351
+ * excluded - they must not drive actions - unless the inspector opts
352
+ * in via `includeQuarantined`.
353
+ *
354
+ * Uses the storage adapter's FTS surface when available
355
+ * (`procedural.search`, the default `@graphorin/store-sqlite` adapter
356
+ * implements it via migration 028); otherwise degrades to an offline
357
+ * in-memory token-overlap scan over {@link list}, so custom adapters
358
+ * keep working without the index.
359
+ */
360
+ async search(
361
+ scope: SessionScope,
362
+ query: string,
363
+ opts: { readonly topK?: number; readonly includeQuarantined?: boolean } = {},
364
+ ): Promise<ReadonlyArray<MemoryHit<Rule>>> {
365
+ return withMemorySpan(
366
+ this.#tracer,
367
+ 'memory.read.procedural',
368
+ scope,
369
+ { 'memory.procedural.action': 'search' },
370
+ async (span) => {
371
+ const topK = opts.topK ?? 5;
372
+ const store = this.#store.procedural;
373
+ if (typeof store.search === 'function') {
374
+ const hits = await store.search(scope, query, {
375
+ topK,
376
+ ...(opts.includeQuarantined === true ? { includeQuarantined: true } : {}),
377
+ });
378
+ span.setAttributes({
379
+ 'memory.read.procedural.count': hits.length,
380
+ 'memory.read.procedural.search_backend': 'store',
381
+ });
382
+ return hits;
383
+ }
384
+ // Fallback: offline token-overlap scoring over list(). Coarse by
385
+ // design - it exists so the runbook surface degrades instead of
386
+ // disappearing on adapters without an FTS index.
387
+ const tokens = tokenizeForRunbook(query);
388
+ const rules = await this.list(scope);
389
+ const scored = rules
390
+ .filter((rule) => opts.includeQuarantined === true || rule.status !== 'quarantined')
391
+ .map((rule) => {
392
+ const haystack = tokenizeForRunbook(
393
+ [rule.text, ...(rule.steps ?? []), ...(rule.tags ?? [])].join(' '),
394
+ );
395
+ const overlap = tokens.filter((t) => haystack.includes(t)).length;
396
+ return { rule, overlap };
397
+ })
398
+ .filter((entry) => entry.overlap > 0)
399
+ .sort((a, b) => b.overlap - a.overlap || (a.rule.id < b.rule.id ? -1 : 1))
400
+ .slice(0, topK);
401
+ span.setAttributes({
402
+ 'memory.read.procedural.count': scored.length,
403
+ 'memory.read.procedural.search_backend': 'fallback',
404
+ });
405
+ return scored.map((entry) => ({
406
+ record: entry.rule,
407
+ score: tokens.length === 0 ? 0 : entry.overlap / tokens.length,
408
+ signals: { lexical: entry.overlap },
409
+ }));
410
+ },
411
+ );
412
+ }
413
+
414
+ /**
415
+ * Promote a quarantined (induced) procedure into `activate()` (MCON-2).
416
+ * Mirrors {@link SemanticMemory.validate}: re-derives the injection verdict
417
+ * from the stored rule text and **refuses** promotion of an injection-flagged
418
+ * procedure unless an operator passes `{ force: true }`. Induced procedures
419
+ * drive *actions*, so this gate matters most for them.
420
+ */
421
+ async validate(
422
+ scope: SessionScope,
423
+ ruleId: string,
424
+ reason?: string,
425
+ options?: { readonly force?: boolean },
426
+ ): Promise<void> {
427
+ await withMemorySpan(
428
+ this.#tracer,
429
+ 'memory.write.procedural',
430
+ scope,
431
+ { 'memory.procedural.action': 'validate', 'memory.procedural.rule_id': ruleId },
432
+ async (span) => {
433
+ const store = this.#store.procedural;
434
+ if (typeof store.setStatus !== 'function') {
435
+ throw new TypeError(
436
+ '[graphorin/memory] ProceduralMemory.validate(...) requires a storage adapter that implements `procedural.setStatus(id, status)`. ' +
437
+ 'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via ProceduralMemoryStoreExt.',
438
+ );
439
+ }
440
+ const force = options?.force === true;
441
+ // No store get-by-id for rules - list() already surfaces quarantined.
442
+ const existing = (await store.list(scope)).find((rule) => rule.id === ruleId) ?? null;
443
+ if (existing !== null && !force) {
444
+ const injection = detectMemoryInjection(existing.text);
445
+ if (injection.flagged) {
446
+ span.setAttributes({ 'memory.procedural.validate.refused': true });
447
+ throw new QuarantinePromotionRefusedError(ruleId, injection.markers);
448
+ }
449
+ }
450
+ span.setAttributes({ 'memory.procedural.validate.forced': force });
451
+ await store.setStatus(ruleId, 'active', reason, scope);
452
+ },
453
+ );
454
+ }
455
+ }
456
+
457
+ /** Render an induced procedure into the human-readable `Rule.text`. */
458
+ function renderProcedureText(procedure: InducedProcedure): string {
459
+ const lines = [procedure.title, ...procedure.steps.map((step, i) => `${i + 1}. ${step}`)];
460
+ return lines.join('\n');
461
+ }
462
+
463
+ /** Lowercased word tokens for the offline runbook-search fallback (D3). */
464
+ function tokenizeForRunbook(text: string): ReadonlyArray<string> {
465
+ return text
466
+ .toLowerCase()
467
+ .split(/[^\p{L}\p{N}]+/u)
468
+ .filter((token) => token.length > 1);
469
+ }
470
+
471
+ function predicateMatches(rule: Rule, context: RuleActivationContext): boolean {
472
+ if (rule.condition === undefined || rule.condition === 'always') return true;
473
+ if (rule.condition.startsWith('topic=')) {
474
+ const wanted = rule.condition.slice('topic='.length).trim();
475
+ return context.topic === wanted;
476
+ }
477
+ if (rule.condition.startsWith('tag=')) {
478
+ const wanted = rule.condition.slice('tag='.length).trim();
479
+ return (context.tags ?? []).includes(wanted);
480
+ }
481
+ return true;
482
+ }