@fgv/ts-agent-memory 5.1.0-46 → 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 (46) hide show
  1. package/.rush/temp/{6e1299d1cebb080772163a532c221cd181b54f9f.tar.log → 5667e36f1c0cc503a5c20a3ebff76e7b9a702d41.tar.log} +8 -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 +55 -0
  7. package/dist/packlets/ingest/orchestrator.js +104 -19
  8. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  9. package/dist/packlets/store/fileTreeMemoryStore.js +11 -4
  10. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  11. package/dist/test/unit/ingest/dedupScope.test.js +434 -0
  12. package/dist/test/unit/ingest/dedupScope.test.js.map +1 -0
  13. package/dist/test/unit/ingest/orchestrator.test.js +7 -4
  14. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
  15. package/dist/test/unit/tools/memoryTools.test.js +1 -0
  16. package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
  17. package/dist/ts-agent-memory.d.ts +92 -5
  18. package/etc/ts-agent-memory.api.md +2 -0
  19. package/lib/packlets/ingest/orchestrator.d.ts +62 -5
  20. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  21. package/lib/packlets/ingest/orchestrator.js +104 -19
  22. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  23. package/lib/packlets/store/fileTreeMemoryStore.d.ts +31 -1
  24. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  25. package/lib/packlets/store/fileTreeMemoryStore.js +11 -4
  26. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  27. package/lib/test/unit/ingest/dedupScope.test.d.ts +2 -0
  28. package/lib/test/unit/ingest/dedupScope.test.d.ts.map +1 -0
  29. package/lib/test/unit/ingest/dedupScope.test.js +436 -0
  30. package/lib/test/unit/ingest/dedupScope.test.js.map +1 -0
  31. package/lib/test/unit/ingest/orchestrator.test.js +7 -4
  32. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
  33. package/lib/test/unit/tools/memoryTools.test.js +1 -0
  34. package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
  35. package/package.json +7 -7
  36. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  37. package/rush-logs/ts-agent-memory.build.log +2 -2
  38. package/src/packlets/ingest/orchestrator.ts +116 -21
  39. package/src/packlets/store/fileTreeMemoryStore.ts +40 -2
  40. package/src/test/unit/ingest/dedupScope.test.ts +542 -0
  41. package/src/test/unit/ingest/orchestrator.test.ts +4 -0
  42. package/src/test/unit/tools/memoryTools.test.ts +1 -0
  43. package/temp/build/lint/_eslint-5eVG3S6w.json +9 -5
  44. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  45. package/temp/ts-agent-memory.api.json +97 -0
  46. package/temp/ts-agent-memory.api.md +2 -0
@@ -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
  /**
@@ -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
@@ -811,7 +840,10 @@ export class FileTreeMemoryStore implements IMemoryStore {
811
840
  hash: string
812
841
  ): Promise<Result<IPutOutcome>> {
813
842
  const policy: IWritePolicy = this._policyFor(record.envelope.kind);
814
- 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);
815
847
  // Content-hash dedup runs BEFORE policy. Its granularity is the kind's
816
848
  // `dedupScope`:
817
849
  // - 'content': an identical { kind, body, links } triple ANYWHERE in the
@@ -1241,7 +1273,8 @@ export class FileTreeMemoryStore implements IMemoryStore {
1241
1273
  const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
1242
1274
  const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
1243
1275
  const policy: IWritePolicy = this._policyFor(kind);
1244
- 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);
1245
1278
  return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
1246
1279
  // Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
1247
1280
  // its mutable metadata are unchanged (does not spawn a redundant version).
@@ -1584,6 +1617,11 @@ export class FileTreeMemoryStore implements IMemoryStore {
1584
1617
  return this._writePolicies.get(kind) ?? this._defaultPolicy;
1585
1618
  }
1586
1619
 
1620
+ /** {@inheritDoc IMemoryStore.dedupScopeFor} */
1621
+ public dedupScopeFor(kind: Kind): DedupScope {
1622
+ return this._policyFor(kind).dedupScope ?? DEFAULT_DEDUP_SCOPE;
1623
+ }
1624
+
1587
1625
  /**
1588
1626
  * Read and validate the record at `<scope>/<idStem>.md`, returning `undefined`
1589
1627
  * when the scope directory or file is absent. Verifies the on-disk id ↔