@fgv/ts-agent-memory 5.1.0-45 → 5.1.0-47

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 (120) hide show
  1. package/.rush/temp/{bc5b0f2098f7df2a5c498ea77b0de13316349b96.tar.log → 5667e36f1c0cc503a5c20a3ebff76e7b9a702d41.tar.log} +26 -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/README.md +134 -4
  7. package/dist/packlets/ingest/orchestrator.js +104 -19
  8. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  9. package/dist/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
  10. package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  11. package/dist/packlets/store/fileTreeMemoryStore.js +31 -8
  12. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  13. package/dist/packlets/types/writePolicy.js +10 -2
  14. package/dist/packlets/types/writePolicy.js.map +1 -1
  15. package/dist/packlets/vector/fragmentConverters.js +41 -0
  16. package/dist/packlets/vector/fragmentConverters.js.map +1 -0
  17. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
  18. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  19. package/dist/packlets/vector/index.js +1 -0
  20. package/dist/packlets/vector/index.js.map +1 -1
  21. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  22. package/dist/test/unit/ingest/dedupScope.test.js +434 -0
  23. package/dist/test/unit/ingest/dedupScope.test.js.map +1 -0
  24. package/dist/test/unit/ingest/orchestrator.test.js +7 -4
  25. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
  26. package/dist/test/unit/store/fileTreeMemoryStore.test.js +64 -0
  27. package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  28. package/dist/test/unit/store/indexInjection.test.js +432 -0
  29. package/dist/test/unit/store/indexInjection.test.js.map +1 -0
  30. package/dist/test/unit/tools/memoryTools.test.js +1 -0
  31. package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
  32. package/dist/test/unit/types/temporalPolicy.test.js +21 -0
  33. package/dist/test/unit/types/temporalPolicy.test.js.map +1 -1
  34. package/dist/test/unit/types/writePolicy.test.js +83 -0
  35. package/dist/test/unit/types/writePolicy.test.js.map +1 -1
  36. package/dist/test/unit/vector/fragmentConverters.test.js +58 -0
  37. package/dist/test/unit/vector/fragmentConverters.test.js.map +1 -0
  38. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
  39. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -1
  40. package/dist/ts-agent-memory.d.ts +332 -42
  41. package/etc/ts-agent-memory.api.md +12 -1
  42. package/lib/packlets/ingest/orchestrator.d.ts +62 -5
  43. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  44. package/lib/packlets/ingest/orchestrator.js +104 -19
  45. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  46. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +10 -7
  47. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
  48. package/lib/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
  49. package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  50. package/lib/packlets/store/fileTreeMemoryStore.d.ts +96 -4
  51. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  52. package/lib/packlets/store/fileTreeMemoryStore.js +31 -8
  53. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  54. package/lib/packlets/types/writePolicy.d.ts +40 -2
  55. package/lib/packlets/types/writePolicy.d.ts.map +1 -1
  56. package/lib/packlets/types/writePolicy.js +10 -2
  57. package/lib/packlets/types/writePolicy.js.map +1 -1
  58. package/lib/packlets/vector/fragmentConverters.d.ts +25 -0
  59. package/lib/packlets/vector/fragmentConverters.d.ts.map +1 -0
  60. package/lib/packlets/vector/fragmentConverters.js +44 -0
  61. package/lib/packlets/vector/fragmentConverters.js.map +1 -0
  62. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +5 -3
  63. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  64. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
  65. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  66. package/lib/packlets/vector/index.d.ts +1 -0
  67. package/lib/packlets/vector/index.d.ts.map +1 -1
  68. package/lib/packlets/vector/index.js +1 -0
  69. package/lib/packlets/vector/index.js.map +1 -1
  70. package/lib/packlets/vector/vectorIndex.d.ts +97 -22
  71. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  72. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  73. package/lib/test/unit/ingest/dedupScope.test.d.ts +2 -0
  74. package/lib/test/unit/ingest/dedupScope.test.d.ts.map +1 -0
  75. package/lib/test/unit/ingest/dedupScope.test.js +436 -0
  76. package/lib/test/unit/ingest/dedupScope.test.js.map +1 -0
  77. package/lib/test/unit/ingest/orchestrator.test.js +7 -4
  78. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
  79. package/lib/test/unit/store/fileTreeMemoryStore.test.js +64 -0
  80. package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  81. package/lib/test/unit/store/indexInjection.test.d.ts +2 -0
  82. package/lib/test/unit/store/indexInjection.test.d.ts.map +1 -0
  83. package/lib/test/unit/store/indexInjection.test.js +434 -0
  84. package/lib/test/unit/store/indexInjection.test.js.map +1 -0
  85. package/lib/test/unit/tools/memoryTools.test.js +1 -0
  86. package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
  87. package/lib/test/unit/types/temporalPolicy.test.js +21 -0
  88. package/lib/test/unit/types/temporalPolicy.test.js.map +1 -1
  89. package/lib/test/unit/types/writePolicy.test.js +83 -0
  90. package/lib/test/unit/types/writePolicy.test.js.map +1 -1
  91. package/lib/test/unit/vector/fragmentConverters.test.d.ts +2 -0
  92. package/lib/test/unit/vector/fragmentConverters.test.d.ts.map +1 -0
  93. package/lib/test/unit/vector/fragmentConverters.test.js +60 -0
  94. package/lib/test/unit/vector/fragmentConverters.test.js.map +1 -0
  95. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
  96. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -1
  97. package/package.json +7 -7
  98. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  99. package/rush-logs/ts-agent-memory.build.log +2 -2
  100. package/src/packlets/ingest/orchestrator.ts +116 -21
  101. package/src/packlets/retrieve/fragmentSemanticRetriever.ts +10 -7
  102. package/src/packlets/store/fileTreeMemoryStore.ts +114 -6
  103. package/src/packlets/types/writePolicy.ts +40 -2
  104. package/src/packlets/vector/fragmentConverters.ts +55 -0
  105. package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +35 -8
  106. package/src/packlets/vector/index.ts +1 -0
  107. package/src/packlets/vector/vectorIndex.ts +97 -22
  108. package/src/test/unit/ingest/dedupScope.test.ts +542 -0
  109. package/src/test/unit/ingest/orchestrator.test.ts +4 -0
  110. package/src/test/unit/store/fileTreeMemoryStore.test.ts +75 -0
  111. package/src/test/unit/store/indexInjection.test.ts +550 -0
  112. package/src/test/unit/tools/memoryTools.test.ts +1 -0
  113. package/src/test/unit/types/temporalPolicy.test.ts +28 -0
  114. package/src/test/unit/types/writePolicy.test.ts +112 -0
  115. package/src/test/unit/vector/fragmentConverters.test.ts +80 -0
  116. package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +88 -0
  117. package/temp/build/lint/_eslint-5eVG3S6w.json +30 -14
  118. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  119. package/temp/ts-agent-memory.api.json +274 -21
  120. package/temp/ts-agent-memory.api.md +12 -1
