@fgv/ts-agent-memory 5.1.0-36 → 5.1.0-39

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 (197) hide show
  1. package/.rush/temp/{f6a88bfdd66517ccb98c2c7ae1be6e6fe9e15d38.tar.log → b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log} +116 -2
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +4 -4
  3. package/.rush/temp/operation/build/all.log +4 -4
  4. package/.rush/temp/operation/build/log-chunks.jsonl +4 -4
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/.rush/temp/shrinkwrap-deps.json +222 -221
  7. package/config/jest.config.json +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/packlets/ingest/cycleGuard.js +111 -0
  11. package/dist/packlets/ingest/cycleGuard.js.map +1 -0
  12. package/dist/packlets/ingest/hostStages.js +6 -0
  13. package/dist/packlets/ingest/hostStages.js.map +1 -0
  14. package/dist/packlets/ingest/index.js +9 -0
  15. package/dist/packlets/ingest/index.js.map +1 -0
  16. package/dist/packlets/ingest/model.js +6 -0
  17. package/dist/packlets/ingest/model.js.map +1 -0
  18. package/dist/packlets/ingest/orchestrator.js +438 -0
  19. package/dist/packlets/ingest/orchestrator.js.map +1 -0
  20. package/dist/packlets/retrieve/index.js +1 -0
  21. package/dist/packlets/retrieve/index.js.map +1 -1
  22. package/dist/packlets/retrieve/temporalRetrievers.js +172 -0
  23. package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -0
  24. package/dist/packlets/store/fileTreeMemoryStore.js +323 -41
  25. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  26. package/dist/packlets/tools/index.js +6 -0
  27. package/dist/packlets/tools/index.js.map +1 -0
  28. package/dist/packlets/tools/memoryTools.js +337 -0
  29. package/dist/packlets/tools/memoryTools.js.map +1 -0
  30. package/dist/packlets/types/identityCodec.js +115 -0
  31. package/dist/packlets/types/identityCodec.js.map +1 -1
  32. package/dist/packlets/types/index.js +1 -0
  33. package/dist/packlets/types/index.js.map +1 -1
  34. package/dist/packlets/types/temporal.js +85 -0
  35. package/dist/packlets/types/temporal.js.map +1 -0
  36. package/dist/packlets/types/writePolicy.js +96 -0
  37. package/dist/packlets/types/writePolicy.js.map +1 -1
  38. package/dist/test/unit/converters/antagonistRoundTrip.test.js +95 -0
  39. package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
  40. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +273 -0
  41. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
  42. package/dist/test/unit/ingest/cycleGuard.test.js +54 -0
  43. package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -0
  44. package/dist/test/unit/ingest/orchestrator.test.js +913 -0
  45. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -0
  46. package/dist/test/unit/retrieve/temporalRetrievers.test.js +182 -0
  47. package/dist/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
  48. package/dist/test/unit/store/antagonistTemporalBoundary.test.js +120 -0
  49. package/dist/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
  50. package/dist/test/unit/store/fileTreeMemoryStore.test.js +74 -7
  51. package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  52. package/dist/test/unit/store/temporalStore.test.js +398 -0
  53. package/dist/test/unit/store/temporalStore.test.js.map +1 -0
  54. package/dist/test/unit/tools/memoryTools.test.js +572 -0
  55. package/dist/test/unit/tools/memoryTools.test.js.map +1 -0
  56. package/dist/test/unit/types/temporalCodec.test.js +203 -0
  57. package/dist/test/unit/types/temporalCodec.test.js.map +1 -0
  58. package/dist/test/unit/types/temporalPolicy.test.js +62 -0
  59. package/dist/test/unit/types/temporalPolicy.test.js.map +1 -0
  60. package/dist/ts-agent-memory.d.ts +1042 -10
  61. package/dist/tsdoc-metadata.json +1 -1
  62. package/etc/ts-agent-memory.api.md +284 -0
  63. package/lib/index.d.ts +2 -0
  64. package/lib/index.d.ts.map +1 -1
  65. package/lib/index.js +2 -0
  66. package/lib/index.js.map +1 -1
  67. package/lib/packlets/ingest/cycleGuard.d.ts +41 -0
  68. package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -0
  69. package/lib/packlets/ingest/cycleGuard.js +115 -0
  70. package/lib/packlets/ingest/cycleGuard.js.map +1 -0
  71. package/lib/packlets/ingest/hostStages.d.ts +88 -0
  72. package/lib/packlets/ingest/hostStages.d.ts.map +1 -0
  73. package/lib/packlets/ingest/hostStages.js +7 -0
  74. package/lib/packlets/ingest/hostStages.js.map +1 -0
  75. package/lib/packlets/ingest/index.d.ts +5 -0
  76. package/lib/packlets/ingest/index.d.ts.map +1 -0
  77. package/lib/packlets/ingest/index.js +25 -0
  78. package/lib/packlets/ingest/index.js.map +1 -0
  79. package/lib/packlets/ingest/model.d.ts +177 -0
  80. package/lib/packlets/ingest/model.d.ts.map +1 -0
  81. package/lib/packlets/ingest/model.js +7 -0
  82. package/lib/packlets/ingest/model.js.map +1 -0
  83. package/lib/packlets/ingest/orchestrator.d.ts +206 -0
  84. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -0
  85. package/lib/packlets/ingest/orchestrator.js +442 -0
  86. package/lib/packlets/ingest/orchestrator.js.map +1 -0
  87. package/lib/packlets/retrieve/index.d.ts +1 -0
  88. package/lib/packlets/retrieve/index.d.ts.map +1 -1
  89. package/lib/packlets/retrieve/index.js +1 -0
  90. package/lib/packlets/retrieve/index.js.map +1 -1
  91. package/lib/packlets/retrieve/temporalRetrievers.d.ts +78 -0
  92. package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -0
  93. package/lib/packlets/retrieve/temporalRetrievers.js +178 -0
  94. package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -0
  95. package/lib/packlets/store/fileTreeMemoryStore.d.ts +118 -9
  96. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  97. package/lib/packlets/store/fileTreeMemoryStore.js +322 -40
  98. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  99. package/lib/packlets/tools/index.d.ts +2 -0
  100. package/lib/packlets/tools/index.d.ts.map +1 -0
  101. package/lib/packlets/tools/index.js +22 -0
  102. package/lib/packlets/tools/index.js.map +1 -0
  103. package/lib/packlets/tools/memoryTools.d.ts +139 -0
  104. package/lib/packlets/tools/memoryTools.d.ts.map +1 -0
  105. package/lib/packlets/tools/memoryTools.js +341 -0
  106. package/lib/packlets/tools/memoryTools.js.map +1 -0
  107. package/lib/packlets/types/identityCodec.d.ts +86 -0
  108. package/lib/packlets/types/identityCodec.d.ts.map +1 -1
  109. package/lib/packlets/types/identityCodec.js +118 -1
  110. package/lib/packlets/types/identityCodec.js.map +1 -1
  111. package/lib/packlets/types/index.d.ts +1 -0
  112. package/lib/packlets/types/index.d.ts.map +1 -1
  113. package/lib/packlets/types/index.js +1 -0
  114. package/lib/packlets/types/index.js.map +1 -1
  115. package/lib/packlets/types/temporal.d.ts +40 -0
  116. package/lib/packlets/types/temporal.d.ts.map +1 -0
  117. package/lib/packlets/types/temporal.js +92 -0
  118. package/lib/packlets/types/temporal.js.map +1 -0
  119. package/lib/packlets/types/writePolicy.d.ts +49 -0
  120. package/lib/packlets/types/writePolicy.d.ts.map +1 -1
  121. package/lib/packlets/types/writePolicy.js +98 -1
  122. package/lib/packlets/types/writePolicy.js.map +1 -1
  123. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts +10 -0
  124. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts.map +1 -0
  125. package/lib/test/unit/converters/antagonistRoundTrip.test.js +97 -0
  126. package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
  127. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts +9 -0
  128. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts.map +1 -0
  129. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +275 -0
  130. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
  131. package/lib/test/unit/ingest/cycleGuard.test.d.ts +2 -0
  132. package/lib/test/unit/ingest/cycleGuard.test.d.ts.map +1 -0
  133. package/lib/test/unit/ingest/cycleGuard.test.js +56 -0
  134. package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -0
  135. package/lib/test/unit/ingest/orchestrator.test.d.ts +2 -0
  136. package/lib/test/unit/ingest/orchestrator.test.d.ts.map +1 -0
  137. package/lib/test/unit/ingest/orchestrator.test.js +915 -0
  138. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -0
  139. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts +2 -0
  140. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts.map +1 -0
  141. package/lib/test/unit/retrieve/temporalRetrievers.test.js +184 -0
  142. package/lib/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
  143. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts +9 -0
  144. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts.map +1 -0
  145. package/lib/test/unit/store/antagonistTemporalBoundary.test.js +122 -0
  146. package/lib/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
  147. package/lib/test/unit/store/fileTreeMemoryStore.test.js +74 -7
  148. package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  149. package/lib/test/unit/store/temporalStore.test.d.ts +2 -0
  150. package/lib/test/unit/store/temporalStore.test.d.ts.map +1 -0
  151. package/lib/test/unit/store/temporalStore.test.js +400 -0
  152. package/lib/test/unit/store/temporalStore.test.js.map +1 -0
  153. package/lib/test/unit/tools/memoryTools.test.d.ts +2 -0
  154. package/lib/test/unit/tools/memoryTools.test.d.ts.map +1 -0
  155. package/lib/test/unit/tools/memoryTools.test.js +574 -0
  156. package/lib/test/unit/tools/memoryTools.test.js.map +1 -0
  157. package/lib/test/unit/types/temporalCodec.test.d.ts +2 -0
  158. package/lib/test/unit/types/temporalCodec.test.d.ts.map +1 -0
  159. package/lib/test/unit/types/temporalCodec.test.js +205 -0
  160. package/lib/test/unit/types/temporalCodec.test.js.map +1 -0
  161. package/lib/test/unit/types/temporalPolicy.test.d.ts +2 -0
  162. package/lib/test/unit/types/temporalPolicy.test.d.ts.map +1 -0
  163. package/lib/test/unit/types/temporalPolicy.test.js +64 -0
  164. package/lib/test/unit/types/temporalPolicy.test.js.map +1 -0
  165. package/package.json +19 -19
  166. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  167. package/rush-logs/ts-agent-memory.build.log +4 -4
  168. package/src/index.ts +2 -0
  169. package/src/packlets/ingest/cycleGuard.ts +142 -0
  170. package/src/packlets/ingest/hostStages.ts +111 -0
  171. package/src/packlets/ingest/index.ts +9 -0
  172. package/src/packlets/ingest/model.ts +184 -0
  173. package/src/packlets/ingest/orchestrator.ts +797 -0
  174. package/src/packlets/retrieve/index.ts +1 -0
  175. package/src/packlets/retrieve/temporalRetrievers.ts +210 -0
  176. package/src/packlets/store/fileTreeMemoryStore.ts +460 -66
  177. package/src/packlets/tools/index.ts +6 -0
  178. package/src/packlets/tools/memoryTools.ts +579 -0
  179. package/src/packlets/types/identityCodec.ts +184 -0
  180. package/src/packlets/types/index.ts +1 -0
  181. package/src/packlets/types/temporal.ts +96 -0
  182. package/src/packlets/types/writePolicy.ts +127 -0
  183. package/src/test/unit/converters/antagonistRoundTrip.test.ts +110 -0
  184. package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +362 -0
  185. package/src/test/unit/ingest/cycleGuard.test.ts +68 -0
  186. package/src/test/unit/ingest/orchestrator.test.ts +1158 -0
  187. package/src/test/unit/retrieve/temporalRetrievers.test.ts +226 -0
  188. package/src/test/unit/store/antagonistTemporalBoundary.test.ts +158 -0
  189. package/src/test/unit/store/fileTreeMemoryStore.test.ts +98 -7
  190. package/src/test/unit/store/temporalStore.test.ts +469 -0
  191. package/src/test/unit/tools/memoryTools.test.ts +771 -0
  192. package/src/test/unit/types/temporalCodec.test.ts +259 -0
  193. package/src/test/unit/types/temporalPolicy.test.ts +96 -0
  194. package/temp/build/lint/_eslint-5eVG3S6w.json +122 -46
  195. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  196. package/temp/ts-agent-memory.api.json +11984 -6314
  197. package/temp/ts-agent-memory.api.md +284 -0
