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

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 (210) hide show
  1. package/.rush/temp/{b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log → cbbdbe09515171b4eba8f2592be2dace1e4e8142.tar.log} +14 -2
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +2 -2
  3. package/.rush/temp/operation/build/all.log +2 -2
  4. package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/dist/packlets/converters/envelopeConverter.js +17 -3
  7. package/dist/packlets/converters/envelopeConverter.js.map +1 -1
  8. package/dist/packlets/index/memoryIndex.js +58 -10
  9. package/dist/packlets/index/memoryIndex.js.map +1 -1
  10. package/dist/packlets/ingest/cycleGuard.js +13 -6
  11. package/dist/packlets/ingest/cycleGuard.js.map +1 -1
  12. package/dist/packlets/ingest/hostStages.js.map +1 -1
  13. package/dist/packlets/ingest/model.js.map +1 -1
  14. package/dist/packlets/ingest/orchestrator.js +94 -49
  15. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  16. package/dist/packlets/retrieve/hybridRetriever.js +13 -2
  17. package/dist/packlets/retrieve/hybridRetriever.js.map +1 -1
  18. package/dist/packlets/retrieve/linkTraversalRetriever.js +46 -57
  19. package/dist/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  20. package/dist/packlets/retrieve/recencyRetriever.js +3 -3
  21. package/dist/packlets/retrieve/recencyRetriever.js.map +1 -1
  22. package/dist/packlets/retrieve/retriever.js +50 -7
  23. package/dist/packlets/retrieve/retriever.js.map +1 -1
  24. package/dist/packlets/retrieve/semanticRetriever.js +9 -3
  25. package/dist/packlets/retrieve/semanticRetriever.js.map +1 -1
  26. package/dist/packlets/retrieve/structuredFilterRetriever.js +3 -3
  27. package/dist/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  28. package/dist/packlets/retrieve/tagRetriever.js +3 -3
  29. package/dist/packlets/retrieve/tagRetriever.js.map +1 -1
  30. package/dist/packlets/retrieve/temporalRetrievers.js +3 -3
  31. package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -1
  32. package/dist/packlets/store/fileTreeMemoryStore.js +72 -16
  33. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  34. package/dist/packlets/tools/memoryTools.js +104 -21
  35. package/dist/packlets/tools/memoryTools.js.map +1 -1
  36. package/dist/packlets/types/envelope.js +13 -1
  37. package/dist/packlets/types/envelope.js.map +1 -1
  38. package/dist/packlets/vector/inMemoryCosineIndex.js +22 -17
  39. package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  40. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  41. package/dist/test/unit/converters/antagonistRoundTrip.test.js +3 -3
  42. package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -1
  43. package/dist/test/unit/converters/envelopeConverter.test.js +125 -8
  44. package/dist/test/unit/converters/envelopeConverter.test.js.map +1 -1
  45. package/dist/test/unit/index/memoryIndex.test.js +87 -25
  46. package/dist/test/unit/index/memoryIndex.test.js.map +1 -1
  47. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +20 -16
  48. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -1
  49. package/dist/test/unit/ingest/cycleGuard.test.js +28 -1
  50. package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -1
  51. package/dist/test/unit/ingest/orchestrator.test.js +187 -45
  52. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
  53. package/dist/test/unit/retrieve/linkTraversalRetriever.test.js +106 -31
  54. package/dist/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -1
  55. package/dist/test/unit/retrieve/retrievers.test.js +299 -37
  56. package/dist/test/unit/retrieve/retrievers.test.js.map +1 -1
  57. package/dist/test/unit/store/embedOnWrite.test.js +69 -12
  58. package/dist/test/unit/store/embedOnWrite.test.js.map +1 -1
  59. package/dist/test/unit/store/listScoped.test.js +109 -0
  60. package/dist/test/unit/store/listScoped.test.js.map +1 -0
  61. package/dist/test/unit/store/rankAxis.test.js +254 -0
  62. package/dist/test/unit/store/rankAxis.test.js.map +1 -0
  63. package/dist/test/unit/tools/memoryTools.test.js +280 -11
  64. package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
  65. package/dist/test/unit/types/writePolicy.test.js +9 -2
  66. package/dist/test/unit/types/writePolicy.test.js.map +1 -1
  67. package/dist/test/unit/vector/inMemoryCosineIndex.test.js +95 -35
  68. package/dist/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -1
  69. package/dist/test/unit/vector/vectorIndex.test.js +24 -15
  70. package/dist/test/unit/vector/vectorIndex.test.js.map +1 -1
  71. package/dist/ts-agent-memory.d.ts +384 -97
  72. package/etc/ts-agent-memory.api.md +64 -22
  73. package/lib/packlets/converters/envelopeConverter.d.ts +8 -1
  74. package/lib/packlets/converters/envelopeConverter.d.ts.map +1 -1
  75. package/lib/packlets/converters/envelopeConverter.js +18 -4
  76. package/lib/packlets/converters/envelopeConverter.js.map +1 -1
  77. package/lib/packlets/index/memoryIndex.d.ts +42 -10
  78. package/lib/packlets/index/memoryIndex.d.ts.map +1 -1
  79. package/lib/packlets/index/memoryIndex.js +58 -10
  80. package/lib/packlets/index/memoryIndex.js.map +1 -1
  81. package/lib/packlets/ingest/cycleGuard.d.ts +5 -5
  82. package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -1
  83. package/lib/packlets/ingest/cycleGuard.js +13 -6
  84. package/lib/packlets/ingest/cycleGuard.js.map +1 -1
  85. package/lib/packlets/ingest/hostStages.d.ts +3 -3
  86. package/lib/packlets/ingest/hostStages.d.ts.map +1 -1
  87. package/lib/packlets/ingest/hostStages.js.map +1 -1
  88. package/lib/packlets/ingest/model.d.ts +25 -14
  89. package/lib/packlets/ingest/model.d.ts.map +1 -1
  90. package/lib/packlets/ingest/model.js.map +1 -1
  91. package/lib/packlets/ingest/orchestrator.d.ts +18 -4
  92. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  93. package/lib/packlets/ingest/orchestrator.js +93 -48
  94. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  95. package/lib/packlets/retrieve/hybridRetriever.d.ts.map +1 -1
  96. package/lib/packlets/retrieve/hybridRetriever.js +12 -1
  97. package/lib/packlets/retrieve/hybridRetriever.js.map +1 -1
  98. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts +18 -23
  99. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts.map +1 -1
  100. package/lib/packlets/retrieve/linkTraversalRetriever.js +45 -56
  101. package/lib/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  102. package/lib/packlets/retrieve/recencyRetriever.js +2 -2
  103. package/lib/packlets/retrieve/recencyRetriever.js.map +1 -1
  104. package/lib/packlets/retrieve/retriever.d.ts +70 -11
  105. package/lib/packlets/retrieve/retriever.d.ts.map +1 -1
  106. package/lib/packlets/retrieve/retriever.js +52 -7
  107. package/lib/packlets/retrieve/retriever.js.map +1 -1
  108. package/lib/packlets/retrieve/semanticRetriever.d.ts.map +1 -1
  109. package/lib/packlets/retrieve/semanticRetriever.js +9 -3
  110. package/lib/packlets/retrieve/semanticRetriever.js.map +1 -1
  111. package/lib/packlets/retrieve/structuredFilterRetriever.js +2 -2
  112. package/lib/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  113. package/lib/packlets/retrieve/tagRetriever.js +2 -2
  114. package/lib/packlets/retrieve/tagRetriever.js.map +1 -1
  115. package/lib/packlets/retrieve/temporalRetrievers.js +3 -3
  116. package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -1
  117. package/lib/packlets/store/fileTreeMemoryStore.d.ts +57 -3
  118. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  119. package/lib/packlets/store/fileTreeMemoryStore.js +72 -16
  120. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  121. package/lib/packlets/tools/memoryTools.d.ts +24 -0
  122. package/lib/packlets/tools/memoryTools.d.ts.map +1 -1
  123. package/lib/packlets/tools/memoryTools.js +104 -21
  124. package/lib/packlets/tools/memoryTools.js.map +1 -1
  125. package/lib/packlets/types/envelope.d.ts +61 -8
  126. package/lib/packlets/types/envelope.d.ts.map +1 -1
  127. package/lib/packlets/types/envelope.js +14 -0
  128. package/lib/packlets/types/envelope.js.map +1 -1
  129. package/lib/packlets/vector/inMemoryCosineIndex.d.ts +9 -5
  130. package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -1
  131. package/lib/packlets/vector/inMemoryCosineIndex.js +22 -17
  132. package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  133. package/lib/packlets/vector/vectorIndex.d.ts +46 -20
  134. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  135. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  136. package/lib/test/unit/converters/antagonistRoundTrip.test.js +3 -3
  137. package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -1
  138. package/lib/test/unit/converters/envelopeConverter.test.js +124 -7
  139. package/lib/test/unit/converters/envelopeConverter.test.js.map +1 -1
  140. package/lib/test/unit/index/memoryIndex.test.js +86 -24
  141. package/lib/test/unit/index/memoryIndex.test.js.map +1 -1
  142. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +20 -16
  143. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -1
  144. package/lib/test/unit/ingest/cycleGuard.test.js +28 -1
  145. package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -1
  146. package/lib/test/unit/ingest/orchestrator.test.js +186 -44
  147. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
  148. package/lib/test/unit/retrieve/linkTraversalRetriever.test.js +106 -31
  149. package/lib/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -1
  150. package/lib/test/unit/retrieve/retrievers.test.js +298 -36
  151. package/lib/test/unit/retrieve/retrievers.test.js.map +1 -1
  152. package/lib/test/unit/store/embedOnWrite.test.js +68 -11
  153. package/lib/test/unit/store/embedOnWrite.test.js.map +1 -1
  154. package/lib/test/unit/store/listScoped.test.d.ts +2 -0
  155. package/lib/test/unit/store/listScoped.test.d.ts.map +1 -0
  156. package/lib/test/unit/store/listScoped.test.js +111 -0
  157. package/lib/test/unit/store/listScoped.test.js.map +1 -0
  158. package/lib/test/unit/store/rankAxis.test.d.ts +2 -0
  159. package/lib/test/unit/store/rankAxis.test.d.ts.map +1 -0
  160. package/lib/test/unit/store/rankAxis.test.js +256 -0
  161. package/lib/test/unit/store/rankAxis.test.js.map +1 -0
  162. package/lib/test/unit/tools/memoryTools.test.js +280 -11
  163. package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
  164. package/lib/test/unit/types/writePolicy.test.js +9 -2
  165. package/lib/test/unit/types/writePolicy.test.js.map +1 -1
  166. package/lib/test/unit/vector/inMemoryCosineIndex.test.js +95 -35
  167. package/lib/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -1
  168. package/lib/test/unit/vector/vectorIndex.test.js +24 -15
  169. package/lib/test/unit/vector/vectorIndex.test.js.map +1 -1
  170. package/package.json +7 -7
  171. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  172. package/rush-logs/ts-agent-memory.build.log +2 -2
  173. package/src/packlets/converters/envelopeConverter.ts +27 -4
  174. package/src/packlets/index/memoryIndex.ts +86 -22
  175. package/src/packlets/ingest/cycleGuard.ts +22 -11
  176. package/src/packlets/ingest/hostStages.ts +3 -3
  177. package/src/packlets/ingest/model.ts +25 -14
  178. package/src/packlets/ingest/orchestrator.ts +143 -67
  179. package/src/packlets/retrieve/hybridRetriever.ts +14 -1
  180. package/src/packlets/retrieve/linkTraversalRetriever.ts +51 -62
  181. package/src/packlets/retrieve/recencyRetriever.ts +3 -3
  182. package/src/packlets/retrieve/retriever.ts +97 -13
  183. package/src/packlets/retrieve/semanticRetriever.ts +10 -5
  184. package/src/packlets/retrieve/structuredFilterRetriever.ts +3 -3
  185. package/src/packlets/retrieve/tagRetriever.ts +3 -3
  186. package/src/packlets/retrieve/temporalRetrievers.ts +3 -3
  187. package/src/packlets/store/fileTreeMemoryStore.ts +117 -12
  188. package/src/packlets/tools/memoryTools.ts +152 -25
  189. package/src/packlets/types/envelope.ts +66 -8
  190. package/src/packlets/vector/inMemoryCosineIndex.ts +45 -22
  191. package/src/packlets/vector/vectorIndex.ts +47 -20
  192. package/src/test/unit/converters/antagonistRoundTrip.test.ts +3 -3
  193. package/src/test/unit/converters/envelopeConverter.test.ts +168 -11
  194. package/src/test/unit/index/memoryIndex.test.ts +99 -14
  195. package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +23 -18
  196. package/src/test/unit/ingest/cycleGuard.test.ts +44 -2
  197. package/src/test/unit/ingest/orchestrator.test.ts +234 -41
  198. package/src/test/unit/retrieve/linkTraversalRetriever.test.ts +134 -35
  199. package/src/test/unit/retrieve/retrievers.test.ts +381 -25
  200. package/src/test/unit/store/embedOnWrite.test.ts +83 -11
  201. package/src/test/unit/store/listScoped.test.ts +138 -0
  202. package/src/test/unit/store/rankAxis.test.ts +349 -0
  203. package/src/test/unit/tools/memoryTools.test.ts +362 -13
  204. package/src/test/unit/types/writePolicy.test.ts +11 -2
  205. package/src/test/unit/vector/inMemoryCosineIndex.test.ts +115 -39
  206. package/src/test/unit/vector/vectorIndex.test.ts +33 -17
  207. package/temp/build/lint/_eslint-5eVG3S6w.json +41 -33
  208. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  209. package/temp/ts-agent-memory.api.json +1080 -136
  210. package/temp/ts-agent-memory.api.md +64 -22