@@ -6,6 +6,7 @@
6
6
  import { Hash, Logging, Result, fail, mapResults, succeed } from '@fgv/ts-utils';
7
7
  import {
8
8
  Convert,
9
+ DedupScope,
9
10
  EntityId,
10
11
  IEdge,
11
12
  IEdgeTarget,
@@ -17,7 +18,6 @@ import {
17
18
  Kind,
18
19
  LinkType,
19
20
  MemoryId,
20
- MemoryScopeKey,
21
21
  Tag,
22
22
  edgeTargetKey,
23
23
  isTemporalRecord,
@@ -310,6 +310,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
310
310
  const edgesResult: Result<ReadonlyArray<ICandidateEdge>> = await this._relate(
311
311
  item,
312
312
  writablePlans,
313
+ MemoryIngestOrchestrator._collapseRedirects(plans),
313
314
  scoped,
314
315
  byKey
315
316
  );
@@ -443,7 +444,9 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
443
444
 
444
445
  /**
445
446
  * Stage 4 — resolve a dedup verdict. Layer 1: an exact `{ kind, body }` match in
446
- * the candidate's scope is a `duplicate-of` (design note §1). Layer 2 (only when
447
+ * the candidate's scope is a `duplicate-of`, at the granularity the kind's
448
+ * {@link DedupScope} declares — scope-wide for `'content'`, restricted to the
449
+ * candidate's own entity for `'entity'` (design note §1). Layer 2 (only when
447
450
  * a resolver + vector index + embedder are all wired): embed the candidate,
448
451
  * surface over-threshold neighbors, and dispatch to the {@link IEntityResolver}.
449
452
  * Otherwise the verdict is `new` (the exact-only fall-back path).
@@ -456,7 +459,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
456
459
  snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
457
460
  byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
458
461
  ): Promise<Result<ResolutionVerdict>> {
459
- return this._findExactMatch(kind, body, addr.scope, snapshot).thenOnSuccess(async (matchId) => {
462
+ return this._findExactMatch(kind, body, addr, snapshot).thenOnSuccess(async (matchId) => {
460
463
  if (matchId !== undefined) {
461
464
  // The exact-match cohort is filtered to `addr.scope`, so the match lives
462
465
  // under that scope — its scope-qualified target is `(addr.scope, matchId)`.
@@ -534,27 +537,52 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
534
537
  }
535
538
 
536
539
  /**
537
- * Find an existing record in `scope` whose `{ kind, body }` hash matches the
538
- * candidate's (layer-1 exact dedup). Invalidated temporal versions are excluded
539
- * — only a live (non-temporal or current) record deduplicates a candidate.
540
+ * Find an existing record whose `{ kind, body }` hash matches the candidate's
541
+ * (layer-1 exact dedup, design note §1). Invalidated temporal versions are
542
+ * excluded — only a live (non-temporal or current) record deduplicates a
543
+ * candidate.
544
+ *
545
+ * @remarks
546
+ * The cohort's granularity is the kind's DECLARED {@link DedupScope}, read
547
+ * through {@link IMemoryStore.dedupScopeFor} so this layer and the store's own
548
+ * write path can never disagree:
549
+ *
550
+ * - `'content'` — every live same-kind record in the candidate's scope,
551
+ * regardless of entity. A cross-id body collision IS a duplicate (the
552
+ * knowledge family; unchanged behavior).
553
+ * - `'entity'` — only records at the candidate's OWN entity address. Two
554
+ * distinct entities with byte-identical bodies never collapse into one
555
+ * another, which is what the declaration has always meant on the direct-put
556
+ * path and now means here too.
557
+ *
558
+ * The same-id collapse itself remains the store's job — this layer only decides
559
+ * whether a cross-id body collision is eligible to be a `duplicate-of` at all.
540
560
  */
541
561
  private _findExactMatch(
542
562
  kind: Kind,
543
563
  body: string,
544
- scope: MemoryScopeKey,
564
+ addr: IIdentityCodecResult,
545
565
  snapshot: ReadonlyArray<IMemoryRecord<unknown>>
546
566
  ): Result<MemoryId | undefined> {
567
+ const dedupScope: DedupScope = this._store.dedupScopeFor(kind);
547
568
  // Same-kind, same-scope, LIVE (non-temporal or current) records are the exact
548
- // cohort. `_resolveAddress(...).map(...).orDefault()` collapses an unresolved
549
- // codec to a non-matching scope with no explicit failure branch.
550
- const cohort: ReadonlyArray<IMemoryRecord<unknown>> = snapshot.filter(
551
- (record) =>
552
- record.envelope.kind === kind &&
553
- !(isTemporalRecord(record) && !isVersionCurrent(record)) &&
554
- this._resolveAddress(record.envelope.entityId, record.envelope.kind)
555
- .onSuccess((addr) => succeed(addr.scope))
556
- .orDefault() === scope
557
- );
569
+ // cohort, narrowed to the candidate's own entity address under `'entity'`
570
+ // granularity. `_resolveAddress(...).orDefault()` collapses an unresolved
571
+ // codec to `undefined` — a non-matching address — with no explicit failure
572
+ // branch, exactly as the scope-only filter did before.
573
+ const cohort: ReadonlyArray<IMemoryRecord<unknown>> = snapshot.filter((record) => {
574
+ if (record.envelope.kind !== kind || (isTemporalRecord(record) && !isVersionCurrent(record))) {
575
+ return false;
576
+ }
577
+ const recordAddr: IIdentityCodecResult | undefined = this._resolveAddress(
578
+ record.envelope.entityId,
579
+ record.envelope.kind
580
+ ).orDefault();
581
+ if (recordAddr === undefined || recordAddr.scope !== addr.scope) {
582
+ return false;
583
+ }
584
+ return dedupScope === 'content' || recordAddr.idStem === addr.idStem;
585
+ });
558
586
  return this._exactKey(kind, body).onSuccess((key) =>
559
587
  mapResults(
560
588
  cohort.map((record) =>
@@ -588,10 +616,11 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
588
616
  return this._hasher.computeHash({ kind, body });
589
617
  }
590
618
 
591
- /** Stage 5 — relate (host), validate edges, and run the write-time cycle guard. */
619
+ /** Stage 5 — relate (host), redirect collapsed targets, validate edges, and run the write-time cycle guard. */
592
620
  private async _relate(
593
621
  item: IIngestItem,
594
622
  writablePlans: ReadonlyArray<ICandidatePlan>,
623
+ redirects: ReadonlyMap<string, IEdgeTarget>,
595
624
  scoped: ReadonlyArray<IScopedRecord>,
596
625
  byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
597
626
  ): Promise<Result<ReadonlyArray<ICandidateEdge>>> {
@@ -606,26 +635,92 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
606
635
  if (proposed.isFailure()) {
607
636
  return proposed;
608
637
  }
638
+ // Redirect BEFORE validation and BEFORE the cycle guard: an edge naming a
639
+ // collapsed candidate must be judged — and persisted — against the record it
640
+ // collapsed into, not against the address that no longer gets written.
641
+ const edges: ReadonlyArray<ICandidateEdge> = MemoryIngestOrchestrator._redirectEdges(
642
+ proposed.value,
643
+ redirects
644
+ );
609
645
  // refIds and the existing-record view (`byKey`, shared with the verdict path)
610
646
  // both key on the canonical scoped address, so a stem reused across scopes
611
647
  // never aliases.
612
648
  const refIds: ReadonlySet<string> = new Set<string>(
613
649
  writablePlans.map((plan) => edgeTargetKey(plan.refTarget))
614
650
  );
615
- const validation: Result<true> = this._validateEdges(item, proposed.value, refIds, byKey);
651
+ const validation: Result<true> = this._validateEdges(item, edges, refIds, byKey);
616
652
  if (validation.isFailure()) {
617
653
  return fail(validation.message);
618
654
  }
619
655
  if (this._cycleGuard === 'reject') {
620
656
  const guard: Result<true> = assertNoCycles(
621
657
  MemoryIngestOrchestrator._existingEdges(scoped),
622
- proposed.value.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type }))
658
+ edges.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type }))
623
659
  );
624
660
  if (guard.isFailure()) {
625
661
  return fail(`ingest '${item.id}': ${guard.message}`);
626
662
  }
627
663
  }
