@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.
- package/.rush/temp/{6e1299d1cebb080772163a532c221cd181b54f9f.tar.log → 5667e36f1c0cc503a5c20a3ebff76e7b9a702d41.tar.log} +8 -2
- package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +2 -2
- package/.rush/temp/operation/build/all.log +2 -2
- package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
- package/.rush/temp/operation/build/state.json +1 -1
- package/README.md +55 -0
- package/dist/packlets/ingest/orchestrator.js +104 -19
- package/dist/packlets/ingest/orchestrator.js.map +1 -1
- package/dist/packlets/store/fileTreeMemoryStore.js +11 -4
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/test/unit/ingest/dedupScope.test.js +434 -0
- package/dist/test/unit/ingest/dedupScope.test.js.map +1 -0
- package/dist/test/unit/ingest/orchestrator.test.js +7 -4
- package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/dist/test/unit/tools/memoryTools.test.js +1 -0
- package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
- package/dist/ts-agent-memory.d.ts +92 -5
- package/etc/ts-agent-memory.api.md +2 -0
- package/lib/packlets/ingest/orchestrator.d.ts +62 -5
- package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
- package/lib/packlets/ingest/orchestrator.js +104 -19
- package/lib/packlets/ingest/orchestrator.js.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +31 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +11 -4
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/test/unit/ingest/dedupScope.test.d.ts +2 -0
- package/lib/test/unit/ingest/dedupScope.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/dedupScope.test.js +436 -0
- package/lib/test/unit/ingest/dedupScope.test.js.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.js +7 -4
- package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/lib/test/unit/tools/memoryTools.test.js +1 -0
- package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
- package/package.json +7 -7
- package/rush-logs/ts-agent-memory.build.cache.log +1 -1
- package/rush-logs/ts-agent-memory.build.log +2 -2
- package/src/packlets/ingest/orchestrator.ts +116 -21
- package/src/packlets/store/fileTreeMemoryStore.ts +40 -2
- package/src/test/unit/ingest/dedupScope.test.ts +542 -0
- package/src/test/unit/ingest/orchestrator.test.ts +4 -0
- package/src/test/unit/tools/memoryTools.test.ts +1 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +9 -5
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +97 -0
- 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
|
|
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
|
|
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
|
|
538
|
-
*
|
|
539
|
-
* — only a live (non-temporal or current) record deduplicates a
|
|
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
|
-
|
|
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
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ↔
|