@@ -0,0 +1,797 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { Hash, Logging, Result, fail, mapResults, succeed } from '@fgv/ts-utils';
7
+ import {
8
+ Convert,
9
+ EntityId,
10
+ IEdge,
11
+ IIdentityCodec,
12
+ IIdentityCodecResult,
13
+ IMemoryEnvelope,
14
+ IMemoryRecord,
15
+ IProvenance,
16
+ Kind,
17
+ LinkType,
18
+ MemoryId,
19
+ MemoryScopeKey,
20
+ Tag,
21
+ isTemporalRecord,
22
+ isVersionCurrent
23
+ } from '../types';
24
+ import { IBodyConverterRegistry } from '../converters';
25
+ import { IMemoryStore } from '../store';
26
+ import { IVectorIndex, MemoryEmbedder } from '../vector';
27
+ import { ICycleGuardEdge, assertNoCycles } from './cycleGuard';
28
+ import {
29
+ IEntityResolver,
30
+ IFactExtractor,
31
+ IMemoryClassifier,
32
+ IRelationCandidate,
33
+ IRelationExtractor
34
+ } from './hostStages';
35
+ import {
36
+ ICandidateEdge,
37
+ ICandidateRecord,
38
+ IEntityResolutionCandidate,
39
+ IIngestItem,
40
+ IIngestItemResult,
41
+ IIngestedRecordResult,
42
+ IMemoryClassification,
43
+ IngestDisposition,
44
+ ResolutionVerdict
45
+ } from './model';
46
+
47
+ /**
48
+ * The provenance source stamped on every record the ingest pipeline writes.
49
+ * @public
50
+ */
51
+ export const HOST_INGEST_PROVENANCE_SOURCE: string = 'host-ingest';
52
+
53
+ /**
54
+ * The link type whose presence on a temporal-kind candidate fires the
55
+ * contradicts→temporal-versioned interlock.
56
+ * @public
57
+ */
58
+ export const CONTRADICTS_LINK_TYPE: LinkType = 'contradicts' as LinkType;
59
+
60
+ /**
61
+ * The default stage-4 layer-2 cosine similarity threshold (design note §2). A
62
+ * near-duplicate candidate is surfaced to the {@link IEntityResolver} only when
63
+ * its nearest neighbor scores `>= 0.85`.
64
+ * @public
65
+ */
66
+ export const DEFAULT_SIMILARITY_THRESHOLD: number = 0.85;
67
+
68
+ /**
69
+ * The default top-K for stage-4 layer-2 similarity candidate generation.
70
+ * @public
71
+ */
72
+ export const DEFAULT_SIMILARITY_TOP_K: number = 5;
73
+
74
+ /**
75
+ * How the write-time cycle guard behaves: `'reject'` (default — a cycle-inducing
76
+ * edge fails the ingest) or `'off'` (no acyclicity constraint, for deployments
77
+ * whose link graphs are legitimately cyclic — e.g. mutual associative links).
78
+ * @public
79
+ */
80
+ export type CycleGuardMode = 'reject' | 'off';
81
+
82
+ /**
83
+ * The fgv-owned six-stage ingest orchestrator. Composes the host's staged
84
+ * classify / extract / (optional) resolve / relate machinery around fgv's owned
85
+ * validation boundary, dedup, edge + cycle safety, provenance stamping, and the
86
+ * contradicts→temporal interlock.
87
+ * @public
88
+ */
89
+ export interface IMemoryIngestOrchestrator {
90
+ /**
91
+ * Ingest a SINGLE item end-to-end (the first-class per-turn streaming path).
92
+ */
93
+ ingestItem(item: IIngestItem): Promise<Result<IIngestItemResult>>;
94
+
95
+ /**
96
+ * Ingest a batch of items. A convenience loop over {@link
97
+ * IMemoryIngestOrchestrator.ingestItem | ingestItem} — items are processed in
98
+ * order and the first failure aborts the batch.
99
+ */
100
+ ingestBatch(items: ReadonlyArray<IIngestItem>): Promise<Result<ReadonlyArray<IIngestItemResult>>>;
101
+ }
102
+
103
+ /**
104
+ * Parameters for {@link MemoryIngestOrchestrator.create}.
105
+ * @public
106
+ */
107
+ export interface IMemoryIngestOrchestratorCreateParams {
108
+ /** The store every write bottoms out in (stage 6). */
109
+ readonly store: IMemoryStore;
110
+ /** Per-kind body converter registry — the stage-3 typed validation boundary. */
111
+ readonly registry: IBodyConverterRegistry;
112
+ /** Per-kind identity codecs (maps a candidate `entityId` to its storage address). */
113
+ readonly codecs?: ReadonlyMap<Kind, IIdentityCodec>;
114
+ /** Default identity codec for kinds without an explicit entry. */
115
+ readonly defaultCodec?: IIdentityCodec;
116
+ /** Stage 2 — the host's classifier. */
117
+ readonly classifier: IMemoryClassifier;
118
+ /** Stage 3 — the host's fact extractor. */
119
+ readonly extractor: IFactExtractor;
120
+ /** Stage 5 — the host's relation extractor. */
121
+ readonly relationExtractor: IRelationExtractor;
122
+ /**
123
+ * Stage 4 (optional, OQ-13) — the host's entity resolver. Absent → stage-4
124
+ * dedup is exact-`{ kind, body }`-hash only (the deterministic-identity path).
125
+ */
126
+ readonly entityResolver?: IEntityResolver;
127
+ /**
128
+ * Optional vector index for stage-4 layer-2 similarity candidate-gen. Wired
129
+ * together with {@link IMemoryIngestOrchestratorCreateParams.embed | embed} AND
130
+ * {@link IMemoryIngestOrchestratorCreateParams.entityResolver | entityResolver};
131
+ * absent (or either co-requisite absent) → layer-2 is skipped and dedup is
132
+ * exact-only.
133
+ */
134
+ readonly vectorIndex?: IVectorIndex;
135
+ /** Optional embedder used to embed a candidate for layer-2 similarity search. */
136
+ readonly embed?: MemoryEmbedder;
137
+ /**
138
+ * Stage-4 layer-2 cosine threshold. Defaults to {@link
139
+ * DEFAULT_SIMILARITY_THRESHOLD} (`0.85`).
140
+ */
141
+ readonly similarityThreshold?: number;
142
+ /**
143
+ * Stage-4 layer-2 top-K. Defaults to {@link DEFAULT_SIMILARITY_TOP_K} (`5`).
144
+ */
145
+ readonly similarityTopK?: number;
146
+ /** Write-time cycle guard mode. Defaults to `'reject'`. */
147
+ readonly cycleGuard?: CycleGuardMode;
148
+ /** Diagnostic logger (defaults to a no-op). */
149
+ readonly logger?: Logging.ILogger;
150
+ }
151
+
152
+ /** The fully-wired stage-4 layer-2 dependencies (present only when all three are supplied). */
153
+ interface ISimilarityWiring {
154
+ readonly resolver: IEntityResolver;
155
+ readonly vectorIndex: IVectorIndex;
156
+ readonly embed: MemoryEmbedder;
157
+ }
158
+
159
+ /** Internal per-candidate plan threaded through the pipeline. */
160
+ interface ICandidatePlan {
161
+ readonly candidate: ICandidateRecord;
162
+ /** The write target's storage address (re-addressed to the target for merge-into). */
163
+ readonly writeAddress: IIdentityCodecResult;
164
+ /** The write target's entity id (re-addressed for merge-into). */
165
+ readonly writeEntityId: EntityId;
166
+ /** The reference id used in stage-5 edges (the write target's `idStem`). */
167
+ readonly refId: MemoryId;
168
+ /** The resolution verdict; carries the target id on its target-bearing arms. */
169
+ readonly verdict: ResolutionVerdict;
170
+ /**
171
+ * The pre-ingest target record for a `merge-into` write — its existing
172
+ * `tags` / `links` / `provenance` are UNIONed with the candidate's at stage 6
173
+ * so a wholesale-replace merge never wipes them.
174
+ */
175
+ readonly mergeTarget?: IMemoryRecord<unknown>;
176
+ }
177
+
178
+ /**
179
+ * Default {@link IMemoryIngestOrchestrator}.
180
+ * @public
181
+ */
182
+ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
183
+ private readonly _store: IMemoryStore;
184
+ private readonly _registry: IBodyConverterRegistry;
185
+ private readonly _codecs: ReadonlyMap<Kind, IIdentityCodec>;
186
+ private readonly _defaultCodec: IIdentityCodec | undefined;
187
+ private readonly _classifier: IMemoryClassifier;
188
+ private readonly _extractor: IFactExtractor;
189
+ private readonly _relationExtractor: IRelationExtractor;
190
+ private readonly _similarity: ISimilarityWiring | undefined;
191
+ private readonly _similarityThreshold: number;
192
+ private readonly _similarityTopK: number;
193
+ private readonly _cycleGuard: CycleGuardMode;
194
+ private readonly _logger: Logging.ILogger;
195
+ private readonly _hasher: Hash.Crc32Normalizer;
196
+
197
+ private constructor(params: IMemoryIngestOrchestratorCreateParams) {
198
+ this._store = params.store;
199
+ this._registry = params.registry;
200
+ this._codecs = params.codecs ?? new Map<Kind, IIdentityCodec>();
201
+ this._defaultCodec = params.defaultCodec;
202
+ this._classifier = params.classifier;
203
+ this._extractor = params.extractor;
204
+ this._relationExtractor = params.relationExtractor;
205
+ this._similarity =
206
+ params.entityResolver !== undefined && params.vectorIndex !== undefined && params.embed !== undefined
207
+ ? { resolver: params.entityResolver, vectorIndex: params.vectorIndex, embed: params.embed }
208
+ : undefined;
209
+ this._similarityThreshold = params.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
210
+ this._similarityTopK = params.similarityTopK ?? DEFAULT_SIMILARITY_TOP_K;
211
+ this._cycleGuard = params.cycleGuard ?? 'reject';
212
+ this._logger = params.logger ?? new Logging.NoOpLogger();
213
+ this._hasher = new Hash.Crc32Normalizer();
214
+ }
215
+
216
+ /** Family-convention factory. */
217
+ public static create(params: IMemoryIngestOrchestratorCreateParams): Result<MemoryIngestOrchestrator> {
218
+ return succeed(new MemoryIngestOrchestrator(params));
219
+ }
220
+
221
+ /** {@inheritDoc IMemoryIngestOrchestrator.ingestItem} */
222
+ public async ingestItem(item: IIngestItem): Promise<Result<IIngestItemResult>> {
223
+ return (await this._classify(item))
224
+ .thenOnSuccess((classification) => this._extract(item, classification))
225
+ .thenOnSuccess((candidates) => this._processCandidates(item, candidates));
226
+ }
227
+
228
+ /** {@inheritDoc IMemoryIngestOrchestrator.ingestBatch} */
229
+ public async ingestBatch(
230
+ items: ReadonlyArray<IIngestItem>
231
+ ): Promise<Result<ReadonlyArray<IIngestItemResult>>> {
232
+ const results: IIngestItemResult[] = [];
233
+ for (const item of items) {
234
+ const result: Result<IIngestItemResult> = await this.ingestItem(item);
235
+ if (result.isFailure()) {
236
+ return fail(result.message);
237
+ }
238
+ results.push(result.value);
239
+ }
240
+ return succeed(results);
241
+ }
242
+
243
+ /** Stage 2 — classify (host), normalizing a rejected promise into a Failure. */
244
+ private async _classify(item: IIngestItem): Promise<Result<IMemoryClassification>> {
245
+ return this._capture(() => this._classifier.classify(item), `ingest '${item.id}': classify`);
246
+ }
247
+
248
+ /** Stage 3 — extract (host), normalizing a rejected promise into a Failure. */
249
+ private async _extract(
250
+ item: IIngestItem,
251
+ classification: IMemoryClassification
252
+ ): Promise<Result<ReadonlyArray<ICandidateRecord>>> {
253
+ return this._capture(() => this._extractor.extract(item, classification), `ingest '${item.id}': extract`);
254
+ }
255
+
256
+ /**
257
+ * Stages 3b-6 over the extracted candidates: validate bodies, resolve/dedup
258
+ * (stage 4), relate + cycle guard (stage 5), and load-with-provenance (stage 6).
259
+ */
260
+ private async _processCandidates(
261
+ item: IIngestItem,
262
+ candidates: ReadonlyArray<ICandidateRecord>
263
+ ): Promise<Result<IIngestItemResult>> {
264
+ // Snapshot the store once; stage-4 resolution and the cycle guard reason over
265
+ // the pre-ingest state.
266
+ const snapshotResult: Result<ReadonlyArray<IMemoryRecord<unknown>>> = await this._store.list();
267
+ if (snapshotResult.isFailure()) {
268
+ return fail(`ingest '${item.id}': failed to snapshot store: ${snapshotResult.message}`);
269
+ }
270
+ const snapshot: ReadonlyArray<IMemoryRecord<unknown>> = snapshotResult.value;
271
+ const byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>> = MemoryIngestOrchestrator._indexById(snapshot);
272
+
273
+ // Stage 3b + 4: validate each body and resolve a verdict/plan.
274
+ const plans: ICandidatePlan[] = [];
275
+ for (const candidate of candidates) {
276
+ const planResult: Result<ICandidatePlan> = await this._planCandidate(item, candidate, snapshot, byId);
277
+ if (planResult.isFailure()) {
278
+ return fail(planResult.message);
279
+ }
280
+ plans.push(planResult.value);
281
+ }
282
+
283
+ // Stage 5: relate over the writable candidates, validate + cycle-guard.
284
+ const writablePlans: ICandidatePlan[] = plans.filter((plan) => plan.verdict.verdict !== 'duplicate-of');
285
+ const edgesResult: Result<ReadonlyArray<ICandidateEdge>> = await this._relate(
286
+ item,
287
+ writablePlans,
288
+ snapshot,
289
+ byId
290
+ );
291
+ if (edgesResult.isFailure()) {
292
+ return fail(edgesResult.message);
293
+ }
294
+ const edges: ReadonlyArray<ICandidateEdge> = edgesResult.value;
295
+
296
+ // Stage 6: load-with-provenance, in extraction order.
297
+ const records: IIngestedRecordResult[] = [];
298
+ for (const plan of plans) {
299
+ const outcome: Result<IIngestedRecordResult> = await this._loadCandidate(item, plan, edges);
300
+ if (outcome.isFailure()) {
301
+ return fail(outcome.message);
302
+ }
303
+ records.push(outcome.value);
304
+ }
305
+ return succeed({ item, records });
306
+ }
307
+
308
+ /** Stage 3b + 4 for one candidate: validate body, resolve address, resolve verdict. */
309
+ private async _planCandidate(
310
+ item: IIngestItem,
311
+ candidate: ICandidateRecord,
312
+ snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
313
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
314
+ ): Promise<Result<ICandidatePlan>> {
315
+ const kind: Kind = candidate.envelope.kind;
316
+ // Stage 3b: the typed validation boundary — no unchecked host body reaches the store.
317
+ const bodyResult: Result<string> = this._registry
318
+ .convert(kind, candidate.body)
319
+ .withErrorFormat((msg) => `ingest '${item.id}': candidate body for kind '${kind}' is invalid: ${msg}`)
320
+ .onSuccess(() => MemoryIngestOrchestrator._asStringBody(item, candidate));
321
+ if (bodyResult.isFailure()) {
322
+ return fail(bodyResult.message);
323
+ }
324
+ const body: string = bodyResult.value;
325
+
326
+ const addrResult: Result<IIdentityCodecResult> = this._resolveAddress(candidate.envelope.entityId, kind);
327
+ if (addrResult.isFailure()) {
328
+ return fail(`ingest '${item.id}': ${addrResult.message}`);
329
+ }
330
+ const addr: IIdentityCodecResult = addrResult.value;
331
+
332
+ return (await this._resolveVerdict(candidate, kind, body, addr, snapshot, byId)).onSuccess((verdict) =>
333
+ this._planFromVerdict(item, candidate, addr, verdict, byId)
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Turn a resolved verdict into a candidate plan. A `new` verdict writes under the
339
+ * candidate's own address. Every TARGET-bearing verdict
340
+ * (`duplicate-of` / `supersede` / `merge-into`) is validated uniformly: the
341
+ * target must be a real store record (fgv owns validation — a non-compliant host
342
+ * resolver never smuggles a bogus id through) AND its kind must equal the
343
+ * candidate's kind (a cross-kind target would write to the wrong scope). Only
344
+ * `merge-into` re-addresses the write to the target's entity.
345
+ */
346
+ private _planFromVerdict(
347
+ item: IIngestItem,
348
+ candidate: ICandidateRecord,
349
+ addr: IIdentityCodecResult,
350
+ verdict: ResolutionVerdict,
351
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
352
+ ): Result<ICandidatePlan> {
353
+ if (verdict.verdict === 'new') {
354
+ return Convert.memoryId.convert(addr.idStem).onSuccess((refId) =>
355
+ succeed({
356
+ candidate,
357
+ writeAddress: addr,
358
+ writeEntityId: candidate.envelope.entityId,
359
+ refId,
360
+ verdict
361
+ })
362
+ );
363
+ }
364
+ // duplicate-of | supersede | merge-into: the target must exist and share the
365
+ // candidate's kind.
366
+ const target: IMemoryRecord<unknown> | undefined = byId.get(verdict.target);
367
+ if (target === undefined) {
368
+ return fail(
369
+ `ingest '${item.id}': ${verdict.verdict} target '${verdict.target}' does not exist in the store`
370
+ );
371
+ }
372
+ if (target.envelope.kind !== candidate.envelope.kind) {
373
+ return fail(
374
+ `ingest '${item.id}': ${verdict.verdict} target '${verdict.target}' is kind '${target.envelope.kind}' but the candidate is kind '${candidate.envelope.kind}'`
375
+ );
376
+ }
377
+ if (verdict.verdict === 'merge-into') {
378
+ // Re-address the write to the target's entity, carrying the target record so
379
+ // stage 6 can UNION its existing tags/links (never overwrite them).
380
+ const targetEntityId: EntityId = target.envelope.entityId;
381
+ return this._resolveAddress(targetEntityId, target.envelope.kind)
382
+ .withErrorFormat((msg) => `ingest '${item.id}': ${msg}`)
383
+ .onSuccess((targetAddr) =>
384
+ Convert.memoryId.convert(targetAddr.idStem).onSuccess((refId) =>
385
+ succeed({
386
+ candidate,
387
+ writeAddress: targetAddr,
388
+ writeEntityId: targetEntityId,
389
+ refId,
390
+ verdict,
391
+ mergeTarget: target
392
+ })
393
+ )
394
+ );
395
+ }
396
+ // duplicate-of | supersede: write under the candidate's own address.
397
+ return Convert.memoryId.convert(addr.idStem).onSuccess((refId) =>
398
+ succeed({
399
+ candidate,
400
+ writeAddress: addr,
401
+ writeEntityId: candidate.envelope.entityId,
402
+ refId,
403
+ verdict
404
+ })
405
+ );
406
+ }
407
+
408
+ /**
409
+ * Stage 4 — resolve a dedup verdict. Layer 1: an exact `{ kind, body }` match in
410
+ * the candidate's scope is a `duplicate-of` (design note §1). Layer 2 (only when
411
+ * a resolver + vector index + embedder are all wired): embed the candidate,
412
+ * surface over-threshold neighbors, and dispatch to the {@link IEntityResolver}.
413
+ * Otherwise the verdict is `new` (the exact-only fall-back path).
414
+ */
415
+ private async _resolveVerdict(
416
+ candidate: ICandidateRecord,
417
+ kind: Kind,
418
+ body: string,
419
+ addr: IIdentityCodecResult,
420
+ snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
421
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
422
+ ): Promise<Result<ResolutionVerdict>> {
423
+ return this._findExactMatch(kind, body, addr.scope, snapshot).thenOnSuccess(async (matchId) => {
424
+ if (matchId !== undefined) {
425
+ return succeed({ verdict: 'duplicate-of', target: matchId });
426
+ }
427
+ const layer2: ISimilarityWiring | undefined = this._similarity;
428
+ if (layer2 === undefined) {
429
+ // No layer-2: exact-only fall-back (the deterministic-identity host path).
430
+ return succeed({ verdict: 'new' });
431
+ }
432
+ return this._resolveViaSimilarity(candidate, addr, body, layer2, byId);
433
+ });
434
+ }
435
+
436
+ /** Layer-2 similarity candidate-gen + resolver dispatch. */
437
+ private async _resolveViaSimilarity(
438
+ candidate: ICandidateRecord,
439
+ addr: IIdentityCodecResult,
440
+ body: string,
441
+ wiring: ISimilarityWiring,
442
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
443
+ ): Promise<Result<ResolutionVerdict>> {
444
+ return MemoryIngestOrchestrator._provisionalRecord(candidate, addr.idStem, body).thenOnSuccess(
445
+ (provisional) => this._resolveViaSimilarityEmbedded(candidate, addr, wiring, byId, provisional)
446
+ );
447
+ }
448
+
449
+ /** Layer-2 continuation once the candidate has a provisional record to embed. */
450
+ private async _resolveViaSimilarityEmbedded(
451
+ candidate: ICandidateRecord,
452
+ addr: IIdentityCodecResult,
453
+ wiring: ISimilarityWiring,
454
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>,
455
+ provisional: IMemoryRecord<unknown>
456
+ ): Promise<Result<ResolutionVerdict>> {
457
+ const embedded: Result<Float32Array> = await this._capture(
458
+ () => wiring.embed(provisional),
459
+ `ingest '${candidate.envelope.entityId}': embed candidate`
460
+ );
461
+ if (embedded.isFailure()) {
462
+ return fail(embedded.message);
463
+ }
464
+ const queried = await this._capture(
465
+ () => wiring.vectorIndex.query(embedded.value, this._similarityTopK),
466
+ `ingest '${candidate.envelope.entityId}': similarity query`
467
+ );
468
+ if (queried.isFailure()) {
469
+ return fail(queried.message);
470
+ }
471
+ const similar: IEntityResolutionCandidate[] = [];
472
+ for (const hit of queried.value) {
473
+ if (hit.score < this._similarityThreshold || hit.id === addr.idStem) {
474
+ continue;
475
+ }
476
+ const record: IMemoryRecord<unknown> | undefined = byId.get(hit.id);
477
+ if (record !== undefined) {
478
+ similar.push({ id: hit.id, record, score: hit.score });
479
+ }
480
+ }
481
+ if (similar.length === 0) {
482
+ return succeed({ verdict: 'new' });
483
+ }
484
+ return this._capture(
485
+ () => wiring.resolver.resolve(candidate, similar),
486
+ `ingest '${candidate.envelope.entityId}': resolve`
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Find an existing record in `scope` whose `{ kind, body }` hash matches the
492
+ * candidate's (layer-1 exact dedup). Invalidated temporal versions are excluded
493
+ * — only a live (non-temporal or current) record deduplicates a candidate.
494
+ */
495
+ private _findExactMatch(
496
+ kind: Kind,
497
+ body: string,
498
+ scope: MemoryScopeKey,
499
+ snapshot: ReadonlyArray<IMemoryRecord<unknown>>
500
+ ): Result<MemoryId | undefined> {
501
+ // Same-kind, same-scope, LIVE (non-temporal or current) records are the exact
502
+ // cohort. `_resolveAddress(...).map(...).orDefault()` collapses an unresolved
503
+ // codec to a non-matching scope with no explicit failure branch.
504
+ const cohort: ReadonlyArray<IMemoryRecord<unknown>> = snapshot.filter(
505
+ (record) =>
506
+ record.envelope.kind === kind &&
507
+ !(isTemporalRecord(record) && !isVersionCurrent(record)) &&
508
+ this._resolveAddress(record.envelope.entityId, record.envelope.kind)
509
+ .onSuccess((addr) => succeed(addr.scope))
510
+ .orDefault() === scope
511
+ );
512
+ return this._exactKey(kind, body).onSuccess((key) =>
513
+ mapResults(
514
+ cohort.map((record) =>
515
+ MemoryIngestOrchestrator._recordBodyString(record).onSuccess((recordBody) =>
516
+ this._exactKey(record.envelope.kind, recordBody).onSuccess((recordKey) =>
517
+ succeed({ id: record.envelope.id, key: recordKey })
518
+ )
519
+ )
520
+ )
521
+ ).onSuccess((keyed) => succeed(keyed.find((entry) => entry.key === key)?.id))
522
+ );
523
+ }
524
+
525
+ /**
526
+ * The body of a persisted record, required to be a string (the store persists
527
+ * only string bodies). Fails loudly rather than blind-casting an `unknown` body
528
+ * into the exact-dedup hash — a non-string existing body is a store-integrity
529
+ * fault, surfaced with context, not a silent miscompute.
530
+ */
531
+ private static _recordBodyString(record: IMemoryRecord<unknown>): Result<string> {
532
+ if (typeof record.body !== 'string') {
533
+ return fail(
534
+ `ingest: stored record '${record.envelope.id}' has a non-string body (got ${typeof record.body})`
535
+ );
536
+ }
537
+ return succeed(record.body);
538
+ }
539
+
540
+ /** The stage-4 exact-dedup key over `{ kind, body }` (design note §1). */
541
+ private _exactKey(kind: Kind, body: string): Result<string> {
542
+ return this._hasher.computeHash({ kind, body });
543
+ }
544
+
545
+ /** Stage 5 — relate (host), validate edges, and run the write-time cycle guard. */
546
+ private async _relate(
547
+ item: IIngestItem,
548
+ writablePlans: ReadonlyArray<ICandidatePlan>,
549
+ snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
550
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
551
+ ): Promise<Result<ReadonlyArray<ICandidateEdge>>> {
552
+ const relationCandidates: IRelationCandidate[] = writablePlans.map((plan) => ({
553
+ candidate: plan.candidate,
554
+ id: plan.refId
555
+ }));
556
+ const proposed: Result<ReadonlyArray<ICandidateEdge>> = await this._capture(
557
+ () => this._relationExtractor.relate({ item, candidates: relationCandidates }),
558
+ `ingest '${item.id}': relate`
559
+ );
560
+ if (proposed.isFailure()) {
561
+ return proposed;
562
+ }
563
+ const refIds: ReadonlySet<string> = new Set<string>(writablePlans.map((plan) => plan.refId));
564
+ const validation: Result<true> = this._validateEdges(item, proposed.value, refIds, byId);
565
+ if (validation.isFailure()) {
566
+ return fail(validation.message);
567
+ }
568
+ if (this._cycleGuard === 'reject') {
569
+ const guard: Result<true> = assertNoCycles(
570
+ MemoryIngestOrchestrator._existingEdges(snapshot),
571
+ proposed.value.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type }))
572
+ );
573
+ if (guard.isFailure()) {
574
+ return fail(`ingest '${item.id}': ${guard.message}`);
575
+ }
576
+ }
577
+ return succeed(proposed.value);
578
+ }
579
+
580
+ /**
581
+ * Validate stage-5 edges: each `source` must be a candidate being written; each
582
+ * `target` must resolve to a sibling candidate or an existing store record.
583
+ */
584
+ private _validateEdges(
585
+ item: IIngestItem,
586
+ edges: ReadonlyArray<ICandidateEdge>,
587
+ refIds: ReadonlySet<string>,
588
+ byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
589
+ ): Result<true> {
590
+ return mapResults(
591
+ edges.map((edge) => {
592
+ if (!refIds.has(edge.source)) {
593
+ return fail(`ingest '${item.id}': edge source '${edge.source}' is not a candidate being written`);
594
+ }
595
+ if (!refIds.has(edge.edge.target) && byId.get(edge.edge.target) === undefined) {
596
+ return fail(
597
+ `ingest '${item.id}': edge target '${edge.edge.target}' resolves to neither a sibling candidate nor an existing record`
598
+ );
599
+ }
600
+ return succeed(true);
601
+ })
602
+ ).onSuccess(() => succeed(true));
603
+ }
604
+
605
+ /** Stage 6 — stamp provenance + edges, admit through the store, record the outcome. */
606
+ private async _loadCandidate(
607
+ item: IIngestItem,
608
+ plan: ICandidatePlan,
609
+ allEdges: ReadonlyArray<ICandidateEdge>
610
+ ): Promise<Result<IIngestedRecordResult>> {
611
+ const myEdges: ReadonlyArray<ICandidateEdge> = allEdges.filter((e) => e.source === plan.refId);
612
+ if (plan.verdict.verdict === 'duplicate-of') {
613
+ // No write: the existing target satisfied the candidate. The target id is a
614
+ // typed field of the narrowed `duplicate-of` verdict — no cast, no guard.
615
+ return succeed({
616
+ candidate: plan.candidate,
617
+ resolution: plan.verdict,
618
+ disposition: 'deduped',
619
+ id: plan.verdict.target,
620
+ edges: myEdges
621
+ });
622
+ }
623
+ return this._buildRecord(item, plan, myEdges).thenOnSuccess(async (record) =>
624
+ (await this._store.put(record))
625
+ .withErrorFormat((msg) => `ingest '${item.id}': write failed: ${msg}`)
626
+ .onSuccess((persisted) => {
627
+ const disposition: IngestDisposition = plan.verdict.verdict === 'merge-into' ? 'merged' : 'written';
628
+ const interlock: 'temporal-versioned' | undefined =
629
+ plan.writeAddress.isVersioned && myEdges.some((e) => e.edge.type === CONTRADICTS_LINK_TYPE)
630
+ ? 'temporal-versioned'
631
+ : undefined;
632
+ return succeed({
633
+ candidate: plan.candidate,
634
+ resolution: plan.verdict,
635
+ disposition,
636
+ id: persisted.envelope.id,
637
+ record: persisted,
638
+ edges: myEdges,
639
+ ...(interlock !== undefined ? { interlock } : {})
640
+ });
641
+ })
642
+ );
643
+ }
644
+
645
+ /** Build the fully-stamped record to persist (provenance + edges + placeholder txn fields). */
646
+ private _buildRecord(
647
+ item: IIngestItem,
648
+ plan: ICandidatePlan,
649
+ myEdges: ReadonlyArray<ICandidateEdge>
650
+ ): Result<IMemoryRecord<unknown>> {
651
+ const base: Omit<IMemoryEnvelope, 'id' | 'seq' | 'contentHash' | 'created' | 'updated'> =
652
+ plan.candidate.envelope;
653
+ // For merge-into the store's `applyUpdate` REPLACES array fields wholesale, so
654
+ // the persisted envelope must already carry the UNION of the target's existing
655
+ // tags/links/provenance and the candidate's — otherwise the merge silently
656
+ // wipes the target's prior links/tags.
657
+ const target: IMemoryRecord<unknown> | undefined = plan.mergeTarget;
658
+ const priorLinks: ReadonlyArray<IEdge> = target !== undefined ? target.envelope.links : [];
659
+ const priorTags: ReadonlyArray<Tag> = target !== undefined ? target.envelope.tags : [];
660
+ const priorProvenance: IProvenance = target !== undefined ? target.envelope.provenance : { source: '' };
661
+ return Convert.memoryId.convert(plan.writeAddress.idStem).onSuccess((id) =>
662
+ // Dedup the combined links by canonical edge key: the target's prior links, the
663
+ // candidate's own links, and the stage-5 edges (two candidates that resolve
664
+ // merge-into the SAME target share a `refId` — hence the same `myEdges` — and a
665
+ // relation extractor may repeat an edge) each appear exactly once.
666
+ this._dedupEdges([...priorLinks, ...base.links, ...myEdges.map((e) => e.edge)]).onSuccess((links) => {
667
+ const provenance: IProvenance = {
668
+ // Preserve the target's provenance extension keys, overlay the candidate's,
669
+ // then stamp the host-ingest source + derivedFrom (target present only for
670
+ // merge-into; otherwise the seed is inert).
671
+ ...(target !== undefined ? priorProvenance : {}),
672
+ ...base.provenance,
673
+ source: HOST_INGEST_PROVENANCE_SOURCE,
674
+ ...(item.sourceId !== undefined ? { derivedFrom: item.sourceId } : {})
675
+ };
676
+ const envelope: IMemoryEnvelope = {
677
+ ...base,
678
+ id,
679
+ entityId: plan.writeEntityId,
680
+ seq: 0,
681
+ contentHash: '',
682
+ created: 0,
683
+ updated: 0,
684
+ provenance,
685
+ tags: MemoryIngestOrchestrator._unionTags(priorTags, base.tags),
686
+ links
687
+ };
688
+ return succeed({ envelope, body: plan.candidate.body });
689
+ })
690
+ );
691
+ }
692
+
693
+ /** Union two tag lists, de-duplicated, preserving first-occurrence order. */
694
+ private static _unionTags(prior: ReadonlyArray<Tag>, incoming: ReadonlyArray<Tag>): ReadonlyArray<Tag> {
695
+ const seen: Set<string> = new Set<string>();
696
+ const out: Tag[] = [];
697
+ for (const tag of [...prior, ...incoming]) {
698
+ if (!seen.has(tag)) {
699
+ seen.add(tag);
700
+ out.push(tag);
701
+ }
702
+ }
703
+ return out;
704
+ }
705
+
706
+ /** De-duplicate a link list by canonical edge hash, preserving first-occurrence order. */
707
+ private _dedupEdges(edges: ReadonlyArray<IEdge>): Result<ReadonlyArray<IEdge>> {
708
+ const seen: Set<string> = new Set<string>();
709
+ const deduped: IEdge[] = [];
710
+ return mapResults(
711
+ edges.map((edge) =>
712
+ this._hasher.computeHash(edge).onSuccess((key) => {
713
+ if (!seen.has(key)) {
714
+ seen.add(key);
715
+ deduped.push(edge);
716
+ }
717
+ return succeed(key);
718
+ })
719
+ )
720
+ ).onSuccess(() => succeed(deduped));
721
+ }
722
+
723
+ /** Resolve a `(kind, entityId)` to its storage address via the registered codec. */
724
+ private _resolveAddress(entityId: EntityId, kind: Kind): Result<IIdentityCodecResult> {
725
+ const codec: IIdentityCodec | undefined = this._codecs.get(kind) ?? this._defaultCodec;
726
+ if (codec === undefined) {
727
+ return fail(`no identity codec registered for kind '${kind}'`);
728
+ }
729
+ return codec.encode(entityId);
730
+ }
731
+
732
+ /** Run a host hook, normalizing a thrown/rejected hook into a Failure (never throws across the seam). */
733
+ private async _capture<T>(op: () => Promise<Result<T>>, label: string): Promise<Result<T>> {
734
+ try {
735
+ return await op();
736
+ } catch (err) {
737
+ const message: string = `${label} threw: ${String(err)}`;
738
+ this._logger.warn(message);
739
+ return fail(message);
740
+ }
741
+ }
742
+
743
+ /** Index a record snapshot by id (last write wins on an id collision across scopes). */
744
+ private static _indexById(
745
+ records: ReadonlyArray<IMemoryRecord<unknown>>
746
+ ): ReadonlyMap<MemoryId, IMemoryRecord<unknown>> {
747
+ const byId: Map<MemoryId, IMemoryRecord<unknown>> = new Map<MemoryId, IMemoryRecord<unknown>>();
748
+ for (const record of records) {
749
+ byId.set(record.envelope.id, record);
750
+ }
751
+ return byId;
752
+ }
753
+
754
+ /** Every existing outbound edge in the snapshot, as cycle-guard edges. */
755
+ private static _existingEdges(
756
+ records: ReadonlyArray<IMemoryRecord<unknown>>
757
+ ): ReadonlyArray<ICycleGuardEdge> {
758
+ const edges: ICycleGuardEdge[] = [];
759
+ for (const record of records) {
760
+ for (const edge of record.envelope.links) {
761
+ edges.push({ source: record.envelope.id, target: edge.target, type: edge.type });
762
+ }
763
+ }
764
+ return edges;
765
+ }
766
+
767
+ /** A provisional record for embedding a candidate (placeholder txn-time fields). */
768
+ private static _provisionalRecord(
769
+ candidate: ICandidateRecord,
770
+ idStem: string,
771
+ body: string
772
+ ): Result<IMemoryRecord<unknown>> {
773
+ return Convert.memoryId.convert(idStem).onSuccess((id) => {
774
+ const envelope: IMemoryEnvelope = {
775
+ ...candidate.envelope,
776
+ id,
777
+ seq: 0,
778
+ contentHash: '',
779
+ created: 0,
780
+ updated: 0
781
+ };
782
+ return succeed({ envelope, body });
783
+ });
784
+ }
785
+
786
+ /** Require a candidate body to be a string (the store persists only string bodies). */
787
+ private static _asStringBody(item: IIngestItem, candidate: ICandidateRecord): Result<string> {
788
+ if (typeof candidate.body !== 'string') {
789
+ return fail(
790
+ `ingest '${item.id}': candidate body for kind '${
791
+ candidate.envelope.kind
792
+ }' must be a string (got ${typeof candidate.body})`
793
+ );
794
+ }
795
+ return succeed(candidate.body);
796
+ }
797
+ }