628
- return succeed(proposed.value);
664
+ return succeed(edges);
665
+ }
666
+
667
+ /**
668
+ * The stage-5 edge-target redirect map for this pass: every `duplicate-of`
669
+ * candidate's OWN scoped address, mapped to the address of the record its
670
+ * verdict collapsed it into (design note §3).
671
+ *
672
+ * @remarks
673
+ * A `duplicate-of` verdict means "this candidate IS that record". The candidate
674
+ * is therefore not written, and its address never becomes a live reference — so
675
+ * a sibling edge built against it in the same pass would resolve to neither a
676
+ * written candidate nor an existing record and would fail the WHOLE ingest item.
677
+ * That is a second-order hazard of collapsing, not a defect in the edge: the
678
+ * host related two candidates it was correctly told about, and one of them
679
+ * turned out to already exist.
680
+ *
681
+ * Redirecting is the honest repair, and it is independent of {@link DedupScope}
682
+ * — it applies equally to a `'content'` kind, where the collapse is exactly
683
+ * right and the ingest still must not fail.
684
+ *
685
+ * The map cannot chain: {@link MemoryIngestOrchestrator._planFromVerdict}
686
+ * requires every target-bearing verdict's target to already exist in the store
687
+ * snapshot, so a redirect destination is always a persisted record and never
688
+ * another collapsed candidate. One pass is sufficient by construction.
689
+ */
690
+ private static _collapseRedirects(plans: ReadonlyArray<ICandidatePlan>): ReadonlyMap<string, IEdgeTarget> {
691
+ const redirects: Map<string, IEdgeTarget> = new Map<string, IEdgeTarget>();
692
+ for (const plan of plans) {
693
+ if (plan.verdict.verdict === 'duplicate-of') {
694
+ redirects.set(edgeTargetKey(plan.refTarget), plan.verdict.target);
695
+ }
696
+ }
697
+ return redirects;
698
+ }
699
+
700
+ /**
701
+ * Rewrite each edge whose TARGET names a collapsed candidate to point at the
702
+ * record that candidate collapsed into.
703
+ *
704
+ * @remarks
705
+ * Only the target is redirected. An edge whose SOURCE is a collapsed candidate
706
+ * stays untouched and is still rejected by {@link
707
+ * MemoryIngestOrchestrator._validateEdges}: sources are the records an edge is
708
+ * written ONTO, a collapsed candidate is never written, and the relation
709
+ * extractor is only ever offered writable candidates as edge sources in the
710
+ * first place. Silently relocating such an edge onto an existing record would
711
+ * attribute a link the host never asked for — the loud failure is correct there.
712
+ */
713
+ private static _redirectEdges(
714
+ edges: ReadonlyArray<ICandidateEdge>,
715
+ redirects: ReadonlyMap<string, IEdgeTarget>
716
+ ): ReadonlyArray<ICandidateEdge> {
717
+ if (redirects.size === 0) {
718
+ return edges;
719
+ }
720
+ return edges.map((e) => {
721
+ const to: IEdgeTarget | undefined = redirects.get(edgeTargetKey(e.edge.target));
722
+ return to === undefined ? e : { ...e, edge: { ...e.edge, target: to } };
723
+ });
629
724
  }