@@ -8,6 +8,7 @@ import {
8
8
  Convert,
9
9
  EntityId,
10
10
  IEdge,
11
+ IEdgeTarget,
11
12
  IIdentityCodec,
12
13
  IIdentityCodecResult,
13
14
  IMemoryEnvelope,
@@ -18,6 +19,7 @@ import {
18
19
  MemoryId,
19
20
  MemoryScopeKey,
20
21
  Tag,
22
+ edgeTargetKey,
21
23
  isTemporalRecord,
22
24
  isVersionCurrent
23
25
  } from '../types';
@@ -156,6 +158,12 @@ interface ISimilarityWiring {
156
158
  readonly embed: MemoryEmbedder;
157
159
  }
158
160
 
161
+ /** A snapshot record paired with its resolved scope-qualified `(scope, id)` address. */
162
+ interface IScopedRecord {
163
+ readonly address: IEdgeTarget;
164
+ readonly record: IMemoryRecord<unknown>;
165
+ }
166
+
159
167
  /** Internal per-candidate plan threaded through the pipeline. */
160
168
  interface ICandidatePlan {
161
169
  readonly candidate: ICandidateRecord;
@@ -165,6 +173,12 @@ interface ICandidatePlan {
165
173
  readonly writeEntityId: EntityId;
166
174
  /** The reference id used in stage-5 edges (the write target's `idStem`). */
167
175
  readonly refId: MemoryId;
176
+ /**
177
+ * The scope-qualified reference used in stage-5 edges: the write target's
178
+ * `(scope, idStem)`. Stage-5 sources / dedup all key on this scoped address so
179
+ * two candidates that share a stem across scopes never collide.
180
+ */
181
+ readonly refTarget: IEdgeTarget;
168
182
  /** The resolution verdict; carries the target id on its target-bearing arms. */
169
183
  readonly verdict: ResolutionVerdict;
170
184
  /**
@@ -268,12 +282,23 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
268
282
  return fail(`ingest '${item.id}': failed to snapshot store: ${snapshotResult.message}`);
269
283
  }
270
284
  const snapshot: ReadonlyArray<IMemoryRecord<unknown>> = snapshotResult.value;
271
- const byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>> = MemoryIngestOrchestrator._indexById(snapshot);
285
+ // Resolve every snapshot record's scope-qualified address ONCE, then index it
286
+ // by the canonical scoped key. The single `byKey` view drives BOTH the edge
287
+ // path (validation + cycle guard) AND the verdict/similarity target lookups —
288
+ // so a filename stem reused across scopes never aliases on any path.
289
+ const scopedResult: Result<ReadonlyArray<IScopedRecord>> = this._scopeRecords(snapshot);
290
+ if (scopedResult.isFailure()) {
291
+ return fail(`ingest '${item.id}': ${scopedResult.message}`);
292
+ }
293
+ const scoped: ReadonlyArray<IScopedRecord> = scopedResult.value;
294
+ const byKey: ReadonlyMap<string, IMemoryRecord<unknown>> = new Map<string, IMemoryRecord<unknown>>(
295
+ scoped.map((s) => [edgeTargetKey(s.address), s.record])
296
+ );
272
297
 
273
298
  // Stage 3b + 4: validate each body and resolve a verdict/plan.
274
299
  const plans: ICandidatePlan[] = [];
275
300
  for (const candidate of candidates) {
276
- const planResult: Result<ICandidatePlan> = await this._planCandidate(item, candidate, snapshot, byId);
301
+ const planResult: Result<ICandidatePlan> = await this._planCandidate(item, candidate, snapshot, byKey);
277
302
  if (planResult.isFailure()) {
278
303
  return fail(planResult.message);
279
304
  }
@@ -285,8 +310,8 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
285
310
  const edgesResult: Result<ReadonlyArray<ICandidateEdge>> = await this._relate(
286
311
  item,
287
312
  writablePlans,
288
- snapshot,
289
- byId
313
+ scoped,
314
+ byKey
290
315
  );
291
316
  if (edgesResult.isFailure()) {
292
317
  return fail(edgesResult.message);
@@ -310,7 +335,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
310
335
  item: IIngestItem,
311
336
  candidate: ICandidateRecord,
312
337
  snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
313
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
338
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
314
339
  ): Promise<Result<ICandidatePlan>> {
315
340
  const kind: Kind = candidate.envelope.kind;
316
341
  // Stage 3b: the typed validation boundary — no unchecked host body reaches the store.
@@ -329,8 +354,8 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
329
354
  }
330
355
  const addr: IIdentityCodecResult = addrResult.value;
331
356
 
332
- return (await this._resolveVerdict(candidate, kind, body, addr, snapshot, byId)).onSuccess((verdict) =>
333
- this._planFromVerdict(item, candidate, addr, verdict, byId)
357
+ return (await this._resolveVerdict(candidate, kind, body, addr, snapshot, byKey)).onSuccess((verdict) =>
358
+ this._planFromVerdict(item, candidate, addr, verdict, byKey)
334
359
  );
335
360
  }
336
361
 
@@ -348,7 +373,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
348
373
  candidate: ICandidateRecord,
349
374
  addr: IIdentityCodecResult,
350
375
  verdict: ResolutionVerdict,
351
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
376
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
352
377
  ): Result<ICandidatePlan> {
353
378
  if (verdict.verdict === 'new') {
354
379
  return Convert.memoryId.convert(addr.idStem).onSuccess((refId) =>
@@ -357,41 +382,51 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
357
382
  writeAddress: addr,
358
383
  writeEntityId: candidate.envelope.entityId,
359
384
  refId,
385
+ refTarget: { scope: addr.scope, id: refId },
360
386
  verdict
361
387
  })
362
388
  );
363
389
  }
364
390
  // 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);
391
+ // candidate's kind. Lookup is on the canonical scoped key, so a stem reused
392
+ // across scopes binds only the record the verdict actually named.
393
+ const target: IMemoryRecord<unknown> | undefined = byKey.get(edgeTargetKey(verdict.target));
367
394
  if (target === undefined) {
368
395
  return fail(
369
- `ingest '${item.id}': ${verdict.verdict} target '${verdict.target}' does not exist in the store`
396
+ `ingest '${item.id}': ${verdict.verdict} target '${MemoryIngestOrchestrator._formatTarget(
397
+ verdict.target
398
+ )}' does not exist in the store`
370
399
  );
371
400
  }
372
401
  if (target.envelope.kind !== candidate.envelope.kind) {
373
402
  return fail(
374
- `ingest '${item.id}': ${verdict.verdict} target '${verdict.target}' is kind '${target.envelope.kind}' but the candidate is kind '${candidate.envelope.kind}'`
403
+ `ingest '${item.id}': ${verdict.verdict} target '${MemoryIngestOrchestrator._formatTarget(
404
+ verdict.target
405
+ )}' is kind '${target.envelope.kind}' but the candidate is kind '${candidate.envelope.kind}'`
375
406
  );
376
407
  }
377
408
  if (verdict.verdict === 'merge-into') {
378
409
  // Re-address the write to the target's entity, carrying the target record so
379
410
  // stage 6 can UNION its existing tags/links (never overwrite them).
380
411
  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
- );
412
+ // The merge target is a snapshot record, and `_scopeRecords` already proved
413
+ // every snapshot record's address resolves before planning runs — so this
414
+ // re-resolve (needed for the full codec result: scope + idStem + isVersioned)
415
+ // cannot fail. No error-context wrap is warranted; a failure here would be a
416
+ // logic contradiction, not a user-facing condition.
417
+ return this._resolveAddress(targetEntityId, target.envelope.kind).onSuccess((targetAddr) =>
418
+ Convert.memoryId.convert(targetAddr.idStem).onSuccess((refId) =>
419
+ succeed({
420
+ candidate,
421
+ writeAddress: targetAddr,
422
+ writeEntityId: targetEntityId,
423
+ refId,
424
+ refTarget: { scope: targetAddr.scope, id: refId },
425
+ verdict,
426
+ mergeTarget: target
427
+ })
428
+ )
429
+ );
395
430
  }
396
431
  // duplicate-of | supersede: write under the candidate's own address.
397
432
  return Convert.memoryId.convert(addr.idStem).onSuccess((refId) =>
@@ -400,6 +435,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
400
435
  writeAddress: addr,
401
436
  writeEntityId: candidate.envelope.entityId,
402
437
  refId,
438
+ refTarget: { scope: addr.scope, id: refId },
403
439
  verdict
404
440
  })
405
441
  );
@@ -418,18 +454,20 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
418
454
  body: string,
419
455
  addr: IIdentityCodecResult,
420
456
  snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
421
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
457
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
422
458
  ): Promise<Result<ResolutionVerdict>> {
423
459
  return this._findExactMatch(kind, body, addr.scope, snapshot).thenOnSuccess(async (matchId) => {
424
460
  if (matchId !== undefined) {
425
- return succeed({ verdict: 'duplicate-of', target: matchId });
461
+ // The exact-match cohort is filtered to `addr.scope`, so the match lives
462
+ // under that scope — its scope-qualified target is `(addr.scope, matchId)`.
463
+ return succeed({ verdict: 'duplicate-of', target: { scope: addr.scope, id: matchId } });
426
464
  }
427
465
  const layer2: ISimilarityWiring | undefined = this._similarity;
428
466
  if (layer2 === undefined) {
429
467
  // No layer-2: exact-only fall-back (the deterministic-identity host path).
430
468
  return succeed({ verdict: 'new' });
431
469
  }
432
- return this._resolveViaSimilarity(candidate, addr, body, layer2, byId);
470
+ return this._resolveViaSimilarity(candidate, addr, body, layer2, byKey);
433
471
  });
434
472
  }
435
473
 
@@ -439,10 +477,10 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
439
477
  addr: IIdentityCodecResult,
440
478
  body: string,
441
479
  wiring: ISimilarityWiring,
442
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
480
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
443
481
  ): Promise<Result<ResolutionVerdict>> {
444
482
  return MemoryIngestOrchestrator._provisionalRecord(candidate, addr.idStem, body).thenOnSuccess(
445
- (provisional) => this._resolveViaSimilarityEmbedded(candidate, addr, wiring, byId, provisional)
483
+ (provisional) => this._resolveViaSimilarityEmbedded(candidate, addr, wiring, byKey, provisional)
446
484
  );
447
485
  }
448
486
 
@@ -451,7 +489,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
451
489
  candidate: ICandidateRecord,
452
490
  addr: IIdentityCodecResult,
453
491
  wiring: ISimilarityWiring,
454
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>,
492
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>,
455
493
  provisional: IMemoryRecord<unknown>
456
494
  ): Promise<Result<ResolutionVerdict>> {
457
495
  const embedded: Result<Float32Array> = await this._capture(
@@ -470,12 +508,20 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
470
508
  }
471
509
  const similar: IEntityResolutionCandidate[] = [];
472
510
  for (const hit of queried.value) {
473
- if (hit.score < this._similarityThreshold || hit.id === addr.idStem) {
511
+ // Skip below-threshold hits and the candidate's OWN scope-qualified address
512
+ // (a re-ingest of the same entity must not dedup against its prior version).
513
+ // Both the scope AND the id must match to be "self" — a same-stem record in
514
+ // another scope is a legitimate distinct neighbor. This field-equality is
515
+ // exactly equivalent to comparing `edgeTargetKey(hit.target)` against the
516
+ // candidate's own key (edgeTargetKey is pure and injective on `(scope, id)`),
517
+ // so this one site reads the components directly rather than re-keying.
518
+ const isSelf: boolean = hit.target.scope === addr.scope && hit.target.id === addr.idStem;
519
+ if (hit.score < this._similarityThreshold || isSelf) {
474
520
  continue;
475
521
  }
476
- const record: IMemoryRecord<unknown> | undefined = byId.get(hit.id);
522
+ const record: IMemoryRecord<unknown> | undefined = byKey.get(edgeTargetKey(hit.target));
477
523
  if (record !== undefined) {
478
- similar.push({ id: hit.id, record, score: hit.score });
524
+ similar.push({ target: hit.target, record, score: hit.score });
479
525
  }
480
526
  }
481
527
  if (similar.length === 0) {
@@ -546,12 +592,12 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
546
592
  private async _relate(
547
593
  item: IIngestItem,
548
594
  writablePlans: ReadonlyArray<ICandidatePlan>,
549
- snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
550
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
595
+ scoped: ReadonlyArray<IScopedRecord>,
596
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
551
597
  ): Promise<Result<ReadonlyArray<ICandidateEdge>>> {
552
598
  const relationCandidates: IRelationCandidate[] = writablePlans.map((plan) => ({
553
599
  candidate: plan.candidate,
554
- id: plan.refId
600
+ id: plan.refTarget
555
601
  }));
556
602
  const proposed: Result<ReadonlyArray<ICandidateEdge>> = await this._capture(
557
603
  () => this._relationExtractor.relate({ item, candidates: relationCandidates }),
@@ -560,14 +606,19 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
560
606
  if (proposed.isFailure()) {
561
607
  return proposed;
562
608
  }
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);
609
+ // refIds and the existing-record view (`byKey`, shared with the verdict path)
610
+ // both key on the canonical scoped address, so a stem reused across scopes
611
+ // never aliases.
612
+ const refIds: ReadonlySet<string> = new Set<string>(
613
+ writablePlans.map((plan) => edgeTargetKey(plan.refTarget))
614
+ );
615
+ const validation: Result<true> = this._validateEdges(item, proposed.value, refIds, byKey);
565
616
  if (validation.isFailure()) {
566
617
  return fail(validation.message);
567
618
  }
568
619
  if (this._cycleGuard === 'reject') {
569
620
  const guard: Result<true> = assertNoCycles(
570
- MemoryIngestOrchestrator._existingEdges(snapshot),
621
+ MemoryIngestOrchestrator._existingEdges(scoped),
571
622
  proposed.value.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type }))
572
623
  );
573
624
  if (guard.isFailure()) {
@@ -579,22 +630,30 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
579
630
 
580
631
  /**
581
632
  * 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.
633
+ * `target` must resolve to a sibling candidate or an existing store record. All
634
+ * matching is on the canonical scope-qualified address.
583
635
  */
584
636
  private _validateEdges(
585
637
  item: IIngestItem,
586
638
  edges: ReadonlyArray<ICandidateEdge>,
587
639
  refIds: ReadonlySet<string>,
588
- byId: ReadonlyMap<MemoryId, IMemoryRecord<unknown>>
640
+ byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
589
641
  ): Result<true> {
590
642
  return mapResults(
591
643
  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`);
644
+ if (!refIds.has(edgeTargetKey(edge.source))) {
645
+ return fail(
646
+ `ingest '${item.id}': edge source '${MemoryIngestOrchestrator._formatTarget(
647
+ edge.source
648
+ )}' is not a candidate being written`
649
+ );
594
650
  }
595
- if (!refIds.has(edge.edge.target) && byId.get(edge.edge.target) === undefined) {
651
+ const targetKey: string = edgeTargetKey(edge.edge.target);
652
+ if (!refIds.has(targetKey) && byKey.get(targetKey) === undefined) {
596
653
  return fail(
597
- `ingest '${item.id}': edge target '${edge.edge.target}' resolves to neither a sibling candidate nor an existing record`
654
+ `ingest '${item.id}': edge target '${MemoryIngestOrchestrator._formatTarget(
655
+ edge.edge.target
656
+ )}' resolves to neither a sibling candidate nor an existing record`
598
657
  );
599
658
  }
600
659
  return succeed(true);
@@ -608,7 +667,8 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
608
667
  plan: ICandidatePlan,
609
668
  allEdges: ReadonlyArray<ICandidateEdge>
610
669
  ): Promise<Result<IIngestedRecordResult>> {
611
- const myEdges: ReadonlyArray<ICandidateEdge> = allEdges.filter((e) => e.source === plan.refId);
670
+ const refKey: string = edgeTargetKey(plan.refTarget);
671
+ const myEdges: ReadonlyArray<ICandidateEdge> = allEdges.filter((e) => edgeTargetKey(e.source) === refKey);
612
672
  if (plan.verdict.verdict === 'duplicate-of') {
613
673
  // No write: the existing target satisfied the candidate. The target id is a
614
674
  // typed field of the narrowed `duplicate-of` verdict — no cast, no guard.
@@ -616,7 +676,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
616
676
  candidate: plan.candidate,
617
677
  resolution: plan.verdict,
618
678
  disposition: 'deduped',
619
- id: plan.verdict.target,
679
+ id: plan.verdict.target.id,
620
680
  edges: myEdges
621
681
  });
622
682
  }
