@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,1729 @@
1
+ import type {
2
+ EmbedderProvider,
3
+ Fact,
4
+ MemoryHit,
5
+ MemoryOwner,
6
+ MemoryProvenance,
7
+ MemoryStatus,
8
+ Sensitivity,
9
+ SessionScope,
10
+ Tracer,
11
+ } from '@graphorin/core';
12
+ import { md5 } from '@graphorin/core';
13
+ import type { ConflictDecision, ConflictPipeline } from '../conflict/index.js';
14
+ import { DEFAULT_SALIENCE_WEIGHTS, type SalienceWeights } from '../consolidator/decay.js';
15
+ import { QuarantinePromotionRefusedError } from '../errors/index.js';
16
+ import { type EntityResolver, normalizeEntityName } from '../graph/entity-resolver.js';
17
+ import { contextualize } from '../internal/contextualize.js';
18
+ import { newMemoryId } from '../internal/id.js';
19
+ import { detectMemoryInjection } from '../internal/injection-heuristics.js';
20
+ import { withMemorySpan } from '../internal/spans.js';
21
+ import type { MemoryStoreAdapter } from '../internal/storage-adapter.js';
22
+ import { explainRecall } from '../search/explain.js';
23
+ import { pprActivation } from '../search/graph-ppr.js';
24
+ import {
25
+ DEFAULT_MAX_ITERATIONS,
26
+ type IterativeRetrievalResult,
27
+ type RetrievalGrader,
28
+ runIterativeRetrieval,
29
+ } from '../search/iterative.js';
30
+ import type { QueryTransformer } from '../search/query-transform.js';
31
+ import { fuseRrf, fuseWeighted, WeightedRRFReranker } from '../search/rrf.js';
32
+ import { trustDiscount } from '../search/trust.js';
33
+ import type { ReRanker } from '../search/types.js';
34
+
35
+ /**
36
+ * Author-time fact payload accepted by {@link SemanticMemory.remember}.
37
+ * The framework derives `id`, `kind: 'semantic'`, `userId`,
38
+ * `createdAt`, `updatedAt`, `validFrom`, the optional `embedder_id`,
39
+ * and the deduplication `hash` from this input.
40
+ *
41
+ * @stable
42
+ */
43
+ export interface FactInput {
44
+ readonly text: string;
45
+ readonly subject?: string;
46
+ readonly predicate?: string;
47
+ readonly object?: string;
48
+ readonly tags?: ReadonlyArray<string>;
49
+ readonly confidence?: number;
50
+ readonly sensitivity?: Sensitivity;
51
+ readonly validFrom?: string;
52
+ readonly validTo?: string;
53
+ readonly supersedes?: string;
54
+ /**
55
+ * Trust-provenance tag (P1-4). Writers that synthesize memory pass
56
+ * `'extraction'` / `'reflection'` so the fact lands quarantined;
57
+ * first-party writers pass `'user'` / `'tool'` (or omit it - absent ⇒
58
+ * treated as first-party `active`). The `status` is *derived* from
59
+ * this tag plus the injection heuristics; it is never author-set.
60
+ */
61
+ readonly provenance?: MemoryProvenance;
62
+ /**
63
+ * Importance hint in `[0, 1]` (X-1 / MCON-12). Feeds the multi-signal
64
+ * salience score that orders decay archiving and capacity eviction -
65
+ * higher importance ⇒ evicted later. Values are clamped to `[0, 1]`;
66
+ * non-finite values are dropped. The consolidator's extraction pass
67
+ * fills this from the model's per-fact 1-10 rating
68
+ * (`normalizeImportance`); absent ⇒ the neutral midpoint at scoring
69
+ * time.
70
+ */
71
+ readonly importance?: number;
72
+ /**
73
+ * Principal dimension (D3). The consolidator stamps `'agent'` on
74
+ * synthesized writes; user-authored writes may pass `'user'`. Absent
75
+ * (the default) leaves the column NULL - treated as `'user'` at
76
+ * filter time. Never gates default recall.
77
+ */
78
+ readonly owner?: MemoryOwner;
79
+ }
80
+
81
+ /**
82
+ * Per-list weights for {@link FusionStrategy} `'weighted'` fusion (X-2),
83
+ * keyed by retriever *kind* rather than position so they survive the
84
+ * P2-3 multi-query fan-out (which appends extra FTS / vector candidate
85
+ * lists). Each defaults to the neutral `1` (≡ RRF). The HyDE list is a
86
+ * vector list and takes the `vector` weight.
87
+ *
88
+ * @stable
89
+ */
90
+ export interface FusionWeights {
91
+ /** Weight applied to every FTS5 (lexical) candidate list. Default `1`. */
92
+ readonly fts?: number;
93
+ /** Weight applied to every vector (incl. HyDE) candidate list. Default `1`. */
94
+ readonly vector?: number;
95
+ /**
96
+ * Weight applied to the graph-expansion candidate list (D5). Default
97
+ * `1` (the previous neutral behaviour). Tune it once the graph leg's
98
+ * reliability is calibrated against labels - the roadmap's "graph as a
99
+ * first-class tunable fusion weight" (was hardcoded neutral).
100
+ */
101
+ readonly graph?: number;
102
+ /** Weight applied to the exact entity-match candidate list (D5). Default `1`. */
103
+ readonly entity?: number;
104
+ }
105
+
106
+ /**
107
+ * Score-fusion strategy for {@link SemanticMemory.search} (X-2).
108
+ *
109
+ * - `'rrf'` (the default when `fusion` is omitted) fuses the candidate
110
+ * lists through the configured reranker - the zero-tuning
111
+ * {@link RRFReranker} unless one was overridden.
112
+ * - `'weighted'` fuses through {@link WeightedRRFReranker}, scaling each
113
+ * list's reciprocal-rank contribution by its {@link FusionWeights}, for
114
+ * callers who have calibrated retriever reliability against labels (the
115
+ * P0-1 eval harness). At equal weights it reproduces RRF.
116
+ *
117
+ * @stable
118
+ */
119
+ export type FusionStrategy =
120
+ | { readonly strategy: 'rrf' }
121
+ | {
122
+ readonly strategy: 'weighted';
123
+ readonly weights: FusionWeights;
124
+ /** Override the RRF constant for the weighted fuse. Default `60`. */
125
+ readonly k?: number;
126
+ };
127
+
128
+ /**
129
+ * Per-call options accepted by {@link SemanticMemory.search}.
130
+ *
131
+ * @stable
132
+ */
133
+ /**
134
+ * Search options an operator may default at construction time via
135
+ * `createMemory({ searchDefaults })` (W-086) - the advanced-retrieval
136
+ * switches (fan-out, HyDE, graph expansion, fusion, decay) that the
137
+ * model-facing surfaces (`fact_search`, auto-recall, `deep_recall`)
138
+ * cannot reach per-call. Deliberately a `Pick` that EXCLUDES the
139
+ * trust-sensitive predicates (`includeQuarantined`, `includeSuperseded`,
140
+ * `trustWeighting`, `owner`): configuration must not be able to silently
141
+ * weaken trust gates for every caller. Per-call options always win
142
+ * key-by-key.
143
+ *
144
+ * @stable
145
+ */
146
+ export type SemanticSearchDefaults = Pick<
147
+ FactSearchOptions,
148
+ | 'multiQuery'
149
+ | 'hyde'
150
+ | 'expandHops'
151
+ | 'entityMatch'
152
+ | 'graphScoring'
153
+ | 'fusion'
154
+ | 'decay'
155
+ | 'candidateTopK'
156
+ >;
157
+
158
+ export interface FactSearchOptions {
159
+ readonly topK?: number;
160
+ readonly signal?: AbortSignal;
161
+ /** Override the per-list candidate count (default `60`). */
162
+ readonly candidateTopK?: number;
163
+ /**
164
+ * Any-of tags filter (MRET-4). A fact matches when it carries at
165
+ * least one of the requested tags; untagged facts never match.
166
+ * Applied in-store on the FTS leg and as a record-level filter on
167
+ * the fused result so every candidate leg (vector / HyDE / graph)
168
+ * obeys it.
169
+ */
170
+ readonly tags?: ReadonlyArray<string>;
171
+ /**
172
+ * Point-in-time ("as of") read. When set, only facts whose
173
+ * bi-temporal validity interval contains this instant are returned
174
+ * (`valid_from <= asOf < valid_to`, open-ended bounds allowed),
175
+ * applied at the store layer to both the FTS and vector candidate
176
+ * lists. ISO-8601. Absent ⇒ current behaviour is unchanged. P0-2.
177
+ *
178
+ * @stable
179
+ */
180
+ readonly asOf?: string;
181
+ /**
182
+ * Include quarantined facts in the result (P1-4). Defaults to
183
+ * `false` - action-driving recall (`fact_search`, auto-recall) never
184
+ * returns quarantined rows. Set `true` only for the validation /
185
+ * inspector path that surfaces quarantined facts to a human for
186
+ * promotion via {@link SemanticMemory.validate}.
187
+ *
188
+ * @stable
189
+ */
190
+ readonly includeQuarantined?: boolean;
191
+ /**
192
+ * Rank-time trust discount (C5). `'on'` (default) multiplies each
193
+ * hit's fused score by its trust factor - quarantined-but-included
194
+ * rows by `1 - quarantine` (default 0.3x), foreign-provenance rows by
195
+ * `1 - foreignProvenance` (default 0.8x); first-party active facts are
196
+ * untouched. `'off'` restores pure similarity ranking (inspector /
197
+ * calibration paths). Factors surface as the `trust` signal on hits
198
+ * and in `explainRecall`.
199
+ *
200
+ * @stable
201
+ */
202
+ readonly trustWeighting?: 'on' | 'off';
203
+ /**
204
+ * Include superseded / validity-expired facts (memory-retrieval-01).
205
+ * Defaults to `false`: a default read behaves as `asOf = now`, so a
206
+ * fact whose validity interval was closed (e.g. by `supersede`)
207
+ * never surfaces as current - what the `fact_supersede` tool
208
+ * promises. Set `true` only for inspector / audit paths. Ignored
209
+ * when an explicit {@link asOf} is supplied.
210
+ *
211
+ * @stable
212
+ */
213
+ readonly includeSuperseded?: boolean;
214
+ /**
215
+ * Optional decay-aware ranking. When set, the reranker output is
216
+ * post-multiplied by the per-fact retention curve
217
+ * `score *= exp(-elapsedDays / tauDays)` so stale facts drop in
218
+ * the result list without ever being hard-deleted (principle 8).
219
+ * Requires the storage adapter to expose
220
+ * `semantic.listForDecay?(...)` so the search can read
221
+ * `strength` + `lastAccessedAt`; adapters without the surface
222
+ * skip the boost silently.
223
+ *
224
+ * @stable
225
+ */
226
+ readonly decay?: {
227
+ readonly tauDays: number;
228
+ /** Override the wall clock (test seam). */
229
+ readonly now?: () => number;
230
+ };
231
+ /**
232
+ * Multi-query / RAG-Fusion (P2-3). When set to `N > 1` *and* a query
233
+ * transformer is configured (`createMemory({ queryTransform })`), the
234
+ * query is fanned into up to `N - 1` reworded variants via one cheap
235
+ * LLM call; each variant is retrieved (FTS + vector) and **all** lists
236
+ * are fused through the existing RRF reranker - recovering memories
237
+ * whose stored wording differs from the user's phrasing. `N` bounds
238
+ * the *total* query strings, including the original. Offline (no
239
+ * transformer, or `N <= 1`) this is a **silent no-op**: search stays
240
+ * single-shot and makes no provider call. Opt-in + retrieval-heavy, so
241
+ * reserve it for deliberate recall rather than every search.
242
+ *
243
+ * @stable
244
+ */
245
+ readonly multiQuery?: number;
246
+ /**
247
+ * HyDE - Hypothetical Document Embeddings (arXiv:2212.10496), P2-3.
248
+ * When `true` *and* both a query transformer and an embedder are
249
+ * configured, generate a short hypothetical answer, embed it, and fuse
250
+ * its vector neighbours into the result. Helps short / ambiguous
251
+ * queries but adds a generate + embed round-trip and can drift - hence
252
+ * opt-in. With no transformer (or no embedder) this is a silent no-op
253
+ * and no provider call is made.
254
+ *
255
+ * @stable
256
+ */
257
+ readonly hyde?: boolean;
258
+ /**
259
+ * Score-fusion strategy (X-2). Omitted (the default) ⇒ RRF via the
260
+ * configured reranker - behaviour is unchanged. `{ strategy:
261
+ * 'weighted', weights }` fuses through {@link WeightedRRFReranker},
262
+ * up-/down-weighting the FTS vs vector candidate lists per
263
+ * {@link FusionWeights}; reserve it for callers who have calibrated the
264
+ * weights against labels (the P0-1 eval harness). At equal weights it
265
+ * reproduces RRF.
266
+ *
267
+ * @stable
268
+ */
269
+ readonly fusion?: FusionStrategy;
270
+ /**
271
+ * One-hop graph expansion (P2-1). With `1` *and* a graph-capable
272
+ * storage adapter (`store.graph`), the facts retrieved by the lexical /
273
+ * vector candidate pass are treated as seeds: facts sharing a canonical
274
+ * entity (subject / object) are fetched via a recursive CTE and fused
275
+ * in as an extra candidate list before rerank - surfacing connected
276
+ * facts the query never matched directly ("what did the person I met in
277
+ * Tbilisi recommend?"). `0` (the default) or a graph-less adapter ⇒ a
278
+ * silent no-op; recall is unchanged. Opt-in + retrieval-heavy. `2`
279
+ * (D5) widens to two-hop expansion - pair with `graphScoring: 'ppr'`
280
+ * so distant neighbours decay instead of tying with direct ones.
281
+ *
282
+ * @stable
283
+ */
284
+ readonly expandHops?: 0 | 1 | 2;
285
+ /**
286
+ * Graph-expansion scoring (D5). `'flat'` (the default) scores every
287
+ * graph neighbour `1` (the pre-D5 behaviour). `'ppr'` uses PPR-lite
288
+ * damped spreading activation (`damping^hopDepth`) via the store's
289
+ * `expandActivation`, so a two-hop neighbour ranks below a direct one;
290
+ * a no-op without `expandHops >= 1` or a graph-capable adapter.
291
+ *
292
+ * @stable
293
+ */
294
+ readonly graphScoring?: 'flat' | 'ppr';
295
+ /**
296
+ * Exact entity-match retriever (D5). When set, the query terms are
297
+ * normalized to entity names and facts linked to a matching canonical
298
+ * entity are fused in as a precise candidate leg (`entity` fusion
299
+ * weight), distinct from the fuzzy FTS / vector legs. A no-op without
300
+ * a graph-capable adapter. Opt-in.
301
+ *
302
+ * @stable
303
+ */
304
+ readonly entityMatch?: boolean;
305
+ /**
306
+ * Retrieval-time principal filter (D3). When set, only facts whose
307
+ * `owner` is in the requested set are returned - `'user'` for
308
+ * user-stated facts, `'agent'` for the agent's own inferences,
309
+ * `'shared'` for multi-agent shared records. Rows written before the
310
+ * feature (owner NULL) are treated as `'user'`. Applied in-store on
311
+ * the FTS + vector legs and as a record-level filter on the fused
312
+ * result so the HyDE / graph legs obey it too. Absent (the default)
313
+ * ⇒ **no owner filter** - recall is byte-identical.
314
+ *
315
+ * @stable
316
+ */
317
+ readonly owner?: MemoryOwner | ReadonlyArray<MemoryOwner>;
318
+ }
319
+
320
+ /**
321
+ * Per-call options for {@link SemanticMemory.searchIterative} (P2-4) -
322
+ * the gated grade-then-reformulate loop. Extends {@link FactSearchOptions}
323
+ * (every base option applies to each retrieval pass); `topK` doubles as
324
+ * the cap on the accumulated result count.
325
+ *
326
+ * @stable
327
+ */
328
+ export interface IterativeSearchOptions extends FactSearchOptions {
329
+ /**
330
+ * Total-pass cap, clamped to `[1, 5]`. Omitted ⇒ the facade-configured
331
+ * default (`createMemory({ iterativeRetrieval: { maxIterations } })`)
332
+ * or `3`.
333
+ */
334
+ readonly maxIterations?: number;
335
+ /**
336
+ * Skip the heuristic difficulty gate and force the loop (still capped
337
+ * and still a no-op without a grader). For deliberate "deep recall"
338
+ * requests and tests.
339
+ */
340
+ readonly forceHard?: boolean;
341
+ /**
342
+ * Difficulty-gate threshold in `[0, 1]` for THIS call (W-088). The
343
+ * gate's signal lexicon is **English-only** - for non-English
344
+ * deployments lower the threshold or use {@link forceHard}. Omitted ⇒
345
+ * the facade default (`iterativeRetrieval.difficultyThreshold`) or the
346
+ * built-in `0.5`.
347
+ */
348
+ readonly difficultyThreshold?: number;
349
+ }
350
+
351
+ /**
352
+ * Outcome of {@link SemanticMemory.searchIterative}. Beyond the ranked
353
+ * `hits`, `sufficient` / `abstained` tell the caller whether the memory
354
+ * actually answered the query - `abstained: true` means it should say so
355
+ * rather than confabulate.
356
+ *
357
+ * @stable
358
+ */
359
+ export type IterativeRecallResult = IterativeRetrievalResult<MemoryHit<Fact>>;
360
+
361
+ /**
362
+ * Per-call options accepted by {@link SemanticMemory.remember}. The
363
+ * Phase 10b pipeline writes one row to `fact_conflicts` (and
364
+ * potentially one to `conflict_check_pending`) for every invocation;
365
+ * pass `pipeline: 'off'` to bypass the pipeline for a single call
366
+ * (useful for one-shot data imports).
367
+ *
368
+ * @stable
369
+ */
370
+ export interface FactRememberOptions {
371
+ readonly pipeline?: 'on' | 'off';
372
+ /** Cancellation signal forwarded to the embedder + storage layers. */
373
+ readonly signal?: AbortSignal;
374
+ /**
375
+ * Precomputed contextual-retrieval index text (P1-3, advanced). When
376
+ * supplied it overrides the instance's `'late-chunk'` computation: the
377
+ * embedding is computed from - and the FTS row indexed against - this
378
+ * text, while the canonical `text` is stored unchanged. The background
379
+ * consolidator passes this in its `'llm'` mode (the one place an LLM is
380
+ * allowed to write the situating context); first-party callers normally
381
+ * omit it and rely on the offline late-chunk default.
382
+ */
383
+ readonly indexText?: string;
384
+ /**
385
+ * Auto-promotion policy (MCON-2). When `true`, a *synthesized* write
386
+ * (consolidator extraction) that is **clean** by the injection heuristics is
387
+ * stored `active` instead of quarantined. Injection-flagged writes always
388
+ * stay quarantined - the security gate is preserved. Off by default; the
389
+ * consolidator passes it only when the operator opts in via
390
+ * `autoPromoteExtraction`. No effect on non-synthesized writes.
391
+ */
392
+ readonly autoPromoteSynthesized?: boolean;
393
+ }
394
+
395
+ /**
396
+ * Returned by {@link SemanticMemory.remember}. The `fact` is the
397
+ * stored row (which may be the *existing* fact when the pipeline
398
+ * dedups). The `decision` mirrors the pipeline outcome so callers can
399
+ * distinguish silent dedups from active inserts.
400
+ *
401
+ * @stable
402
+ */
403
+ export interface RememberOutcome {
404
+ readonly fact: Fact;
405
+ readonly decision: ConflictDecision;
406
+ /**
407
+ * Why this write landed quarantined, if it did (P1-4 / MRET-3).
408
+ * `'injection'` - the offline injection heuristics flagged the text
409
+ * (a memory-poisoning candidate). `'synthesized'` - a consolidator /
410
+ * reflection / induction write awaiting validation. Absent when the
411
+ * fact is `active` or when a dedup returned a pre-existing row.
412
+ */
413
+ readonly quarantineReason?: 'injection' | 'synthesized';
414
+ }
415
+
416
+ /**
417
+ * `SemanticMemory` - long-term factual store. Hybrid (vector + FTS5)
418
+ * search merges the two ranked lists through the configured
419
+ * {@link ReRanker} (default {@link RRFReranker} with `k = 60`).
420
+ *
421
+ * Phase 10a wrote facts straight through with MD5 deduplication;
422
+ * Phase 10b routes every `remember(...)` call through the multi-stage
423
+ * conflict resolution pipeline (DEC-117 / ADR-018 ext / RB-02). The
424
+ * pipeline can be disabled per-call (`pipeline: 'off'`) or per-`Memory`
425
+ * instance (`createMemory({ conflictPipeline: { mode: 'off' } })`).
426
+ *
427
+ * @stable
428
+ */
429
+ export class SemanticMemory {
430
+ readonly #store: MemoryStoreAdapter;
431
+ readonly #tracer: Tracer;
432
+ readonly #embedder: EmbedderProvider | null;
433
+ readonly #embedderIdProvider: () => string | null;
434
+ readonly #pipeline: ConflictPipeline | null;
435
+ readonly #contextualMode: 'off' | 'late-chunk';
436
+ readonly #queryTransformer: QueryTransformer | null;
437
+ readonly #entityResolver: EntityResolver | null;
438
+ readonly #grader: RetrievalGrader | null;
439
+ readonly #iterativeMaxIterations: number;
440
+ readonly #iterativeDifficultyThreshold: number | undefined;
441
+ #reranker: ReRanker;
442
+ readonly #trustWeights: SalienceWeights;
443
+ readonly #searchDefaults: SemanticSearchDefaults;
444
+
445
+ constructor(args: {
446
+ store: MemoryStoreAdapter;
447
+ tracer: Tracer;
448
+ embedder: EmbedderProvider | null;
449
+ embedderIdProvider: () => string | null;
450
+ reranker: ReRanker;
451
+ conflictPipeline?: ConflictPipeline;
452
+ /**
453
+ * Query transformer for multi-query / HyDE retrieval (P2-3). When
454
+ * supplied, `search(..., { multiQuery })` / `{ hyde }` opt into one
455
+ * cheap LLM call to rewrite / hypothesize the query; omitted (the
456
+ * default) ⇒ those options are silent no-ops and search stays
457
+ * offline + single-shot.
458
+ */
459
+ queryTransformer?: QueryTransformer;
460
+ /**
461
+ * Contextual-retrieval mode for the write path (P1-3). `'late-chunk'`
462
+ * (default) prepends a deterministic situating context to the text
463
+ * that is embedded + FTS-indexed, leaving the canonical `text`
464
+ * untouched; `'off'` indexes the bare text. The hot write path never
465
+ * makes an LLM call - the `'llm'` enrichment is confined to the
466
+ * background consolidator, which supplies a precomputed `indexText`.
467
+ */
468
+ contextualRetrieval?: 'off' | 'late-chunk';
469
+ /**
470
+ * Entity resolver for the relation graph (P2-1). When supplied,
471
+ * `remember(...)` resolves a fact's subject / object to canonical
472
+ * entities and links them, enabling `search(..., { expandHops: 1 })`.
473
+ * Omitted (the default) ⇒ writes carry s/p/o but form no entity
474
+ * links, and the write path stays offline + unchanged.
475
+ */
476
+ entityResolver?: EntityResolver;
477
+ /**
478
+ * Retrieval grader for the gated iterative loop (P2-4). When
479
+ * supplied, `searchIterative(...)` can grade a retrieved set and
480
+ * reformulate on hard queries; omitted (the default) ⇒
481
+ * `searchIterative` runs a single, difficulty-gated pass and makes no
482
+ * provider call.
483
+ */
484
+ grader?: RetrievalGrader;
485
+ /** Default total-pass cap for `searchIterative`. Default 3. */
486
+ iterativeMaxIterations?: number;
487
+ /**
488
+ * Default difficulty-gate threshold for `searchIterative` (W-088).
489
+ * Omitted ⇒ the gate's built-in `0.5`. Per-call
490
+ * `difficultyThreshold` overrides it.
491
+ */
492
+ iterativeDifficultyThreshold?: number;
493
+ /**
494
+ * Weights for the rank-time trust discount (C5). Reuses the
495
+ * eviction-path `SalienceWeights` semantics; defaults to
496
+ * `DEFAULT_SALIENCE_WEIGHTS`.
497
+ */
498
+ trustWeights?: SalienceWeights;
499
+ /**
500
+ * Construction-time retrieval defaults (W-086) merged under every
501
+ * `search(...)` call - see {@link SemanticSearchDefaults}. Because
502
+ * the merge happens inside `search()`, the model-facing surfaces
503
+ * (`fact_search`, auto-recall, `deep_recall`) inherit them without
504
+ * any per-surface wiring; per-call options override key-by-key (so
505
+ * e.g. `deep_recall`'s widen-pass `expandHops` still wins).
506
+ */
507
+ searchDefaults?: SemanticSearchDefaults;
508
+ }) {
509
+ this.#store = args.store;
510
+ this.#tracer = args.tracer;
511
+ this.#embedder = args.embedder;
512
+ this.#embedderIdProvider = args.embedderIdProvider;
513
+ this.#reranker = args.reranker;
514
+ this.#pipeline = args.conflictPipeline ?? null;
515
+ this.#contextualMode = args.contextualRetrieval ?? 'late-chunk';
516
+ this.#queryTransformer = args.queryTransformer ?? null;
517
+ this.#entityResolver = args.entityResolver ?? null;
518
+ this.#grader = args.grader ?? null;
519
+ this.#iterativeMaxIterations = args.iterativeMaxIterations ?? DEFAULT_MAX_ITERATIONS;
520
+ this.#iterativeDifficultyThreshold = args.iterativeDifficultyThreshold;
521
+ this.#trustWeights = args.trustWeights ?? DEFAULT_SALIENCE_WEIGHTS;
522
+ this.#searchDefaults = args.searchDefaults ?? {};
523
+ }
524
+
525
+ /** Replace the active reranker. Returns the previous instance. */
526
+ setReranker(reranker: ReRanker): ReRanker {
527
+ const previous = this.#reranker;
528
+ this.#reranker = reranker;
529
+ return previous;
530
+ }
531
+
532
+ /** Currently active reranker. */
533
+ reranker(): ReRanker {
534
+ return this.#reranker;
535
+ }
536
+
537
+ /**
538
+ * Persist a fact. Returns the stored record. Phase 10b routes every
539
+ * call through the multi-stage conflict resolution pipeline; the
540
+ * legacy straight-through path is reachable per-call via
541
+ * `{ pipeline: 'off' }` (operators may disable the pipeline globally
542
+ * via `createMemory({ conflictPipeline: { mode: 'off' } })`).
543
+ */
544
+ async remember(
545
+ scope: SessionScope,
546
+ input: FactInput,
547
+ options: FactRememberOptions = {},
548
+ ): Promise<Fact> {
549
+ const { fact } = await this.rememberWithDecision(scope, input, options);
550
+ return fact;
551
+ }
552
+
553
+ /**
554
+ * Like {@link remember} but returns the pipeline `decision` alongside
555
+ * the stored fact. Useful for callers that need to distinguish
556
+ * silent dedups (`decision.kind === 'dedup'`) from fresh inserts.
557
+ *
558
+ * @stable
559
+ */
560
+ async rememberWithDecision(
561
+ scope: SessionScope,
562
+ input: FactInput,
563
+ options: FactRememberOptions = {},
564
+ ): Promise<RememberOutcome> {
565
+ return withMemorySpan(this.#tracer, 'memory.write.semantic', scope, {}, async (span) => {
566
+ const now = new Date().toISOString();
567
+ const text = input.text;
568
+ // P1-4: derive the retrieval-trust status. Synthesized writes
569
+ // (consolidator extraction / reflection) and candidates that trip
570
+ // the offline injection heuristics land quarantined - excluded
571
+ // from default recall until a human validates them. First-party
572
+ // writes (user / tool / imported / unset) stay active. `status`
573
+ // is never author-set; it is always derived here.
574
+ const provenance = input.provenance;
575
+ const synthesized =
576
+ provenance === 'extraction' || provenance === 'reflection' || provenance === 'induction';
577
+ const injection = detectMemoryInjection(text);
578
+ // MCON-2 auto-promotion: an opted-in, injection-clean synthesized write is
579
+ // admitted `active`. Injection-flagged writes always stay quarantined.
580
+ const autoPromote = options.autoPromoteSynthesized === true;
581
+ const status: MemoryStatus = injection.flagged
582
+ ? 'quarantined'
583
+ : synthesized && !autoPromote
584
+ ? 'quarantined'
585
+ : 'active';
586
+ // MRET-3: surface *why* a fresh write was quarantined so callers
587
+ // (the fact_remember tool, harnesses) can tell an injection-flagged
588
+ // poison candidate apart from a synthesized-but-clean consolidator
589
+ // write. Injection takes precedence - it is the security-relevant
590
+ // reason and gates promotion in validate().
591
+ const quarantineReason: 'injection' | 'synthesized' | undefined =
592
+ status === 'quarantined' ? (injection.flagged ? 'injection' : 'synthesized') : undefined;
593
+ const fact: Fact = {
594
+ id: newMemoryId('fact'),
595
+ kind: 'semantic',
596
+ userId: scope.userId,
597
+ ...(scope.sessionId !== undefined ? { sessionId: scope.sessionId } : {}),
598
+ ...(scope.agentId !== undefined ? { agentId: scope.agentId } : {}),
599
+ sensitivity: input.sensitivity ?? 'internal',
600
+ text,
601
+ // P2-1: carry the s/p/o triple onto the persisted fact (it used to
602
+ // be dropped here, surviving only in the late-chunk index text).
603
+ // This is what activates the relation-graph substrate.
604
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
605
+ ...(input.predicate !== undefined ? { predicate: input.predicate } : {}),
606
+ ...(input.object !== undefined ? { object: input.object } : {}),
607
+ ...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
608
+ // X-1 / MCON-12: the importance hint that drives salience-ordered
609
+ // forgetting. Clamped to [0, 1]; non-finite ⇒ unscored.
610
+ ...(typeof input.importance === 'number' && Number.isFinite(input.importance)
611
+ ? { importance: Math.min(1, Math.max(0, input.importance)) }
612
+ : {}),
613
+ ...(input.tags !== undefined ? { tags: Object.freeze([...input.tags]) } : {}),
614
+ ...(input.validFrom !== undefined ? { validFrom: input.validFrom } : { validFrom: now }),
615
+ ...(input.validTo !== undefined ? { validTo: input.validTo } : {}),
616
+ ...(input.supersedes !== undefined ? { supersedes: input.supersedes } : {}),
617
+ ...(provenance !== undefined ? { provenance } : {}),
618
+ status,
619
+ // D3: carry the principal dimension; absent ⇒ NULL (treated 'user').
620
+ ...(input.owner !== undefined ? { owner: input.owner } : {}),
621
+ createdAt: now,
622
+ updatedAt: now,
623
+ };
624
+ const embedderId = this.#embedderIdProvider();
625
+ const embedder = this.#embedder;
626
+ // P1-3: the text that is embedded + FTS-indexed. A caller-supplied
627
+ // `indexText` (the consolidator's `'llm'` mode) wins; otherwise the
628
+ // instance mode decides between the bare text (`'off'`) and the
629
+ // offline late-chunk prefix derived from the *author-supplied*
630
+ // signals on `input` (entities / timeframe / topics). The canonical
631
+ // `fact.text` persisted below is never altered.
632
+ const indexText = this.#resolveIndexText(input, fact.text, options.indexText);
633
+ const pipelineEnabled = this.#pipeline !== null && (options.pipeline ?? 'on') === 'on';
634
+ const decision: ConflictDecision =
635
+ pipelineEnabled && this.#pipeline !== null
636
+ ? await this.#pipeline.run(
637
+ {
638
+ store: this.#store,
639
+ tracer: this.#tracer,
640
+ embedder,
641
+ embedderId,
642
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
643
+ },
644
+ fact,
645
+ )
646
+ : { kind: 'admit', stage: 'exact-dedup', reason: 'pipeline-skipped' };
647
+
648
+ span.setAttributes({
649
+ 'memory.semantic.text_length': text.length,
650
+ 'memory.semantic.hash': md5(text),
651
+ 'memory.semantic.pipeline.decision': decision.kind,
652
+ 'memory.semantic.pipeline.stage': decision.stage,
653
+ 'memory.semantic.pipeline.enabled': pipelineEnabled,
654
+ 'memory.semantic.status': status,
655
+ 'memory.semantic.contextualized': indexText !== text,
656
+ ...(provenance !== undefined ? { 'memory.semantic.provenance': provenance } : {}),
657
+ 'memory.semantic.injection_flagged': injection.flagged,
658
+ ...(injection.flagged
659
+ ? { 'memory.semantic.injection_markers': injection.markers.join(',') }
660
+ : {}),
661
+ });
662
+
663
+ const reasonField = quarantineReason !== undefined ? { quarantineReason } : {};
664
+ switch (decision.kind) {
665
+ case 'admit':
666
+ case 'pending': {
667
+ await this.#commitFact(fact, embedder, embedderId, indexText);
668
+ await this.#linkEntities(scope, fact, options.signal);
669
+ return { fact, decision, ...reasonField };
670
+ }
671
+ case 'dedup': {
672
+ const existing = await this.#fetchExisting(decision.existingId);
673
+ // The candidate was never committed; the existing row's own
674
+ // status is authoritative, so do not attach the candidate's
675
+ // quarantine reason here.
676
+ return { fact: existing ?? fact, decision };
677
+ }
678
+ case 'supersede': {
679
+ await this.#commitFact(fact, embedder, embedderId, indexText);
680
+ await this.#store.semantic.supersede(decision.existingId, fact, decision.reason);
681
+ await this.#linkEntities(scope, fact, options.signal);
682
+ return { fact, decision, ...reasonField };
683
+ }
684
+ }
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Resolve the contextual-retrieval index text (P1-3). A caller
690
+ * override (the consolidator's `'llm'` mode) takes precedence; then
691
+ * the instance mode decides. Late-chunk derives the situating context
692
+ * from the *author-supplied* signals on `input` (so an extraction's
693
+ * subject/predicate/object survive even though the persisted `Fact`
694
+ * drops them) and never from framework-defaulted fields, so a plain
695
+ * `remember({ text })` write returns the canonical text unchanged.
696
+ */
697
+ #resolveIndexText(input: FactInput, canonicalText: string, override?: string): string {
698
+ if (override !== undefined) return override;
699
+ if (this.#contextualMode === 'off') return canonicalText;
700
+ return contextualize({
701
+ text: canonicalText,
702
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
703
+ ...(input.predicate !== undefined ? { predicate: input.predicate } : {}),
704
+ ...(input.object !== undefined ? { object: input.object } : {}),
705
+ ...(input.tags !== undefined ? { tags: input.tags } : {}),
706
+ ...(input.validFrom !== undefined ? { validFrom: input.validFrom } : {}),
707
+ });
708
+ }
709
+
710
+ async #commitFact(
711
+ fact: Fact,
712
+ embedder: EmbedderProvider | null,
713
+ embedderId: string | null,
714
+ indexText: string,
715
+ ): Promise<void> {
716
+ const adapterSupportsEmbeddedWrite =
717
+ typeof this.#store.semantic.rememberWithEmbedding === 'function';
718
+ const contextualized = indexText !== fact.text;
719
+ const hasEmbeddingPath =
720
+ embedder !== null && embedderId !== null && adapterSupportsEmbeddedWrite;
721
+ if (hasEmbeddingPath) {
722
+ // Embed the *contextual* text so the vector surface agrees with the
723
+ // FTS index; the persisted `fact.text` stays canonical (P1-3). PS-10:
724
+ // stored content is the `passage` role for asymmetric (E5) embedders.
725
+ const [vector] = await embedder.embed([indexText], { taskType: 'passage' });
726
+ if (vector !== undefined && this.#store.semantic.rememberWithEmbedding !== undefined) {
727
+ await this.#store.semantic.rememberWithEmbedding(fact, {
728
+ embedding: { embedderId, vector },
729
+ ...(contextualized ? { indexText } : {}),
730
+ });
731
+ return;
732
+ }
733
+ }
734
+ // No embedder, but contextualization still needs to reach the lexical
735
+ // index - route through the extended write when the adapter supports
736
+ // it. Plain (non-contextualized) writes keep the canonical fast path.
737
+ if (
738
+ contextualized &&
739
+ adapterSupportsEmbeddedWrite &&
740
+ this.#store.semantic.rememberWithEmbedding !== undefined
741
+ ) {
742
+ await this.#store.semantic.rememberWithEmbedding(fact, { indexText });
743
+ return;
744
+ }
745
+ await this.#store.semantic.remember(fact);
746
+ }
747
+
748
+ async #fetchExisting(factId: string): Promise<Fact | null> {
749
+ if (typeof this.#store.semantic.get !== 'function') return null;
750
+ return this.#store.semantic.get(factId);
751
+ }
752
+
753
+ /**
754
+ * P2-1: resolve the fact's subject / object to canonical entities and
755
+ * link them so one-hop expansion can traverse this fact. A no-op when
756
+ * no resolver is configured (the default) or the fact has no s/p/o, and
757
+ * resilient - a resolution failure never breaks the write that just
758
+ * committed.
759
+ */
760
+ async #linkEntities(scope: SessionScope, fact: Fact, signal?: AbortSignal): Promise<void> {
761
+ if (this.#entityResolver === null) return;
762
+ if (fact.subject === undefined && fact.object === undefined) return;
763
+ try {
764
+ await this.#entityResolver.linkFact(scope, fact, {
765
+ ...(signal !== undefined ? { signal } : {}),
766
+ });
767
+ } catch {
768
+ // Graph linking is a soft enrichment - never fail a committed write.
769
+ }
770
+ }
771
+
772
+ /** Hybrid (vector + FTS5) search merged through the configured reranker. */
773
+ async search(
774
+ scope: SessionScope,
775
+ query: string,
776
+ callOpts: FactSearchOptions = {},
777
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
778
+ // W-086: construction-time retrieval defaults; per-call options win
779
+ // key-by-key. The trust predicates are not defaultable by design
780
+ // (SemanticSearchDefaults excludes them).
781
+ const opts: FactSearchOptions = { ...this.#searchDefaults, ...callOpts };
782
+ return withMemorySpan(
783
+ this.#tracer,
784
+ 'memory.search.semantic',
785
+ scope,
786
+ { 'memory.search.query_length': query.length },
787
+ async (span) => {
788
+ const candidateTopK = opts.candidateTopK ?? 60;
789
+ const finalTopK = opts.topK ?? 10;
790
+ // X-2: when weighted fusion is opted-in, each candidate list is
791
+ // weighted by its *kind* (FTS vs vector) so the weights survive
792
+ // the P2-3 fan-out below; otherwise `listWeights` is unused and
793
+ // fusion runs through the configured reranker (RRF by default).
794
+ const weighted =
795
+ opts.fusion !== undefined && opts.fusion.strategy === 'weighted' ? opts.fusion : null;
796
+ const wFts = weighted?.weights.fts ?? 1;
797
+ const wVector = weighted?.weights.vector ?? 1;
798
+ const wGraph = weighted?.weights.graph ?? 1;
799
+ const wEntity = weighted?.weights.entity ?? 1;
800
+ // P2-3: fan the query into reworded variants when opted-in *and*
801
+ // a transformer is configured; otherwise this is just `[query]`
802
+ // (single-shot, no provider call - the offline default).
803
+ const queries = await this.#expandQueries(query, opts);
804
+ // W-087: one batched embed call for the whole fan-out (null on
805
+ // the single-shot path or on failure - per-variant fallback).
806
+ const variantVectors = await this.#tryBatchEmbedQueries(queries);
807
+ // W-087: kick the HyDE LLM call off NOW so it overlaps with the
808
+ // FTS + vector legs below; #tryHyde never rejects (it returns []
809
+ // on any failure), so an early start cannot leak a rejection.
810
+ const hydeHitsPromise = this.#tryHyde(scope, query, opts, candidateTopK);
811
+ const lists: Array<ReadonlyArray<MemoryHit<Fact>>> = [];
812
+ const listWeights: number[] = [];
813
+ // MRET-13: retriever-kind labels in lockstep with `lists` so the
814
+ // rrf.<label> explanation signals stay stable across fan-outs.
815
+ const listLabels: string[] = [];
816
+ let primaryFtsCount = 0;
817
+ let primaryVectorCount = 0;
818
+ let isPrimary = true;
819
+ for (const q of queries) {
820
+ const ftsHits = await this.#store.semantic.search(scope, {
821
+ query: q,
822
+ topK: candidateTopK,
823
+ ...(opts.asOf !== undefined ? { asOf: opts.asOf } : {}),
824
+ // MRET-4: in-store any-of tags predicate on the FTS leg.
825
+ ...(opts.tags !== undefined && opts.tags.length > 0 ? { tags: opts.tags } : {}),
826
+ ...(opts.includeQuarantined === true ? { includeQuarantined: true } : {}),
827
+ ...(opts.includeSuperseded === true ? { includeSuperseded: true } : {}),
828
+ // D3: in-store principal filter on the FTS leg.
829
+ ...(opts.owner !== undefined ? { owner: opts.owner } : {}),
830
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
831
+ });
832
+ lists.push(ftsHits);
833
+ listWeights.push(wFts);
834
+ listLabels.push(`fts_${queries.indexOf(q)}`);
835
+ const preVector = variantVectors?.get(q);
836
+ const vectorHits =
837
+ preVector !== undefined
838
+ ? await this.#vectorSearchWithVector(
839
+ scope,
840
+ preVector,
841
+ candidateTopK,
842
+ opts.asOf,
843
+ opts.includeQuarantined,
844
+ opts.includeSuperseded,
845
+ opts.owner,
846
+ )
847
+ : await this.#tryVectorSearch(
848
+ scope,
849
+ q,
850
+ candidateTopK,
851
+ opts.asOf,
852
+ opts.includeQuarantined,
853
+ opts.includeSuperseded,
854
+ opts.owner,
855
+ );
856
+ if (vectorHits.length > 0) {
857
+ lists.push(vectorHits);
858
+ listWeights.push(wVector);
859
+ listLabels.push(`vector_${queries.indexOf(q)}`);
860
+ }
861
+ if (isPrimary) {
862
+ primaryFtsCount = ftsHits.length;
863
+ primaryVectorCount = vectorHits.length;
864
+ isPrimary = false;
865
+ }
866
+ }
867
+ // P2-3 HyDE: embed a hypothetical answer and fuse its neighbours
868
+ // (a vector list ⇒ it takes the `vector` weight). Started before
869
+ // the fan-out loop (W-087) - awaited here.
870
+ const hydeHits = await hydeHitsPromise;
871
+ if (hydeHits.length > 0) {
872
+ lists.push(hydeHits);
873
+ listWeights.push(wVector);
874
+ listLabels.push('hyde');
875
+ }
876
+ // P2-1: one-hop graph expansion - seed on the candidates gathered
877
+ // so far and fuse in facts that share a canonical entity (a third
878
+ // candidate kind ⇒ neutral fusion weight). A no-op without
879
+ // `expandHops` / a graph adapter.
880
+ const graphHits = await this.#tryExpandHops(scope, lists, opts, candidateTopK);
881
+ if (graphHits.length > 0) {
882
+ lists.push(graphHits);
883
+ listWeights.push(wGraph);
884
+ listLabels.push('graph');
885
+ }
886
+ // D5: exact entity-match leg - a precise "facts about <entity>"
887
+ // candidate list, fused with its own weight.
888
+ const entityHits = await this.#tryEntityMatch(scope, query, opts, candidateTopK);
889
+ if (entityHits.length > 0) {
890
+ lists.push(entityHits);
891
+ listWeights.push(wEntity);
892
+ listLabels.push('entity');
893
+ }
894
+ // X-2: weighted fusion runs through a per-call WeightedRRFReranker
895
+ // built from the per-list weights; the default stays the
896
+ // configured reranker so behaviour is unchanged when `fusion` is
897
+ // omitted.
898
+ const reranker: ReRanker =
899
+ weighted !== null
900
+ ? new WeightedRRFReranker({
901
+ weights: listWeights,
902
+ ...(weighted.k !== undefined ? { k: weighted.k } : {}),
903
+ })
904
+ : this.#reranker;
905
+ // MRET-8: when decay re-ranking is requested, fuse the FULL
906
+ // candidate pool (not just finalTopK) so a fresh fact sitting at
907
+ // position topK+1 pre-decay can still enter the final page; the
908
+ // finalTopK cut moves to AFTER decay + filters.
909
+ // memory-retrieval-03: the tags filter also runs AFTER fusion
910
+ // (the vector / HyDE / graph legs have no store-level tags
911
+ // predicate), so a tagged search must widen the same way -
912
+ // otherwise untagged candidates occupy fused ranks, get
913
+ // filtered, and the search silently returns fewer than topK.
914
+ // MRET-8 / memory-retrieval-03 / W-085: whenever ANY post-fusion
915
+ // stage can reorder or drop hits (decay, tags, owner, graph legs,
916
+ // entity match, the C5 trust discount, or quarantined-included
917
+ // reads), the reranker must return the FULL fused pool - cutting
918
+ // to finalTopK first would fix page membership before those
919
+ // stages run, so a 0.8x-discounted foreign-provenance hit could
920
+ // never be displaced by a first-party candidate at fused rank
921
+ // topK+1. trustWeighting is ON by default, making the widening
922
+ // near-always active; for a purely first-party result set the
923
+ // discount factor is 1 everywhere, so order AND membership stay
924
+ // byte-identical to the unwidened cut.
925
+ const fusedTopK =
926
+ opts.decay !== undefined ||
927
+ (opts.tags?.length ?? 0) > 0 ||
928
+ opts.owner !== undefined ||
929
+ (opts.expandHops ?? 0) > 0 ||
930
+ opts.entityMatch === true ||
931
+ opts.trustWeighting !== 'off' ||
932
+ opts.includeQuarantined === true
933
+ ? Math.max(
934
+ finalTopK,
935
+ lists.reduce((n, l) => n + l.length, 0),
936
+ )
937
+ : finalTopK;
938
+ const fused = await reranker.rerank(query, lists, {
939
+ topK: fusedTopK,
940
+ labels: listLabels,
941
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
942
+ });
943
+ const decayed = await this.#applyDecay(scope, fused, opts.decay);
944
+ // MRET-4: the vector / HyDE / graph legs have no store-level tags
945
+ // predicate - enforce the any-of filter on the fused records so
946
+ // every leg obeys it.
947
+ const filtered =
948
+ opts.tags !== undefined && opts.tags.length > 0
949
+ ? decayed.filter((h) => {
950
+ const recordTags = h.record.tags;
951
+ if (recordTags === undefined || recordTags.length === 0) return false;
952
+ return opts.tags?.some((t) => recordTags.includes(t)) === true;
953
+ })
954
+ : decayed;
955
+ // D3: the HyDE / graph legs have no store-level owner predicate -
956
+ // enforce the principal filter on the fused records so every leg
957
+ // obeys it. Rows with no stored owner count as 'user'.
958
+ const wantedOwners =
959
+ opts.owner === undefined
960
+ ? null
961
+ : typeof opts.owner === 'string'
962
+ ? [opts.owner]
963
+ : opts.owner;
964
+ const ownerFiltered =
965
+ wantedOwners !== null
966
+ ? filtered.filter((h) => wantedOwners.includes(h.record.owner ?? 'user'))
967
+ : filtered;
968
+ // C5: rank-time trust discount - quarantined-but-included and
969
+ // foreign-provenance hits are down-weighted BEFORE the final cut,
970
+ // mirroring the eviction-path securityFactor. First-party active
971
+ // facts keep factor 1, so ordinary rankings are unchanged.
972
+ const trusted =
973
+ opts.trustWeighting === 'off' ? ownerFiltered : this.#applyTrustDiscount(ownerFiltered);
974
+ const ranked = trusted.slice(0, finalTopK);
975
+ // MRET-7: recall reinforces the recalled facts - stamp
976
+ // last-accessed + bump strength so "recently accessed facts decay
977
+ // slower" actually holds. Bookkeeping only: a failure here must
978
+ // never break the read path.
979
+ if (ranked.length > 0 && typeof this.#store.semantic.markAccessed === 'function') {
980
+ try {
981
+ await this.#store.semantic.markAccessed(
982
+ ranked.map((h) => h.record.id),
983
+ undefined,
984
+ scope,
985
+ );
986
+ } catch {
987
+ // Best-effort: decay reinforcement is advisory.
988
+ }
989
+ }
990
+ const explanation = explainRecall(ranked, {
991
+ query,
992
+ rerankerId: reranker.id,
993
+ });
994
+ span.setAttributes({
995
+ // `fts_count` / `vector_count` report the *original* query's
996
+ // candidate lists, so the single-shot default reads exactly as
997
+ // before; `query_count` (≥1) and `hyde_applied` cover the
998
+ // P2-3 fan-out.
999
+ 'memory.search.semantic.fts_count': primaryFtsCount,
1000
+ 'memory.search.semantic.vector_count': primaryVectorCount,
1001
+ 'memory.search.semantic.final_count': ranked.length,
1002
+ 'memory.search.semantic.reranker_id': reranker.id,
1003
+ 'memory.search.semantic.decay_applied': opts.decay !== undefined,
1004
+ 'memory.search.semantic.query_count': queries.length,
1005
+ 'memory.search.semantic.hyde_applied': hydeHits.length > 0,
1006
+ 'memory.search.semantic.expand_hops': opts.expandHops ?? 0,
1007
+ 'memory.search.semantic.graph_count': graphHits.length,
1008
+ // X-3: per-signal recall explanation (ids + scores + signals,
1009
+ // no query text - the query is surfaced only as `query_length`
1010
+ // above to keep traces free of recall content).
1011
+ 'memory.search.semantic.explain': JSON.stringify(explanation.results),
1012
+ ...(opts.asOf !== undefined ? { 'memory.search.semantic.as_of': opts.asOf } : {}),
1013
+ ...(opts.includeQuarantined === true
1014
+ ? { 'memory.search.semantic.include_quarantined': true }
1015
+ : {}),
1016
+ });
1017
+ return ranked;
1018
+ },
1019
+ );
1020
+ }
1021
+
1022
+ /**
1023
+ * Gated, iterative ("deep") recall for hard queries (P2-4). A cheap
1024
+ * local heuristic ({@link assessQueryDifficulty}) decides whether the
1025
+ * query is even a loop candidate; simple lookups take exactly one
1026
+ * {@link search} pass and make no provider call. For a query judged
1027
+ * hard *and* with a grader configured
1028
+ * (`createMemory({ iterativeRetrieval })`), the retrieved set is graded
1029
+ * for sufficiency and, when weak, the query is reformulated and
1030
+ * retrieved again - **widening to one-hop graph expansion**
1031
+ * (`expandHops: 1`) on each reformulation pass - up to `maxIterations`
1032
+ * (hard-capped at 5). When still insufficient it returns
1033
+ * `abstained: true` so the caller can decline to answer instead of
1034
+ * confabulating.
1035
+ *
1036
+ * Without a grader (the offline default) this degrades to a single,
1037
+ * difficulty-gated `search` and never calls a provider.
1038
+ *
1039
+ * @stable
1040
+ */
1041
+ async searchIterative(
1042
+ scope: SessionScope,
1043
+ query: string,
1044
+ opts: IterativeSearchOptions = {},
1045
+ ): Promise<IterativeRecallResult> {
1046
+ // Reuse the `memory.search.semantic` span type (the inner per-pass
1047
+ // searches nest under it); iterative-specific data is namespaced under
1048
+ // the `…iterative.*` attribute prefix so no new core SpanType is
1049
+ // needed and P2-4 stays memory-only.
1050
+ return withMemorySpan(
1051
+ this.#tracer,
1052
+ 'memory.search.semantic',
1053
+ scope,
1054
+ { 'memory.search.query_length': query.length },
1055
+ async (span) => {
1056
+ const result = await runIterativeRetrieval<MemoryHit<Fact>>(
1057
+ query,
1058
+ {
1059
+ // `maxIterations` / `forceHard` ride along harmlessly (search
1060
+ // ignores unknown keys); `expandHops: 1` widens recall to the
1061
+ // P2-1 graph on reformulation passes, and the per-pass signal
1062
+ // overrides any inherited one.
1063
+ retrieve: (q, widen, signal) =>
1064
+ this.search(scope, q, {
1065
+ ...opts,
1066
+ ...(widen ? { expandHops: 1 } : {}),
1067
+ ...(signal !== undefined ? { signal } : {}),
1068
+ }),
1069
+ snippetOf: (hit) => hit.record.text,
1070
+ idOf: (hit) => hit.record.id,
1071
+ grader: this.#grader,
1072
+ // MRET-2: re-fuse the per-pass lists with RRF before the
1073
+ // final topK cut so a reformulation-pass find can outrank
1074
+ // pass-1 noise (discovery order silently dropped it).
1075
+ fuse: (lists) => fuseRrf(lists, 60),
1076
+ },
1077
+ (() => {
1078
+ // W-088: per-call threshold wins over the facade default.
1079
+ const difficultyThreshold =
1080
+ opts.difficultyThreshold ?? this.#iterativeDifficultyThreshold;
1081
+ return {
1082
+ maxIterations: opts.maxIterations ?? this.#iterativeMaxIterations,
1083
+ maxResults: opts.topK ?? 10,
1084
+ ...(opts.forceHard !== undefined ? { forceHard: opts.forceHard } : {}),
1085
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
1086
+ ...(difficultyThreshold !== undefined
1087
+ ? { difficulty: { threshold: difficultyThreshold } }
1088
+ : {}),
1089
+ };
1090
+ })(),
1091
+ );
1092
+ span.setAttributes({
1093
+ 'memory.search.semantic.iterative.gate_hard': result.gateHard,
1094
+ 'memory.search.semantic.iterative.iterations': result.iterations,
1095
+ 'memory.search.semantic.iterative.sufficient': result.sufficient,
1096
+ 'memory.search.semantic.iterative.abstained': result.abstained,
1097
+ 'memory.search.semantic.iterative.query_count': result.queries.length,
1098
+ 'memory.search.semantic.iterative.final_count': result.hits.length,
1099
+ });
1100
+ return result;
1101
+ },
1102
+ );
1103
+ }
1104
+
1105
+ /**
1106
+ * Raw vector KNN neighbours for the consolidator's reconcile
1107
+ * pre-filter (P0-3). Unlike {@link search} this skips FTS, reranking,
1108
+ * and decay so the cosine scores survive intact (the conflict-pipeline
1109
+ * zone thresholds are calibrated against them), and it **includes
1110
+ * quarantined facts** so prior synthesized memories are visible to
1111
+ * reconciliation. Returns `[]` when no embedder / vector adapter is
1112
+ * configured - the consolidator then treats every candidate as a
1113
+ * fresh `add`, degrading gracefully to the pre-reconcile behaviour.
1114
+ *
1115
+ * @stable
1116
+ */
1117
+ async neighbors(
1118
+ scope: SessionScope,
1119
+ text: string,
1120
+ opts: { readonly topK?: number } = {},
1121
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1122
+ return this.#tryVectorSearch(scope, text, opts.topK ?? 10, undefined, true);
1123
+ }
1124
+
1125
+ /** Lookup a single fact by id. Returns `null` for soft-deleted / missing. */
1126
+ async get(scope: SessionScope, factId: string): Promise<Fact | null> {
1127
+ return withMemorySpan(
1128
+ this.#tracer,
1129
+ 'memory.read.semantic',
1130
+ scope,
1131
+ { 'memory.semantic.fact_id': factId },
1132
+ async (span) => {
1133
+ if (typeof this.#store.semantic.get !== 'function') {
1134
+ throw new TypeError(
1135
+ '[graphorin/memory] SemanticMemory.get(...) requires a storage adapter that implements `semantic.get(id)`. ' +
1136
+ 'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via SemanticMemoryStoreExt.',
1137
+ );
1138
+ }
1139
+ const fact = await this.#store.semantic.get(factId);
1140
+ span.setAttributes({ 'memory.read.semantic.found': fact !== null });
1141
+ return fact;
1142
+ },
1143
+ );
1144
+ }
1145
+
1146
+ /**
1147
+ * Return the full bi-temporal supersede chain that `factId` belongs
1148
+ * to, oldest → newest, including superseded / soft-deleted rows so
1149
+ * callers can answer "how did this fact change over time". Requires
1150
+ * a storage adapter that implements
1151
+ * `SemanticMemoryStoreExt.historyOf(...)` - the default
1152
+ * `@graphorin/store-sqlite` adapter wires this through. P0-2.
1153
+ *
1154
+ * @stable
1155
+ */
1156
+ async history(scope: SessionScope, factId: string): Promise<ReadonlyArray<Fact>> {
1157
+ return withMemorySpan(
1158
+ this.#tracer,
1159
+ 'memory.read.semantic',
1160
+ scope,
1161
+ { 'memory.semantic.action': 'history', 'memory.semantic.fact_id': factId },
1162
+ async (span) => {
1163
+ if (typeof this.#store.semantic.historyOf !== 'function') {
1164
+ throw new TypeError(
1165
+ '[graphorin/memory] SemanticMemory.history(...) requires a storage adapter that implements `semantic.historyOf(scope, id)`. ' +
1166
+ 'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via SemanticMemoryStoreExt.',
1167
+ );
1168
+ }
1169
+ const chain = await this.#store.semantic.historyOf(scope, factId);
1170
+ span.setAttributes({ 'memory.read.semantic.history_length': chain.length });
1171
+ return chain;
1172
+ },
1173
+ );
1174
+ }
1175
+
1176
+ /**
1177
+ * Promote a quarantined fact to `active` (P1-4). The validation path
1178
+ * that admits a synthesized memory into action-driving recall once a
1179
+ * human (or trusted non-agent caller) has reviewed it. Writes a
1180
+ * `memory_history` audit row. Requires a storage adapter that
1181
+ * implements `SemanticMemoryStoreExt.setStatus(...)` - the default
1182
+ * `@graphorin/store-sqlite` adapter wires this through.
1183
+ *
1184
+ * MRET-3 / MST-1: promotion of a fact whose text still trips the
1185
+ * offline injection heuristics is **refused** with
1186
+ * {@link QuarantinePromotionRefusedError} - the model-facing
1187
+ * `fact_validate` tool calls this with no `force`, so a poisoned
1188
+ * memory can never be promoted by the agent itself (the one-turn
1189
+ * `fact_remember(poison)` → `fact_validate(id)` chain is closed). An
1190
+ * operator can override after review by passing `{ force: true }`
1191
+ * from a trusted (non-agent) context. Synthesized-but-clean writes
1192
+ * (consolidator / reflection) promote normally.
1193
+ *
1194
+ * @stable
1195
+ */
1196
+ async validate(
1197
+ scope: SessionScope,
1198
+ factId: string,
1199
+ reason?: string,
1200
+ options?: { readonly force?: boolean },
1201
+ ): Promise<void> {
1202
+ await withMemorySpan(
1203
+ this.#tracer,
1204
+ 'memory.write.semantic',
1205
+ scope,
1206
+ { 'memory.semantic.action': 'validate', 'memory.semantic.fact_id': factId },
1207
+ async (span) => {
1208
+ if (typeof this.#store.semantic.setStatus !== 'function') {
1209
+ throw new TypeError(
1210
+ '[graphorin/memory] SemanticMemory.validate(...) requires a storage adapter that implements `semantic.setStatus(id, status)`. ' +
1211
+ 'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via SemanticMemoryStoreExt.',
1212
+ );
1213
+ }
1214
+ const force = options?.force === true;
1215
+ // Re-derive the injection verdict from the stored (immutable)
1216
+ // text. A poison candidate stays flagged forever, so this gate
1217
+ // does not depend on persisting a quarantine reason.
1218
+ const existing = await this.#fetchExisting(factId);
1219
+ if (existing !== null && !force) {
1220
+ const injection = detectMemoryInjection(existing.text);
1221
+ if (injection.flagged) {
1222
+ span.setAttributes({
1223
+ 'memory.semantic.validate.refused': true,
1224
+ 'memory.semantic.injection_markers': injection.markers.join(','),
1225
+ });
1226
+ throw new QuarantinePromotionRefusedError(factId, injection.markers);
1227
+ }
1228
+ }
1229
+ span.setAttributes({ 'memory.semantic.validate.forced': force });
1230
+ await this.#store.semantic.setStatus(factId, 'active', reason, scope);
1231
+ // W-019: complete a PENDING supersede. If this fact was written
1232
+ // as a (quarantined) successor of an active fact, the old
1233
+ // fact's interval was deliberately left open; close it now that
1234
+ // the successor is recall-visible. Store-level supersede is an
1235
+ // upsert + COALESCE(valid_to) - idempotent, and it never
1236
+ // clobbers an interval already closed by a racing path.
1237
+ if (existing !== null && existing.supersedes !== undefined) {
1238
+ const oldFact = await this.#fetchExisting(existing.supersedes);
1239
+ const stillOpen =
1240
+ oldFact !== null && oldFact.supersededBy === undefined && oldFact.validTo === undefined;
1241
+ if (stillOpen) {
1242
+ span.setAttributes({ 'memory.semantic.validate.completed_supersede': true });
1243
+ await this.#store.semantic.supersede(
1244
+ existing.supersedes,
1245
+ { ...existing, status: 'active' },
1246
+ reason ?? 'validated-supersede',
1247
+ );
1248
+ }
1249
+ }
1250
+ },
1251
+ );
1252
+ }
1253
+
1254
+ /**
1255
+ * Mark `oldId` superseded by a new fact. Returns the new record.
1256
+ *
1257
+ * W-019 (security-first, knowledge-preserving): when the successor
1258
+ * lands QUARANTINED (the default for `extraction`/synthesized
1259
+ * provenance), the old ACTIVE fact's validity interval is NOT closed
1260
+ * - default recall keeps returning the old knowledge until the
1261
+ * successor passes {@link validate}, which completes the closure.
1262
+ * The link is recorded on the successor's `supersedes` field. With
1263
+ * `autoPromoteSynthesized` (threaded from the consolidator's
1264
+ * `autoPromoteExtraction` escape hatch) an injection-clean successor
1265
+ * is active immediately and the interval closes right away - the
1266
+ * pre-W-019 behaviour. Inverting the default (auto-activating the
1267
+ * successor) would hand a MINJA attacker instant active memory via
1268
+ * any text the reconciler classifies as an 'update'.
1269
+ */
1270
+ async supersede(
1271
+ scope: SessionScope,
1272
+ oldId: string,
1273
+ newInput: FactInput,
1274
+ reason?: string,
1275
+ options?: { readonly autoPromoteSynthesized?: boolean },
1276
+ ): Promise<{ readonly old: string; readonly new: Fact }> {
1277
+ return withMemorySpan(
1278
+ this.#tracer,
1279
+ 'memory.write.semantic',
1280
+ scope,
1281
+ { 'memory.semantic.action': 'supersede' },
1282
+ async (span) => {
1283
+ // Bypass the conflict pipeline - the explicit supersede call
1284
+ // is the user's authoritative decision; a second pipeline
1285
+ // pass would race with itself (the new fact is by definition
1286
+ // a near-dup of the old one) and could silently reroute the
1287
+ // write into a `dedup` outcome.
1288
+ const newFact = await this.remember(
1289
+ scope,
1290
+ { ...newInput, supersedes: oldId },
1291
+ {
1292
+ pipeline: 'off',
1293
+ ...(options?.autoPromoteSynthesized === true ? { autoPromoteSynthesized: true } : {}),
1294
+ },
1295
+ );
1296
+ if (newFact.status === 'quarantined') {
1297
+ // Deferred closing: the successor is not recall-visible yet,
1298
+ // so the old fact must remain. `remember` already persisted
1299
+ // the `supersedes` link on the successor row.
1300
+ span.setAttributes({ 'memory.semantic.supersede.pending': true });
1301
+ return { old: oldId, new: newFact };
1302
+ }
1303
+ await this.#store.semantic.supersede(oldId, newFact, reason);
1304
+ return { old: oldId, new: newFact };
1305
+ },
1306
+ );
1307
+ }
1308
+
1309
+ /** Soft-delete a fact (kept for replay; never hard-deleted). */
1310
+ async forget(scope: SessionScope, factId: string, reason?: string): Promise<void> {
1311
+ await withMemorySpan(
1312
+ this.#tracer,
1313
+ 'memory.write.semantic',
1314
+ scope,
1315
+ { 'memory.semantic.action': 'forget', 'memory.semantic.fact_id': factId },
1316
+ async () => {
1317
+ await this.#store.semantic.forget(factId, reason, scope);
1318
+ },
1319
+ );
1320
+ }
1321
+
1322
+ /**
1323
+ * Hard-delete a fact (GDPR path). Distinct from {@link forget}: the
1324
+ * record is removed from storage entirely instead of soft-archived.
1325
+ * Requires a storage adapter that implements
1326
+ * `SemanticMemoryStoreExt.purge(...)` - the default
1327
+ * `@graphorin/store-sqlite` adapter wires this through.
1328
+ */
1329
+ async purge(scope: SessionScope, factId: string, reason?: string): Promise<void> {
1330
+ await withMemorySpan(
1331
+ this.#tracer,
1332
+ 'memory.write.semantic',
1333
+ scope,
1334
+ { 'memory.semantic.action': 'purge', 'memory.semantic.fact_id': factId },
1335
+ async () => {
1336
+ if (typeof this.#store.semantic.purge !== 'function') {
1337
+ throw new TypeError(
1338
+ '[graphorin/memory] SemanticMemory.purge(...) requires a storage adapter that implements `semantic.purge(id)`. ' +
1339
+ 'For storage adapters without GDPR-grade hard-delete, prefer `SemanticMemory.forget(...)`.',
1340
+ );
1341
+ }
1342
+ await this.#store.semantic.purge(factId, reason, scope);
1343
+ },
1344
+ );
1345
+ }
1346
+
1347
+ /** Fuse multiple ranked lists outside of a `search()` call. */
1348
+ async fuse(
1349
+ query: string,
1350
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<Fact>>>,
1351
+ options: { topK?: number; signal?: AbortSignal } = {},
1352
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1353
+ return this.#reranker.rerank(query, lists, options);
1354
+ }
1355
+
1356
+ /** Pure-fusion helper - exposed for callers that already collected results. */
1357
+ static fuseRrf<TRecord extends Fact>(
1358
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
1359
+ k = 60,
1360
+ ): ReadonlyArray<MemoryHit<TRecord>> {
1361
+ return fuseRrf(lists, k);
1362
+ }
1363
+
1364
+ /**
1365
+ * Pure weighted-fusion helper (X-2) - like {@link SemanticMemory.fuseRrf}
1366
+ * but scales each list `i`'s reciprocal-rank contribution by
1367
+ * `weights[i]`. A missing / invalid entry defaults to `1`, so equal or
1368
+ * absent weights reproduce RRF.
1369
+ */
1370
+ static fuseWeighted<TRecord extends Fact>(
1371
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<TRecord>>>,
1372
+ weights: ReadonlyArray<number> | undefined,
1373
+ k = 60,
1374
+ ): ReadonlyArray<MemoryHit<TRecord>> {
1375
+ return fuseWeighted(lists, weights, k);
1376
+ }
1377
+
1378
+ /**
1379
+ * Multi-query expansion (P2-3). Returns the original query followed by
1380
+ * up to `multiQuery - 1` deduped reworded variants. A no-op (`[query]`)
1381
+ * when `multiQuery` is unset / `<= 1` or no transformer is configured -
1382
+ * so the default path makes no provider call. A transformer failure
1383
+ * degrades to `[query]` rather than breaking recall.
1384
+ */
1385
+ async #expandQueries(query: string, opts: FactSearchOptions): Promise<ReadonlyArray<string>> {
1386
+ const n = opts.multiQuery;
1387
+ if (n === undefined || n <= 1 || this.#queryTransformer === null) return [query];
1388
+ try {
1389
+ const variants = await this.#queryTransformer.expand(query, n - 1, {
1390
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
1391
+ });
1392
+ const seen = new Set<string>([query.trim().toLowerCase()]);
1393
+ const out: string[] = [query];
1394
+ for (const variant of variants) {
1395
+ const trimmed = variant.trim();
1396
+ const key = trimmed.toLowerCase();
1397
+ if (trimmed.length === 0 || seen.has(key)) continue;
1398
+ seen.add(key);
1399
+ out.push(trimmed);
1400
+ if (out.length >= n) break;
1401
+ }
1402
+ return out;
1403
+ } catch {
1404
+ return [query];
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * HyDE retrieval (P2-3). Generates a hypothetical answer, embeds it,
1410
+ * and returns its vector neighbours to fuse into the result. A no-op
1411
+ * (`[]`) unless `hyde` is set, a transformer is configured, **and** an
1412
+ * embedder + vector surface exist - the embedder guard is checked
1413
+ * *first* so a missing embedder skips the LLM call entirely rather than
1414
+ * generating a passage that can never be embedded.
1415
+ */
1416
+ async #tryHyde(
1417
+ scope: SessionScope,
1418
+ query: string,
1419
+ opts: FactSearchOptions,
1420
+ topK: number,
1421
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1422
+ if (opts.hyde !== true || this.#queryTransformer === null) return [];
1423
+ if (
1424
+ this.#embedder === null ||
1425
+ this.#embedderIdProvider() === null ||
1426
+ typeof this.#store.semantic.searchVector !== 'function'
1427
+ ) {
1428
+ return [];
1429
+ }
1430
+ try {
1431
+ const pseudo = await this.#queryTransformer.hypothetical(query, {
1432
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
1433
+ });
1434
+ if (pseudo === null || pseudo.trim().length === 0) return [];
1435
+ // W-144: the HyDE leg carries the SAME predicate set as the direct
1436
+ // FTS / vector / graph / entity legs - an includeSuperseded audit
1437
+ // search must not silently evaluate this leg validity-now, and the
1438
+ // owner filter belongs in-store, not post-fusion.
1439
+ return this.#tryVectorSearch(
1440
+ scope,
1441
+ pseudo,
1442
+ topK,
1443
+ opts.asOf,
1444
+ opts.includeQuarantined,
1445
+ opts.includeSuperseded,
1446
+ opts.owner,
1447
+ );
1448
+ } catch {
1449
+ return [];
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * One-hop graph expansion (P2-1). Seeds on the unique fact ids already
1455
+ * gathered into `lists` and fuses in facts sharing a canonical entity,
1456
+ * via the adapter's recursive-CTE `graph.expandOneHop`. A no-op (`[]`)
1457
+ * unless `expandHops >= 1` and the adapter exposes `graph` - and
1458
+ * resilient: a traversal error degrades to no expansion rather than
1459
+ * breaking recall. Neighbours carry a `graph` signal so explanations
1460
+ * (X-3) can attribute a hit to the hop.
1461
+ */
1462
+ async #tryExpandHops(
1463
+ scope: SessionScope,
1464
+ lists: ReadonlyArray<ReadonlyArray<MemoryHit<Fact>>>,
1465
+ opts: FactSearchOptions,
1466
+ limit: number,
1467
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1468
+ const hops = opts.expandHops ?? 0;
1469
+ const graphStore = this.#store.graph;
1470
+ if (hops < 1 || graphStore === undefined || typeof graphStore.expandOneHop !== 'function') {
1471
+ return [];
1472
+ }
1473
+ const seedIds: string[] = [];
1474
+ const seen = new Set<string>();
1475
+ for (const list of lists) {
1476
+ for (const hit of list) {
1477
+ if (!seen.has(hit.record.id)) {
1478
+ seen.add(hit.record.id);
1479
+ seedIds.push(hit.record.id);
1480
+ }
1481
+ }
1482
+ }
1483
+ if (seedIds.length === 0) return [];
1484
+ const expandOpts = {
1485
+ maxHops: hops,
1486
+ limit,
1487
+ ...(opts.includeQuarantined === true ? { includeQuarantined: true } : {}),
1488
+ ...(opts.includeSuperseded === true ? { includeSuperseded: true } : {}),
1489
+ ...(opts.asOf !== undefined ? { asOf: opts.asOf } : {}),
1490
+ };
1491
+ try {
1492
+ // D5: PPR-lite damped spreading activation when opted in AND the
1493
+ // store exposes the graded expansion; otherwise the flat one-hop.
1494
+ if (opts.graphScoring === 'ppr' && typeof graphStore.expandActivation === 'function') {
1495
+ const graded = await graphStore.expandActivation(scope, seedIds, expandOpts);
1496
+ const scores = pprActivation(graded);
1497
+ return graded.map((entry, i) => ({
1498
+ record: entry.fact,
1499
+ score: scores[i] ?? 0,
1500
+ signals: Object.freeze({ graph: scores[i] ?? 0 }),
1501
+ }));
1502
+ }
1503
+ const neighbours = await graphStore.expandOneHop(scope, seedIds, expandOpts);
1504
+ return neighbours.map((fact) => ({
1505
+ record: fact,
1506
+ score: 1,
1507
+ signals: Object.freeze({ graph: 1 }),
1508
+ }));
1509
+ } catch {
1510
+ return [];
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * D5 exact entity-match leg. Normalizes the query into candidate
1516
+ * entity names and fetches facts linked to a matching canonical
1517
+ * entity. A no-op without `entityMatch`, a graph adapter, or the
1518
+ * `factsForEntityName` store method.
1519
+ */
1520
+ async #tryEntityMatch(
1521
+ scope: SessionScope,
1522
+ query: string,
1523
+ opts: FactSearchOptions,
1524
+ limit: number,
1525
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1526
+ if (opts.entityMatch !== true) return [];
1527
+ const graphStore = this.#store.graph;
1528
+ if (graphStore === undefined || typeof graphStore.factsForEntityName !== 'function') {
1529
+ return [];
1530
+ }
1531
+ // Candidate entity names: the whole normalized query plus its
1532
+ // longer word n-grams (cheap, offline). The store's canonical index
1533
+ // makes each lookup O(1); most miss, the hit is precise.
1534
+ const normalized = normalizeEntityName(query);
1535
+ if (normalized.length === 0) return [];
1536
+ const candidates = new Set<string>([normalized]);
1537
+ const words = normalized.split(' ').filter((w) => w.length > 2);
1538
+ for (const w of words) candidates.add(w);
1539
+ const out: MemoryHit<Fact>[] = [];
1540
+ const seen = new Set<string>();
1541
+ try {
1542
+ for (const name of candidates) {
1543
+ const facts = await graphStore.factsForEntityName(scope, name, {
1544
+ limit,
1545
+ ...(opts.includeQuarantined === true ? { includeQuarantined: true } : {}),
1546
+ ...(opts.includeSuperseded === true ? { includeSuperseded: true } : {}),
1547
+ ...(opts.asOf !== undefined ? { asOf: opts.asOf } : {}),
1548
+ });
1549
+ for (const fact of facts) {
1550
+ if (seen.has(fact.id)) continue;
1551
+ seen.add(fact.id);
1552
+ out.push({ record: fact, score: 1, signals: Object.freeze({ entity: 1 }) });
1553
+ }
1554
+ }
1555
+ } catch {
1556
+ return [];
1557
+ }
1558
+ return out;
1559
+ }
1560
+
1561
+ async #tryVectorSearch(
1562
+ scope: SessionScope,
1563
+ query: string,
1564
+ topK: number,
1565
+ asOf?: string,
1566
+ includeQuarantined?: boolean,
1567
+ includeSuperseded?: boolean,
1568
+ owner?: MemoryOwner | ReadonlyArray<MemoryOwner>,
1569
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1570
+ const embedderId = this.#embedderIdProvider();
1571
+ if (
1572
+ this.#embedder === null ||
1573
+ embedderId === null ||
1574
+ typeof this.#store.semantic.searchVector !== 'function'
1575
+ ) {
1576
+ return [];
1577
+ }
1578
+ // PS-10: a search query is the `query` role for asymmetric (E5) embedders.
1579
+ const [vector] = await this.#embedder.embed([query], { taskType: 'query' });
1580
+ if (vector === undefined) return [];
1581
+ return this.#vectorSearchWithVector(
1582
+ scope,
1583
+ vector,
1584
+ topK,
1585
+ asOf,
1586
+ includeQuarantined,
1587
+ includeSuperseded,
1588
+ owner,
1589
+ );
1590
+ }
1591
+
1592
+ /**
1593
+ * W-087: the store half of {@link SemanticMemory.#tryVectorSearch} -
1594
+ * runs the KNN read against an ALREADY-COMPUTED query vector so the
1595
+ * multi-query fan-out can batch its N variant embeddings into one
1596
+ * `embed([q1..qN])` call instead of N provider round-trips.
1597
+ */
1598
+ async #vectorSearchWithVector(
1599
+ scope: SessionScope,
1600
+ vector: Float32Array,
1601
+ topK: number,
1602
+ asOf?: string,
1603
+ includeQuarantined?: boolean,
1604
+ includeSuperseded?: boolean,
1605
+ owner?: MemoryOwner | ReadonlyArray<MemoryOwner>,
1606
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1607
+ const embedderId = this.#embedderIdProvider();
1608
+ if (embedderId === null || typeof this.#store.semantic.searchVector !== 'function') {
1609
+ return [];
1610
+ }
1611
+ return this.#store.semantic.searchVector(
1612
+ scope,
1613
+ vector,
1614
+ embedderId,
1615
+ topK,
1616
+ asOf,
1617
+ includeQuarantined,
1618
+ includeSuperseded,
1619
+ owner,
1620
+ );
1621
+ }
1622
+
1623
+ /**
1624
+ * W-087: embed every fan-out variant in ONE embedder call. Returns
1625
+ * `null` - and the caller degrades to the per-variant path - when
1626
+ * there is no fan-out, no vector surface, or the batch call fails; a
1627
+ * batching failure must never fail the search.
1628
+ */
1629
+ async #tryBatchEmbedQueries(
1630
+ queries: ReadonlyArray<string>,
1631
+ ): Promise<ReadonlyMap<string, Float32Array> | null> {
1632
+ if (queries.length <= 1) return null;
1633
+ if (
1634
+ this.#embedder === null ||
1635
+ this.#embedderIdProvider() === null ||
1636
+ typeof this.#store.semantic.searchVector !== 'function'
1637
+ ) {
1638
+ return null;
1639
+ }
1640
+ try {
1641
+ const vectors = await this.#embedder.embed([...queries], { taskType: 'query' });
1642
+ if (vectors.length !== queries.length) return null;
1643
+ const byQuery = new Map<string, Float32Array>();
1644
+ queries.forEach((q, i) => {
1645
+ const v = vectors[i];
1646
+ if (v !== undefined) byQuery.set(q, v);
1647
+ });
1648
+ return byQuery;
1649
+ } catch {
1650
+ return null;
1651
+ }
1652
+ }
1653
+
1654
+ /**
1655
+ * C5: multiply each hit's fused score by its trust factor and re-sort.
1656
+ * No-ops (returns the same array) when every factor is 1.
1657
+ */
1658
+ #applyTrustDiscount(hits: ReadonlyArray<MemoryHit<Fact>>): ReadonlyArray<MemoryHit<Fact>> {
1659
+ let changed = false;
1660
+ const discounted = hits.map((hit) => {
1661
+ const factor = trustDiscount(hit.record, this.#trustWeights);
1662
+ if (factor >= 1) return hit;
1663
+ changed = true;
1664
+ return {
1665
+ record: hit.record,
1666
+ score: hit.score * factor,
1667
+ signals: Object.freeze({ ...(hit.signals ?? {}), trust: factor }),
1668
+ };
1669
+ });
1670
+ if (!changed) return hits;
1671
+ return [...discounted].sort((a, b) => b.score - a.score);
1672
+ }
1673
+
1674
+ async #applyDecay(
1675
+ scope: SessionScope,
1676
+ hits: ReadonlyArray<MemoryHit<Fact>>,
1677
+ decay: FactSearchOptions['decay'],
1678
+ ): Promise<ReadonlyArray<MemoryHit<Fact>>> {
1679
+ if (decay === undefined || hits.length === 0) return hits;
1680
+ // MRET-8: read decay columns ONLY for the hit ids (narrow IN-list)
1681
+ // instead of re-reading the scope's 1000 oldest rows per search -
1682
+ // the old window was O(scope) per query AND, being the LRU head,
1683
+ // could even miss the hits entirely.
1684
+ const semantic = this.#store.semantic;
1685
+ let rows: ReadonlyArray<{
1686
+ readonly id: string;
1687
+ readonly strength: number;
1688
+ readonly lastAccessedAt: number | null;
1689
+ readonly createdAt: number;
1690
+ }>;
1691
+ if (typeof semantic.listDecaySignals === 'function') {
1692
+ rows = await semantic.listDecaySignals(hits.map((h) => h.record.id));
1693
+ } else if (typeof semantic.listForDecay === 'function') {
1694
+ rows = await semantic.listForDecay(scope, 1000);
1695
+ } else {
1696
+ return hits;
1697
+ }
1698
+ if (rows.length === 0) return hits;
1699
+ const signals = new Map(
1700
+ rows.map((row) => [
1701
+ row.id,
1702
+ {
1703
+ strength: row.strength,
1704
+ lastAccessedAt: row.lastAccessedAt,
1705
+ createdAt: row.createdAt,
1706
+ },
1707
+ ]),
1708
+ );
1709
+ const now = (decay.now ?? Date.now)();
1710
+ const tauMs = Math.max(1, decay.tauDays) * 24 * 60 * 60 * 1000;
1711
+ const out = hits.map((hit) => {
1712
+ const sig = signals.get(hit.record.id);
1713
+ if (sig === undefined) return hit;
1714
+ const reference = sig.lastAccessedAt ?? sig.createdAt;
1715
+ const elapsed = Math.max(0, now - reference);
1716
+ const retention = Math.exp(-elapsed / (tauMs * Math.max(0.5, sig.strength)));
1717
+ // Record the decay multiplier as a signal so recall explanations
1718
+ // (X-3) can attribute the score drop to staleness, not just to
1719
+ // fusion. Hits with no decay row keep their signals untouched.
1720
+ return {
1721
+ ...hit,
1722
+ score: hit.score * retention,
1723
+ signals: Object.freeze({ ...(hit.signals ?? {}), decay: retention }),
1724
+ };
1725
+ });
1726
+ const sorted = [...out].sort((a, b) => b.score - a.score);
1727
+ return Object.freeze(sorted);
1728
+ }
1729
+ }