630
725
 
631
726
  /**
@@ -60,16 +60,19 @@ export interface IFragmentRetrieverCapabilities {
60
60
  * The sub-document semantic-search retriever — the "discovery" half of a
61
61
  * search-then-read contract. It embeds a fragment query, queries the
62
62
  * {@link IFragmentVectorIndex}, and returns the raw per-fragment
63
- * {@link IVectorQueryHit | hits} (each carrying a record `target` AND the matched
64
- * `locator`), NOT resolved records: the consumer re-reads each record and slices it
65
- * by the locator on its own read side.
63
+ * {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of
64
+ * `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved
65
+ * records: the consumer re-reads each record and resolves the fragment on its own
66
+ * read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it
67
+ * is not a slice guaranteed to reproduce the fragment's text.
66
68
  *
67
69
  * @remarks
68
70
  * Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and
69
- * returns records; fragment discovery is span-granular and returns locators. Keeping
70
- * it a distinct surface matches the consumer contract (memory stays record-granular;
71
- * sub-document knowledge uses a separate fragment index) and avoids overloading the
72
- * record retriever's return type with a locator that only makes sense here.
71
+ * returns records; fragment discovery is fragment-granular and returns fragment
72
+ * identities. Keeping it a distinct surface matches the consumer contract (memory
73
+ * stays record-granular; sub-document knowledge uses a separate fragment index) and
74
+ * avoids overloading the record retriever's return type with identity fields that
75
+ * only make sense here.
73
76
  *
74
77
  * When no backend is wired, `supportsFragmentRecall` is `false` and any fragment
75
78
  * query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER
@@ -147,6 +147,35 @@ export interface IMemoryStore {
147
147
  */
148
148
  asRecordSource(): IMemoryRecordSource;
149
149
 