@@ -740,30 +800,46 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
740
800
  }
741
801
  }
742
802
 
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> {
803
+ /**
804
+ * Every existing outbound edge in the snapshot, as cycle-guard edges. The
805
+ * source is the record's own scope-qualified address (already resolved by
806
+ * {@link MemoryIngestOrchestrator._scopeRecords}); the target is the edge's
807
+ * own scoped target. Both ends are scoped so the guard never conflates a stem
808
+ * shared across scopes into one graph node.
809
+ */
810
+ private static _existingEdges(scoped: ReadonlyArray<IScopedRecord>): ReadonlyArray<ICycleGuardEdge> {
758
811
  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 });
812
+ for (const s of scoped) {
813
+ for (const edge of s.record.envelope.links) {
814
+ edges.push({ source: s.address, target: edge.target, type: edge.type });
762
815
  }
763
816
  }
764
817
  return edges;
765
818
  }
766
819
 
820
+ /**
821
+ * Resolve every snapshot record's scope-qualified `(scope, id)` address via its
822
+ * registered codec. Fails loudly if a record's kind has no resolvable codec —
823
+ * the edge path cannot place an un-scopeable record in the graph, and a missing
824
+ * codec for a stored kind is a real misconfiguration, not something to paper over.
825
+ */
826
+ private _scopeRecords(
827
+ records: ReadonlyArray<IMemoryRecord<unknown>>
828
+ ): Result<ReadonlyArray<IScopedRecord>> {
829
+ return mapResults(
830
+ records.map((record) =>
831
+ this._resolveAddress(record.envelope.entityId, record.envelope.kind)
832
+ .withErrorFormat((msg) => `cannot resolve scope for stored record '${record.envelope.id}': ${msg}`)
833
+ .onSuccess((addr) => succeed({ address: { scope: addr.scope, id: record.envelope.id }, record }))
834
+ )
835
+ );
836
+ }
837
+
838
+ /** Human-readable `scope/id` rendering of a scoped target for edge-validation diagnostics. */
839
+ private static _formatTarget(target: IEdgeTarget): string {
840
+ return `${target.scope}/${target.id}`;
841
+ }
842
+
767
843
  /** A provisional record for embedding a candidate (placeholder txn-time fields). */
768
844
  private static _provisionalRecord(
769
845
  candidate: ICandidateRecord,
@@ -11,6 +11,7 @@ import {
11
11
  IMemoryRetrieverCapabilities,
12
12
  guardRetrieverCapabilities,
13
13
  limitRecords,
14
+ rankCompare,
14
15
  recencyCompare
15
16
  } from './retriever';
16
17
 
@@ -145,7 +146,15 @@ export class HybridRetriever implements IMemoryRetriever {
145
146
  );
146
147
  return mapResults(perRetriever)
147
148
  .onSuccess((resultSets) => this._mergeStrategy.merge(resultSets))
148
- .onSuccess((merged) => succeed(limitRecords(merged, query.limit)));
149
+ .onSuccess((merged) => {
150
+ // `orderBy: 'rank'` re-orders the merged set by rank (descending, absent
151
+ // last) before the page window, so a rank-ordered hybrid query yields a
152
+ // rank-ordered page. Absent / `'recency'` preserves the merge strategy's
153
+ // own ordering (byte-identical to the pre-`orderBy` behavior).
154
+ const ordered: ReadonlyArray<IMemoryRecord<unknown>> =
155
+ query.orderBy === 'rank' ? [...merged].sort(rankCompare) : merged;
156
+ return succeed(limitRecords(ordered, query.limit, query.offset));
157
+ });
149
158
  });