150
+ /**
151
+ * The EFFECTIVE {@link DedupScope} for `kind` — the granularity at which a
152
+ * write for this kind deduplicates against the existing vault.
153
+ *
154
+ * @remarks
155
+ * This is a **read accessor over the store's already-injected write policies**,
156
+ * and it is the single place any caller — the store's own write path included —
157
+ * asks what a kind's dedup granularity is. It resolves the full chain the store
158
+ * applies on write: the kind's registered {@link IWritePolicy}, falling back to
159
+ * the store's default policy, then that policy's
160
+ * {@link IWritePolicy.dedupScope | dedupScope}, falling back to
161
+ * {@link DEFAULT_DEDUP_SCOPE}. Note the store's default policy is a
162
+ * {@link KnowledgeLwwPolicy}, which declares `'content'` — so a kind with NO
163
+ * registered policy resolves to `'content'`, not to `DEFAULT_DEDUP_SCOPE`.
164
+ *
165
+ * It exists so a caller that must agree with the store about dedup granularity
166
+ * — notably the ingest orchestrator's stage-4 layer-1 exact match — can read the
167
+ * declaration through this seam instead of being handed a second copy of the
168
+ * policy map. A second declaration site is precisely the defect this accessor
169
+ * was added to remove.
170
+ *
171
+ * Deliberately synchronous, total, and NOT `Result`-returning: it reads
172
+ * constructor-injected configuration, touches no I/O, and cannot fail (every
173
+ * link in the fallback chain has a total default). It exposes only the scope,
174
+ * never the {@link IWritePolicy} itself, so it can never become a back door for
175
+ * invoking admission or merge logic out of band.
176
+ */
177
+ dedupScopeFor(kind: Kind): DedupScope;
178
+
150
179
  /**
151
180
  * Write a record. Validates the body, computes a content hash, deduplicates
152
181
  * (scope-wide, before policy), applies the kind's {@link IWritePolicy}, stamps
@@ -194,6 +223,59 @@ export interface IFileTreeMemoryStoreCreateParams {
194
223
  readonly rankProjectors?: ReadonlyMap<Kind, RankProjector>;
195
224
  /** Default codec for kinds without an explicit entry. */
196
225
  readonly defaultCodec?: IIdentityCodec;
226
+ /**
227
+ * Optional derived-index implementation. Defaults to a fresh {@link MemoryIndex} —
228
+ * omitting this parameter is byte-identical to the store's behavior before the
229
+ * parameter existed. When supplied, the store uses it for EVERY index operation
230
+ * it performs and never holds a second index, so an injected index is the store's
231
+ * only view of its own records:
232
+ *
233
+ * - `rebuild` — once, from the initial vault walk in `create()`.
234
+ * - `patch` — on every persisted write, delete, version invalidation, and
235
+ * cap-cull eviction.
236
+ * - `entries` — behind {@link IMemoryStore.list | list} /
237
+ * {@link IMemoryStore.listScoped | listScoped}, the keyed temporal reads, the
238
+ * write path's content-hash dedup and write-policy admission cohort, AND the
239
+ * temporal (versioned) write and delete paths, which resolve an entity's
240
+ * version history entirely from the index.
241
+ *
242
+ * That last group is the one to weigh before injecting anything other than a
243
+ * pass-through decorator: an index that filters, reorders, or otherwise reshapes
244
+ * `entries()` changes WRITE semantics, not just what reads return. Concretely,
245
+ * on a versioned kind the store derives an entity's whole version history from
246
+ * `entries()` filtered by scope, and that derivation decides which version a
247
+ * `put` treats as current (so what it dedups against and what it merges its
248
+ * patch over), which prior versions it stamps `invalid_at` on, what the
249
+ * admission cohort is, and which versions a `delete` tombstones. An index that
250
+ * hides a version makes it invisible to all of those — the FileTree still holds
251
+ * it, but the store will not supersede, invalidate, or tombstone it. On flat
252
+ * kinds the same reshaping changes what dedups and what a cap-cull policy
253
+ * evicts. A faithful delegating decorator — the intended use below — has no such
254
+ * effect. Note the store's keyed reads ({@link IMemoryStore.get | get} on a flat
255
+ * kind, and {@link IMemoryStore.getById | getById}) go to the FileTree, not the
256
+ * index; the FileTree remains the source of truth and the index stays a derived
257
+ * view.
258
+ *
259
+ * @remarks
260
+ * **This is an instrumentation seam, NOT a resident-memory fix.** The intended
261
+ * use is wrapping the shipped {@link MemoryIndex} in a decorator that counts and
262
+ * times the calls the store makes — resident bytes by kind, open cost against
263
+ * vault size, where the curve actually bends — so a decision about a partial-read
264
+ * redesign can be driven by measurements instead of estimates.
265
+ *
266
+ * It does NOT lower the store's resident-memory ceiling, and injecting a
267
+ * "persisted" or "lazy" index will not change that. {@link IMemoryIndex}'s read
268
+ * surface returns whole records by construction: `entries()` yields
269
+ * {@link IIndexedMemoryRecord}s and `byKind` / `byTag` / `byRecency` / `byRank`
270
+ * yield `IMemoryRecord<unknown>` — `{ envelope, body }` pairs with the body
271
+ * materialized. Any implementation satisfying the current contract must therefore
272
+ * be able to produce every body on demand. An injected index changes WHERE records
273
+ * come from; it does not change WHETHER bodies are held. Lowering the ceiling
274
+ * requires a partial-read (id-or-envelope-only) redesign of `IMemoryIndex` itself,
275
+ * which is separate, breaking, design-first work and is deliberately not part of
276
+ * this seam.
277
+ */
278
+ readonly index?: IMemoryIndex;
197
279
  /** Scope encoding. Defaults to {@link defaultMemoryScopeEncoding}. */
198
280
  readonly scopeEncoding?: (scope: MemoryScopeKey) => Result<string>;
199
281
  /**
@@ -412,13 +494,15 @@ export class FileTreeMemoryStore implements IMemoryStore {
412
494
  }
413
495
 
414
496
  /**
415
- * Family-convention factory. Builds the derived index and a default LWW
416
- * policy, then performs an initial FileTree walk so an existing vault is
417
- * indexed (and the `seq` counter resumes past the highest persisted `seq`).
497
+ * Family-convention factory. Resolves the derived index (the caller's
498
+ * {@link IFileTreeMemoryStoreCreateParams.index | index} when supplied, a fresh
499
+ * {@link MemoryIndex} otherwise) and a default LWW policy, then performs an
500
+ * initial FileTree walk so an existing vault is indexed (and the `seq` counter
501
+ * resumes past the highest persisted `seq`).
418
502
  */
419
503
  public static create(params: IFileTreeMemoryStoreCreateParams): Result<FileTreeMemoryStore> {
420
504
  return KnowledgeLwwPolicy.create().onSuccess((defaultPolicy) =>
421
- MemoryIndex.create().onSuccess((index) => {
505
+ FileTreeMemoryStore._resolveIndex(params.index).onSuccess((index) => {
422
506
  const store: FileTreeMemoryStore = new FileTreeMemoryStore({
423
507
  root: params.root,
424
508
  registry: params.registry,
@@ -442,6 +526,21 @@ export class FileTreeMemoryStore implements IMemoryStore {
442
526
  );
443
527
  }
444
528
 
529
+ /**
530
+ * Resolve the derived index for a `create()`: the caller's injected
531
+ * {@link IMemoryIndex} verbatim, or a fresh {@link MemoryIndex} when none was
532
+ * supplied (the default that keeps an omitting caller byte-identical).
533
+ */
534
+ private static _resolveIndex(index: IMemoryIndex | undefined): Result<IMemoryIndex> {
535
+ // Nullish rather than strictly-undefined, matching how every sibling optional
536
+ // param in `create()` handles absence (`params.codecs ?? new Map()`, and so on).
537
+ // A JS caller — or a TS caller arriving through an `unknown` escape hatch —
538
+ // passing `null` otherwise gets `null` installed as the store's index and fails
539
+ // later inside `entries()` with a message that names neither the param nor the
540
+ // cause.
541
+ return index ? succeed(index) : MemoryIndex.create();
542
+ }
543
+
445
544
  /** {@inheritDoc IMemoryStore.get} */
446
545
  public async get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>> {
447
546
  const result: Result<IMemoryRecord<unknown> | undefined> = this._codecFor(kind).onSuccess((codec) =>
@@ -741,7 +840,10 @@ export class FileTreeMemoryStore implements IMemoryStore {
741
840
  hash: string
742
841
  ): Promise<Result<IPutOutcome>> {
743
842
  const policy: IWritePolicy = this._policyFor(record.envelope.kind);
744
- const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
843
+ // Read through the public accessor so the write path and every external
844
+ // caller (the ingest orchestrator's layer-1) resolve dedup granularity from
845
+ // ONE place — there is no second declaration site.
846
+ const dedupScope: DedupScope = this.dedupScopeFor(record.envelope.kind);
745
847
  // Content-hash dedup runs BEFORE policy. Its granularity is the kind's
746
848
  // `dedupScope`:
747
849
  // - 'content': an identical { kind, body, links } triple ANYWHERE in the
@@ -1171,7 +1273,8 @@ export class FileTreeMemoryStore implements IMemoryStore {
1171
1273
  const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
1172
1274
  const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
1173
1275
  const policy: IWritePolicy = this._policyFor(kind);
1174
- const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
1276
+ // Same single-owner read as the flat path (see `_writeResolved`).
1277
+ const dedupScope: DedupScope = this.dedupScopeFor(kind);
1175
1278
  return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
1176
1279
  // Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
1177
1280
  // its mutable metadata are unchanged (does not spawn a redundant version).
@@ -1514,6 +1617,11 @@ export class FileTreeMemoryStore implements IMemoryStore {
1514
1617
  return this._writePolicies.get(kind) ?? this._defaultPolicy;
1515
1618
  }
1516
1619
 
1620
+ /** {@inheritDoc IMemoryStore.dedupScopeFor} */
1621
+ public dedupScopeFor(kind: Kind): DedupScope {
1622
+ return this._policyFor(kind).dedupScope ?? DEFAULT_DEDUP_SCOPE;
1623
+ }
1624
+
1517
1625
  /**
1518
1626
  * Read and validate the record at `<scope>/<idStem>.md`, returning `undefined`
1519
1627
  * when the scope directory or file is absent. Verifies the on-disk id ↔
@@ -90,6 +90,27 @@ export interface IWritePolicy {
90
90
  * record. Called when admission is `accept` AND a record with the same
91
91
  * `entityId` already exists (an update, not a first write).
92
92
  *
93
+ * @remarks
94
+ * **RFC-7386 semantics are the contract here, not an artifact of the shipped
95
+ * policies' merge configuration.** Implementations are expected to honor them,
96
+ * and consumers may rely on them:
97
+ *
98
+ * - **Objects merge per key.** A supplied key overwrites; an omitted key is
99
+ * PRESERVED, not dropped. Patching one key of a nested object (e.g. one
100
+ * field of `provenance`) leaves its siblings intact.
101
+ * - **An explicit `null` on a sub-key clears exactly that sub-key.** This is
102
+ * the sanctioned way to remove a single key from a nested object.
103
+ * - **Arrays replace wholesale** — `tags` / `links` are never element-merged.
104
+ * - **A whole-block `null` that would delete a REQUIRED field is rejected
105
+ * loudly** (`Result.fail`), never silently accepted. `body` / `tags` /
106
+ * `links` / `provenance` are required; `embeddingRef` is optional and a
107
+ * `null` restores it to absent (NOT to `null`), so it stays hash-stable.
108
+ *
109
+ * Which fields these guarantees cover is **policy-dependent**: they apply to
110
+ * the fields the policy declares in {@link IWritePolicy.mutableFields}, and a
111
+ * field outside that list is inert — its patch key is dropped before the merge,
112
+ * so a `null` on it neither clears the value nor raises an error.
113
+ *
93
114
  * @param existing - The current persisted record.
94
115
  * @param patch - A partial JSON object in Merge Patch format. `null`
95
116
  * deletes the corresponding key; arrays replace wholesale; nested objects
@@ -133,13 +154,21 @@ const MERGE_PATCH_OPTIONS: Partial<IJsonEditorOptions> = {
133
154
  * view, runs the merge over that view, then rebuilds a coherent record. The
134
155
  * identity and transaction-time envelope fields (`id`, `entityId`, `kind`,
135
156
  * `created`, `updated`, `seq`, `contentHash`) are NOT mutable and are
136
- * preserved verbatim; the store stamps `updated` / `seq` on write.
157
+ * preserved verbatim; the store stamps `updated` / `seq` / `contentHash` on write.
158
+ *
159
+ * Because the surface is pinned rather than caller-supplied, the RFC-7386
160
+ * guarantees documented on {@link IWritePolicy.applyUpdate} apply to every field
161
+ * listed above — in particular to `provenance`, whose keys merge individually, a
162
+ * `null` on any one of which clears that key alone, and a `null` on the whole
163
+ * block of which is rejected loudly (it is a required field). Consumers may
164
+ * depend on this; it is covered by tests.
137
165
  * @public
138
166
  */
139
167
  export class KnowledgeLwwPolicy implements IWritePolicy {
140
168
  /**
141
169
  * The knowledge mutable surface: the body plus the envelope metadata a
142
- * consumer may revise without minting a new entity.
170
+ * consumer may revise without minting a new entity. Pinned, not
171
+ * caller-supplied — see the class remarks for what that guarantees.
143
172
  */
144
173
  public readonly mutableFields: ReadonlyArray<string> = [
145
174
  'body',
@@ -271,6 +300,15 @@ export interface IMemoryCapCullPolicyParams {
271
300
  * The fields a merge-patch update may touch (drawn from the record-level
272
301
  * mutable vocabulary: `body` / `tags` / `links` / `provenance` /
273
302
  * `embeddingRef`). Fields outside this list are immutable.
303
+ *
304
+ * @remarks
305
+ * This list — unlike {@link KnowledgeLwwPolicy}'s pinned surface — is
306
+ * caller-supplied, so it is what decides which fields get the RFC-7386
307
+ * guarantees documented on {@link IWritePolicy.applyUpdate}. Declare
308
+ * `provenance` here to get per-key provenance merging and `null` sub-key
309
+ * clearing; omit it and every provenance patch key is inert (dropped before
310
+ * the merge, so the existing value is preserved verbatim and even a
311
+ * whole-block `null` is a silent no-op rather than an error).
274
312
  */
275
313
  readonly mutableFields: ReadonlyArray<string>;
276
314
  }
@@ -0,0 +1,55 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { Converter, Converters, Result, fail, succeed } from '@fgv/ts-utils';
7
+ import { IEmbeddedFragment, IFragmentLocator } from './vectorIndex';
8
+
9
+ /**
10
+ * Converter for an {@link IFragmentLocator}. Validates the two offsets only — the
11
+ * span's *meaning* (character / byte / token unit) is the consumer's, and the index
12
+ * never interprets it. Note the span is advisory: see {@link IFragmentLocator}.
13
+ * @public
14
+ */
15
+ export const fragmentLocatorConverter: Converter<IFragmentLocator> = Converters.object<IFragmentLocator>({
16
+ start: Converters.number,
17
+ end: Converters.number
18
+ });
19
+
20
+ /** Validates an already-constructed `Float32Array` embedding vector in place. */
21
+ const embeddingVectorConverter: Converter<Float32Array> = Converters.generic<Float32Array>(
22
+ (from: unknown): Result<Float32Array> =>
23
+ from instanceof Float32Array ? succeed(from) : fail('embedded fragment: vector must be a Float32Array')
24
+ );
25
+
26
+ /** The field-shape half of {@link embeddedFragmentConverter}; the invariant is layered on top. */
27
+ const embeddedFragmentFieldsConverter: Converter<IEmbeddedFragment> = Converters.object<IEmbeddedFragment>(
28
+ {
29
+ locator: fragmentLocatorConverter.optional(),
30
+ fragmentId: Converters.string.optional(),
31
+ vector: embeddingVectorConverter
32
+ },
33
+ { optionalFields: ['locator', 'fragmentId'] }
34
+ );
35
+
36
+ /**
37
+ * Converter for an {@link IEmbeddedFragment}, and the boundary that enforces the
38
+ * fragment-identifiability invariant: **at least one of `locator` / `fragmentId`
39
+ * must be present.**
40
+ *
41
+ * @remarks
42
+ * The invariant lives here rather than in the type. A conditional-required union
43
+ * (`{ locator; fragmentId? } | { locator?; fragmentId }`) was considered and
44
+ * declined: it costs at every construction site and buys nothing at the read site,
45
+ * where each field reads as `… | undefined` either way. What it must not cost is
46
+ * identifiability — a fragment carrying neither identity cannot be resolved back to
47
+ * anything by a consumer holding the query hit, so it is rejected here.
48
+ * @public
49
+ */
50
+ export const embeddedFragmentConverter: Converter<IEmbeddedFragment> =
51
+ embeddedFragmentFieldsConverter.withConstraint((fragment: IEmbeddedFragment) =>
52
+ fragment.locator === undefined && fragment.fragmentId === undefined
53
+ ? fail('embedded fragment: at least one of `locator` or `fragmentId` is required')
54
+ : succeed(fragment)
55
+ );
@@ -8,16 +8,31 @@ import { IEdgeTarget, edgeTargetKey } from '../types';
8
8
  import {
9
9
  FragmentEmbedder,
10
10
  IEmbeddedFragment,
11
- IFragmentLocator,
12
11
  IFragmentVectorIndex,
13
12
  IMemoryRecordSource,
14
13
  IScopedMemoryRecord,
15
14
  IVectorQueryHit
16
15
  } from './vectorIndex';
17
16
 
18
- /** One stored fragment: its in-record locator plus the vector for that span. */
17
+ /**
18
+ * The identity fields a fragment was added with, already in query-hit shape: a field
19
+ * the fragment did not carry is *absent*, never present-but-`undefined`, so a hit
20
+ * for a fragment added without a `fragmentId` is structurally identical to one
21
+ * produced before `fragmentId` existed.
22
+ */
23
+ type FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;
24
+
25
+ /** Project an incoming fragment's identity fields, dropping the ones it did not carry. */
26
+ function fragmentIdentity(fragment: IEmbeddedFragment): FragmentIdentity {
27
+ return {
28
+ ...(fragment.locator !== undefined ? { locator: fragment.locator } : {}),
29
+ ...(fragment.fragmentId !== undefined ? { fragmentId: fragment.fragmentId } : {})
30
+ };
31
+ }
32
+
33
+ /** One stored fragment: the identity it was added with plus its vector. */
19
34
  interface IStoredFragment {
20
- readonly locator: IFragmentLocator;
35
+ readonly identity: FragmentIdentity;
21
36
  readonly vector: Float32Array;
22
37
  }
23
38
 
@@ -36,9 +51,11 @@ interface IScoredFragment {
36
51
  /**
37
52
  * The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the
38
53
  * fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many
39
- * `Float32Array`s per record (one per in-record {@link IFragmentLocator | span})
40
- * and answers a query by computing cosine similarity against every stored
41
- * fragment, returning the top-k fragment hits by descending score.
54
+ * `Float32Array`s per record (one per {@link IEmbeddedFragment | fragment}) and
55
+ * answers a query by computing cosine similarity against every stored fragment,
56
+ * returning the top-k fragment hits by descending score. Each hit carries back
57
+ * whichever of {@link IFragmentLocator | locator} / `fragmentId` its fragment was
58
+ * added with; a fragment must carry at least one of the two.
42
59
  *
43
60
  * @remarks
44
61
  * Same regime and same non-goals as {@link InMemoryCosineIndex}: no external
@@ -108,6 +125,16 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
108
125
  if (fragment.vector.length === 0) {
109
126
  return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
110
127
  }
128
+ // A fragment carrying neither identity cannot be resolved back to anything by a
129
+ // consumer holding the hit — the same invariant `embeddedFragmentConverter`
130
+ // enforces at the untyped boundary, re-checked here at the index seam.
131
+ if (fragment.locator === undefined && fragment.fragmentId === undefined) {
132
+ return Promise.resolve(
133
+ fail(
134
+ `fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`
135
+ )
136
+ );
137
+ }
111
138
  if (dimension === undefined) {
112
139
  dimension = fragment.vector.length;
113
140
  } else if (fragment.vector.length !== dimension) {
@@ -118,7 +145,7 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
118
145
  );
119
146
  }
120
147
  // Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.
121
- stored.push({ locator: fragment.locator, vector: Float32Array.from(fragment.vector) });
148
+ stored.push({ identity: fragmentIdentity(fragment), vector: Float32Array.from(fragment.vector) });
122
149
  }
123
150
  // Whole-record replace: an empty `fragments` array drops the record entirely
124
151
  // rather than leaving an empty shell behind. Commit the (possibly newly-derived)
@@ -163,7 +190,7 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
163
190
  hit: {
164
191
  target: record.target,
165
192
  score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),
166
- locator: fragment.locator
193
+ ...fragment.identity
167
194
  }
168
195
  });
169
196
  }
@@ -4,5 +4,6 @@
4
4
  */
5
5
 
6
6
  export * from './vectorIndex';
7
+ export * from './fragmentConverters';
7
8
  export * from './inMemoryCosineIndex';
8
9
  export * from './inMemoryFragmentCosineIndex';