150
159
  }
151
160
 
@@ -164,6 +173,10 @@ export class HybridRetriever implements IMemoryRetriever {
164
173
  private _projectQuery(query: IMemoryQuery, retriever: IMemoryRetriever): IMemoryQuery {
165
174
  const projected: { -readonly [K in keyof IMemoryQuery]: IMemoryQuery[K] } = { ...query };
166
175
  delete projected.limit;
176
+ // Offset, like limit, is a post-merge concern: a child that pre-skipped its
177
+ // own ordered set would drop candidates the merge needs to score correctly.
178
+ // The hybrid applies the `{ offset, limit }` window once, after merge.
179
+ delete projected.offset;
167
180
  if (!retriever.capabilities.supportsSemanticRecall) {
168
181
  delete projected.semantic;
169
182
  delete projected.topK;
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Result, fail, succeed } from '@fgv/ts-utils';
7
- import { IMemoryRecord, MemoryId } from '../types';
7
+ import { IEdgeTarget, IMemoryRecord, edgeTargetKey } from '../types';
8
8
  import { IIndexedMemoryRecord, IMemoryIndex } from '../index';
9
9
  import {
10
10
  IMemoryQuery,
@@ -13,7 +13,7 @@ import {
13
13
  guardRetrieverCapabilities,
14
14
  indexedRecordMatchesQuery,
15
15
  limitRecords,
16
- recencyCompare
16
+ orderingCompare
17
17
  } from './retriever';
18
18
 
19
19
  /** The capabilities a link-traversal retriever exposes (link traversal only). */
@@ -35,20 +35,23 @@ export const LINK_TRAVERSAL_NO_SEED_MESSAGE: string =
35
35
  'link traversal requires a seed id (linkedFrom or linkedTo)';
36
36
 
37
37
  /**
38
- * Breadth-first link-traversal retriever. From a seed {@link MemoryId} it walks
39
- * the link graph up to `query.hops` levels and returns the records reached
40
- * (excluding the seed), recency-ordered and limited.
38
+ * Breadth-first link-traversal retriever. From a scope-qualified
39
+ * {@link IEdgeTarget} seed it walks the link graph up to `query.hops` levels and
40
+ * returns the records reached (excluding the seed), recency-ordered and limited.
41
41
  *
42
42
  * @remarks
43
43
  * - **Direction.** `linkedFrom` walks OUTBOUND edges (each record's
44
44
  * `envelope.links[].target`); `linkedTo` walks INBOUND edges (the index's
45
45
  * `backlinks`). Exactly one is the seed; `linkedFrom` wins if both are set.
46
+ * - **Scope-qualified nodes.** Every graph node is an {@link IEdgeTarget}
47
+ * `(scope, id)` pair, so following an edge to `turn-3` reaches ONLY the record
48
+ * in the edge's own scope — never a same-stem record in another scope.
46
49
  * - **Bound + cycle safety.** Traversal is bounded by `hops` (default `1` — a
47
- * single hop) and a visited-set guard. The graph is keyed by bare
48
- * string {@link MemoryId}s, so a `Set<string>` visited-set is the exact,
49
- * collision-free cycle key — no structural hashing (e.g. `Crc32Normalizer`) is
50
- * needed. A self-loop or any multi-hop cycle terminates because a revisited id
51
- * is never re-expanded.
50
+ * single hop) and a visited-set guard. Nodes are canonicalized to their
51
+ * `(scope, id)` string via {@link edgeTargetKey}, so a `Set<string>` visited-set
52
+ * is the exact, collision-free cycle key — no structural hashing (e.g.
53
+ * `Crc32Normalizer`) is needed. A self-loop or any multi-hop cycle terminates
54
+ * because a revisited node is never re-expanded.
52
55
  * - **Post-filter.** The scope / kind / tag / predicate axes of the query are
53
56
  * applied to the reached records (the link axes are the traversal itself).
54
57
  * @public
@@ -80,24 +83,26 @@ export class LinkTraversalRetriever implements IMemoryRetriever {
80
83
  /** Run the bounded, cycle-safe BFS and post-filter the reached records. */
81
84
  private _traverse(query: IMemoryQuery): Result<ReadonlyArray<IMemoryRecord<unknown>>> {
82
85
  const outbound: boolean = query.linkedFrom !== undefined;
83
- const seed: MemoryId | undefined = query.linkedFrom ?? query.linkedTo;
86
+ const seed: IEdgeTarget | undefined = query.linkedFrom ?? query.linkedTo;
84
87
  if (seed === undefined) {
85
88
  return fail(LINK_TRAVERSAL_NO_SEED_MESSAGE);
86
89
  }
87
90
  const hops: number = query.hops ?? DEFAULT_HOPS;
88
- const byId: ReadonlyMap<MemoryId, IIndexedMemoryRecord[]> = this._indexById();
91
+ const byKey: ReadonlyMap<string, IIndexedMemoryRecord> = this._indexByKey();
89
92
 
90
- // The visited-set IS the cycle guard: ids are strings, so set membership is
91
- // an exact identity check. The seed is pre-marked so it is never re-added.
92
- const visited: Set<string> = new Set<string>([seed]);
93
- const reached: MemoryId[] = [];
94
- let frontier: MemoryId[] = [seed];
93
+ // The visited-set IS the cycle guard: nodes are canonicalized to their
94
+ // `(scope, id)` string, so set membership is an exact identity check. The
95
+ // seed is pre-marked so it is never re-added.
96
+ const visited: Set<string> = new Set<string>([edgeTargetKey(seed)]);
97
+ const reached: IEdgeTarget[] = [];
98
+ let frontier: IEdgeTarget[] = [seed];
95
99
  for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
96
- const next: MemoryId[] = [];
97
- for (const id of frontier) {
98
- for (const neighbor of outbound ? this._outbound(id, byId) : this._inbound(id)) {
99
- if (!visited.has(neighbor)) {
100
- visited.add(neighbor);
100
+ const next: IEdgeTarget[] = [];
101
+ for (const node of frontier) {
102
+ for (const neighbor of outbound ? this._outbound(node, byKey) : this._inbound(node)) {
103
+ const neighborKey: string = edgeTargetKey(neighbor);
104
+ if (!visited.has(neighborKey)) {
105
+ visited.add(neighborKey);
101
106
  reached.push(neighbor);
102
107
  next.push(neighbor);
103
108
  }
@@ -107,63 +112,47 @@ export class LinkTraversalRetriever implements IMemoryRetriever {
107
112
  }
108
113
 
109
114
  const entries: IIndexedMemoryRecord[] = [];
110
- for (const id of reached) {
111
- const matches: IIndexedMemoryRecord[] | undefined = byId.get(id);
112
- if (matches !== undefined) {
113
- entries.push(...matches);
115
+ for (const node of reached) {
116
+ const match: IIndexedMemoryRecord | undefined = byKey.get(edgeTargetKey(node));
117
+ if (match !== undefined) {
118
+ entries.push(match);
114
119
  }
115
120
  }
116
121
  const ordered: IMemoryRecord<unknown>[] = entries
117
122
  .filter((entry) => indexedRecordMatchesQuery(entry, query))
118
123
  .map((entry) => entry.record)
119
- .sort(recencyCompare);
120
- return succeed(limitRecords(ordered, query.limit));
124
+ .sort(orderingCompare(query.orderBy));
125
+ return succeed(limitRecords(ordered, query.limit, query.offset));
121
126
  }
122
127
 
123
128
  /**
124
- * Group the index's entries by bare {@link MemoryId}. An id can map to more
125
- * than one entry when distinct scopes reuse a filename stem (e.g. `turn-0` in
126
- * two conversations), so the value is an array.
127
- *
128
- * @remarks
129
- * **Design note (links are globally-scoped identifiers in this phase).** An
130
- * {@link IEdge.target} is a bare `MemoryId`, not a `(scope, id)` pair, so
131
- * traversal resolves a target across ALL scopes that hold that id. When two
132
- * scopes reuse a stem, following an edge to it reaches every match. This
133
- * mirrors the `backlinks` index, which is also keyed by bare id. Scope-
134
- * qualified link resolution is intentionally out of scope for Phase C and
135
- * would be an additive change here (and to {@link IEdge} / the index).
129
+ * Group the index's entries by their scope-qualified {@link edgeTargetKey}
130
+ * `(scope, id)` composite. Each composite is the index's primary key, so it maps
131
+ * to exactly one entry — two records that reuse a filename stem across scopes
132
+ * (e.g. `turn-0` in two conversations) get distinct keys and never collide.
136
133
  */
137
- private _indexById(): ReadonlyMap<MemoryId, IIndexedMemoryRecord[]> {
138
- const byId: Map<MemoryId, IIndexedMemoryRecord[]> = new Map<MemoryId, IIndexedMemoryRecord[]>();
134
+ private _indexByKey(): ReadonlyMap<string, IIndexedMemoryRecord> {
135
+ const byKey: Map<string, IIndexedMemoryRecord> = new Map<string, IIndexedMemoryRecord>();
139
136
  for (const entry of this._index.entries()) {
140
- const id: MemoryId = entry.record.envelope.id;
141
- const existing: IIndexedMemoryRecord[] | undefined = byId.get(id);
142
- if (existing === undefined) {
143
- byId.set(id, [entry]);
144
- } else {
145
- existing.push(entry);
146
- }
137
+ byKey.set(edgeTargetKey({ scope: entry.scope, id: entry.record.envelope.id }), entry);
147
138
  }
148
- return byId;
139
+ return byKey;
149
140
  }
150
141
 
151
- /** Outbound neighbors: the targets of every edge on the records with this id. */
152
- private _outbound(id: MemoryId, byId: ReadonlyMap<MemoryId, IIndexedMemoryRecord[]>): MemoryId[] {
153
- const targets: MemoryId[] = [];
154
- const matches: IIndexedMemoryRecord[] | undefined = byId.get(id);
155
- if (matches !== undefined) {
156
- for (const entry of matches) {
157
- for (const edge of entry.record.envelope.links) {
158
- targets.push(edge.target);
159
- }
142
+ /** Outbound neighbors: the scope-qualified targets of every edge on the record at `node`. */
143
+ private _outbound(node: IEdgeTarget, byKey: ReadonlyMap<string, IIndexedMemoryRecord>): IEdgeTarget[] {
144
+ const targets: IEdgeTarget[] = [];
145
+ const match: IIndexedMemoryRecord | undefined = byKey.get(edgeTargetKey(node));
146
+ if (match !== undefined) {
147
+ for (const edge of match.record.envelope.links) {
148
+ targets.push(edge.target);
160
149
  }
161
150
  }
162
151
  return targets;
163
152
  }
164
153
 
165
- /** Inbound neighbors: the ids whose edges point AT this id (the backlinks). */
166
- private _inbound(id: MemoryId): ReadonlyArray<MemoryId> {
167
- return this._index.backlinks(id);
154
+ /** Inbound neighbors: the scope-qualified sources whose edges point AT `node` (the backlinks). */
155
+ private _inbound(node: IEdgeTarget): ReadonlyArray<IEdgeTarget> {
156
+ return this._index.backlinks(node);
168
157
  }
169
158
  }
@@ -13,7 +13,7 @@ import {
13
13
  NON_SEMANTIC_CAPABILITIES,
14
14
  guardRetrieverCapabilities,
15
15
  limitRecords,
16
- recencyCompare,
16
+ orderingCompare,
17
17
  selectByQuery
18
18
  } from './retriever';
19
19
 
@@ -45,9 +45,9 @@ export class RecencyRetriever implements IMemoryRetriever {
45
45
  return Promise.resolve(
46
46
  guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => {
47
47
  const ordered: IMemoryRecord<unknown>[] = selectByQuery(this._index.entries(), query).sort(
48
- recencyCompare
48
+ orderingCompare(query.orderBy)
49
49
  );
50
- return succeed(limitRecords(ordered, query.limit));
50
+ return succeed(limitRecords(ordered, query.limit, query.offset));
51
51
  })
52
52
  );
53
53
  }