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

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 (82) hide show
  1. package/.rush/temp/{cbbdbe09515171b4eba8f2592be2dace1e4e8142.tar.log → 285f03271c27ef724d49e730c0db58d9e4ac1a44.tar.log} +38 -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/retrieve/fragmentSemanticRetriever.js +78 -0
  7. package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -0
  8. package/dist/packlets/retrieve/index.js +1 -0
  9. package/dist/packlets/retrieve/index.js.map +1 -1
  10. package/dist/packlets/store/fileTreeMemoryStore.js +105 -14
  11. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  12. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +200 -0
  13. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -0
  14. package/dist/packlets/vector/index.js +1 -0
  15. package/dist/packlets/vector/index.js.map +1 -1
  16. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  17. package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js +116 -0
  18. package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +1 -0
  19. package/dist/test/unit/store/fragmentEmbedOnWrite.test.js +255 -0
  20. package/dist/test/unit/store/fragmentEmbedOnWrite.test.js.map +1 -0
  21. package/dist/test/unit/store/lenientOpen.test.js +248 -0
  22. package/dist/test/unit/store/lenientOpen.test.js.map +1 -0
  23. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +297 -0
  24. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -0
  25. package/dist/ts-agent-memory.d.ts +367 -4
  26. package/etc/ts-agent-memory.api.md +78 -0
  27. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +90 -0
  28. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -0
  29. package/lib/packlets/retrieve/fragmentSemanticRetriever.js +82 -0
  30. package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -0
  31. package/lib/packlets/retrieve/index.d.ts +1 -0
  32. package/lib/packlets/retrieve/index.d.ts.map +1 -1
  33. package/lib/packlets/retrieve/index.js +1 -0
  34. package/lib/packlets/retrieve/index.js.map +1 -1
  35. package/lib/packlets/store/fileTreeMemoryStore.d.ts +115 -1
  36. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  37. package/lib/packlets/store/fileTreeMemoryStore.js +104 -13
  38. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  39. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +74 -0
  40. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -0
  41. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +204 -0
  42. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -0
  43. package/lib/packlets/vector/index.d.ts +1 -0
  44. package/lib/packlets/vector/index.d.ts.map +1 -1
  45. package/lib/packlets/vector/index.js +1 -0
  46. package/lib/packlets/vector/index.js.map +1 -1
  47. package/lib/packlets/vector/vectorIndex.d.ts +85 -4
  48. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  49. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  50. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts +2 -0
  51. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts.map +1 -0
  52. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js +118 -0
  53. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +1 -0
  54. package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts +2 -0
  55. package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts.map +1 -0
  56. package/lib/test/unit/store/fragmentEmbedOnWrite.test.js +257 -0
  57. package/lib/test/unit/store/fragmentEmbedOnWrite.test.js.map +1 -0
  58. package/lib/test/unit/store/lenientOpen.test.d.ts +2 -0
  59. package/lib/test/unit/store/lenientOpen.test.d.ts.map +1 -0
  60. package/lib/test/unit/store/lenientOpen.test.js +250 -0
  61. package/lib/test/unit/store/lenientOpen.test.js.map +1 -0
  62. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts +2 -0
  63. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts.map +1 -0
  64. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +299 -0
  65. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -0
  66. package/package.json +7 -7
  67. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  68. package/rush-logs/ts-agent-memory.build.log +2 -2
  69. package/src/packlets/retrieve/fragmentSemanticRetriever.ts +135 -0
  70. package/src/packlets/retrieve/index.ts +1 -0
  71. package/src/packlets/store/fileTreeMemoryStore.ts +208 -16
  72. package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +262 -0
  73. package/src/packlets/vector/index.ts +1 -0
  74. package/src/packlets/vector/vectorIndex.ts +97 -4
  75. package/src/test/unit/retrieve/fragmentSemanticRetriever.test.ts +163 -0
  76. package/src/test/unit/store/fragmentEmbedOnWrite.test.ts +349 -0
  77. package/src/test/unit/store/lenientOpen.test.ts +292 -0
  78. package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +389 -0
  79. package/temp/build/lint/_eslint-5eVG3S6w.json +29 -5
  80. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  81. package/temp/ts-agent-memory.api.json +4584 -2859
  82. package/temp/ts-agent-memory.api.md +78 -0
@@ -7,10 +7,26 @@ import { Result } from '@fgv/ts-utils';
7
7
  import { IEdgeTarget, IMemoryRecord } from '../types';
8
8
 
9
9
  /**
10
- * A single hit returned by {@link IVectorIndex.query}: the matched record's
11
- * scope-qualified {@link IEdgeTarget | address} and the backend's similarity
12
- * score (higher = more similar; the exact scale is backend-defined). Hits are
13
- * returned in descending score order.
10
+ * A half-open `[start, end)` span into a record's body — the in-record locator a
11
+ * {@link IFragmentVectorIndex} carries on each fragment hit. `start` is inclusive,
12
+ * `end` exclusive. The unit (character / byte / token offsets) is the consumer's
13
+ * choice: the index stores the two integers opaquely and never interprets them,
14
+ * so they line up with whatever locator the consumer's own read side uses.
15
+ * @public
16
+ */
17
+ export interface IFragmentLocator {
18
+ /** Inclusive start offset into the record body. */
19
+ readonly start: number;
20
+ /** Exclusive end offset into the record body. */
21
+ readonly end: number;
22
+ }
23
+
24
+ /**
25
+ * A single hit returned by {@link IVectorIndex.query} (or
26
+ * {@link IFragmentVectorIndex.query}): the matched record's scope-qualified
27
+ * {@link IEdgeTarget | address} and the backend's similarity score (higher = more
28
+ * similar; the exact scale is backend-defined). Hits are returned in descending
29
+ * score order.
14
30
  *
15
31
  * @remarks
16
32
  * The address is a `(scope, id)` pair, NOT a bare {@link MemoryId} — per-scope
@@ -18,6 +34,10 @@ import { IEdgeTarget, IMemoryRecord } from '../types';
18
34
  * stem under different scopes, so a bare id could not disambiguate two records
19
35
  * that share a stem. The caller re-resolves the hit against the record index by
20
36
  * the same scoped address.
37
+ *
38
+ * `locator` is present only on hits from a {@link IFragmentVectorIndex} — it
39
+ * identifies WHICH fragment of the record matched. Record-granular
40
+ * {@link IVectorIndex} hits omit it.
21
41
  * @public
22
42
  */
23
43
  export interface IVectorQueryHit {
@@ -25,6 +45,8 @@ export interface IVectorQueryHit {
25
45
  readonly target: IEdgeTarget;
26
46
  /** Backend similarity score; higher is more similar. */
27
47
  readonly score: number;
48
+ /** The matched fragment's in-record span; present only for fragment-index hits. */
49
+ readonly locator?: IFragmentLocator;
28
50
  }
29
51
 
30
52
  /**
@@ -65,6 +87,65 @@ export interface IVectorIndex {
65
87
  query(vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;
66
88
  }
67
89
 
90
+ /**
91
+ * One embedded fragment of a record: its in-record {@link IFragmentLocator | span}
92
+ * and the vector for that span. Produced by a {@link FragmentEmbedder} and stored
93
+ * via {@link IFragmentVectorIndex.addFragments}.
94
+ * @public
95
+ */
96
+ export interface IEmbeddedFragment {
97
+ /** The fragment's in-record span. */
98
+ readonly locator: IFragmentLocator;
99
+ /** The embedding vector for that span. */
100
+ readonly vector: Float32Array;
101
+ }
102
+
103
+ /**
104
+ * The fragment-granular sibling of {@link IVectorIndex}: instead of one vector per
105
+ * record it holds many vectors per record, each tagged with an in-record
106
+ * {@link IFragmentLocator}, and its `query` returns per-fragment hits carrying that
107
+ * locator. This is the seam behind sub-document semantic search — the "discovery"
108
+ * half of a search-then-read contract, where a hit's `(target, locator)` tells the
109
+ * consumer which record AND which span to read.
110
+ *
111
+ * @remarks
112
+ * Deliberately NOT `extends IVectorIndex`: an index keyed by `(target, locator)`
113
+ * has no well-defined single-vector `add(target, vector)`. It is a parallel
114
+ * contract with three operations — `addFragments`, `remove`, `query` — reusing
115
+ * {@link IVectorQueryHit} (whose `locator` is always populated here). Kept distinct
116
+ * from the record-granular index per the consumer contract: memory recall stays
117
+ * record-granular; sub-document knowledge uses a separate fragment index.
118
+ * @public
119
+ */
120
+ export interface IFragmentVectorIndex {
121
+ /**
122
+ * Add (or replace) all fragments for the scope-qualified `target`. Whole-record
123
+ * semantics: every fragment previously held for `target` is dropped and replaced
124
+ * by `fragments`, so a re-authored document never leaves stale fragments behind.
125
+ * Returns the number of fragments now held for the record.
126
+ */
127
+ addFragments(target: IEdgeTarget, fragments: ReadonlyArray<IEmbeddedFragment>): Promise<Result<number>>;
128
+
129
+ /**
130
+ * Remove every fragment for the scope-qualified `target`. Returns the removed
131
+ * target. Idempotent — removing a target with no fragments still succeeds.
132
+ */
133
+ remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>>;
134
+
135
+ /**
136
+ * Return the `topK` nearest fragments to `vector`, in descending score order,
137
+ * each hit carrying its record `target` and fragment `locator`. When
138
+ * `maxPerRecord` is supplied, no more than that many fragments of any single
139
+ * record appear in the result — the cap is applied during selection (before the
140
+ * `topK` cut) so one long document cannot crowd out others.
141
+ */
142
+ query(
143
+ vector: Float32Array,
144
+ topK: number,
145
+ maxPerRecord?: number
146
+ ): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;
147
+ }
148
+
68
149
  /**
69
150
  * Embeds a complete record into a vector for the store's embed-on-write hook.
70
151
  * Async and `Result`-returning, since a real embedder does a network call (cloud
@@ -74,6 +155,18 @@ export interface IVectorIndex {
74
155
  */
75
156
  export type MemoryEmbedder = (record: IMemoryRecord<unknown>) => Promise<Result<Float32Array>>;
76
157
 
158
+ /**
159
+ * The fragment-granular sibling of {@link MemoryEmbedder}: chunks a record's body
160
+ * and embeds each chunk, returning one {@link IEmbeddedFragment} per chunk. The
161
+ * chunking policy (window size, overlap) lives entirely in the consumer's embedder
162
+ * — the core stays chunking-agnostic, exactly as it stays embedder-agnostic for
163
+ * the record-granular path. Used by the store's fragment-embed-on-write hook.
164
+ * @public
165
+ */
166
+ export type FragmentEmbedder = (
167
+ record: IMemoryRecord<unknown>
168
+ ) => Promise<Result<ReadonlyArray<IEmbeddedFragment>>>;
169
+
77
170
  /**
78
171
  * A record paired with its scope-qualified {@link IEdgeTarget | address}, as
79
172
  * yielded by {@link IMemoryRecordSource.list}. The address is required because
@@ -0,0 +1,163 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import '@fgv/ts-utils-jest';
7
+ import { Result, fail, succeed } from '@fgv/ts-utils';
8
+ import {
9
+ FRAGMENT_SEMANTIC_UNWIRED_MESSAGE,
10
+ FragmentSemanticRetriever,
11
+ IEdgeTarget,
12
+ IEmbeddedFragment,
13
+ IFragmentLocator,
14
+ IFragmentVectorIndex,
15
+ IVectorQueryHit,
16
+ MemoryId,
17
+ MemoryScopeKey
18
+ } from '../../../index';
19
+
20
+ function target(scope: string, id: string): IEdgeTarget {
21
+ return { scope: scope as MemoryScopeKey, id: id as MemoryId };
22
+ }
23
+
24
+ function loc(start: number, end: number): IFragmentLocator {
25
+ return { start, end };
26
+ }
27
+
28
+ function hit(scope: string, id: string, start: number, end: number, score: number): IVectorQueryHit {
29
+ return { target: target(scope, id), score, locator: loc(start, end) };
30
+ }
31
+
32
+ const okEmbed = (): Promise<Result<Float32Array>> => Promise.resolve(succeed(Float32Array.from([0.1, 0.2])));
33
+
34
+ /** A scripted fragment index that records the args it was queried with. */
35
+ class FakeFragmentIndex implements IFragmentVectorIndex {
36
+ public lastTopK: number | undefined;
37
+ public lastMaxPerRecord: number | undefined;
38
+ private readonly _hits: ReadonlyArray<IVectorQueryHit>;
39
+ private readonly _fail: boolean;
40
+ public constructor(hits: ReadonlyArray<IVectorQueryHit>, shouldFail: boolean = false) {
41
+ this._hits = hits;
42
+ this._fail = shouldFail;
43
+ }
44
+ public addFragments(
45
+ __t: IEdgeTarget,
46
+ fragments: ReadonlyArray<IEmbeddedFragment>
47
+ ): Promise<Result<number>> {
48
+ return Promise.resolve(succeed(fragments.length));
49
+ }
50
+ public remove(t: IEdgeTarget): Promise<Result<IEdgeTarget>> {
51
+ return Promise.resolve(succeed(t));
52
+ }
53
+ public query(
54
+ __vector: Float32Array,
55
+ topK: number,
56
+ maxPerRecord?: number
57
+ ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
58
+ this.lastTopK = topK;
59
+ this.lastMaxPerRecord = maxPerRecord;
60
+ return Promise.resolve(this._fail ? fail('fragment backend down') : succeed(this._hits));
61
+ }
62
+ }
63
+
64
+ describe('FragmentSemanticRetriever', () => {
65
+ test('reports supportsFragmentRecall=false when no backend is wired', () => {
66
+ const r = FragmentSemanticRetriever.create({}).orThrow();
67
+ expect(r.capabilities.supportsFragmentRecall).toBe(false);
68
+ });
69
+
70
+ test('reports supportsFragmentRecall=true when a backend is wired', () => {
71
+ const r = FragmentSemanticRetriever.create({
72
+ backend: { fragmentIndex: new FakeFragmentIndex([]), embedQuery: okEmbed }
73
+ }).orThrow();
74
+ expect(r.capabilities.supportsFragmentRecall).toBe(true);
75
+ });
76
+
77
+ test('degrades loudly when no backend is wired — never a silent empty', async () => {
78
+ const r = FragmentSemanticRetriever.create({}).orThrow();
79
+ expect(await r.retrieve({ semantic: 'hi' })).toFailWith(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);
80
+ });
81
+
82
+ test('returns the per-fragment hits (target + locator + score) in backend order', async () => {
83
+ const hits = [hit('knowledge', 'doc-a', 0, 5, 0.9), hit('knowledge', 'doc-a', 20, 25, 0.4)];
84
+ const r = FragmentSemanticRetriever.create({
85
+ backend: { fragmentIndex: new FakeFragmentIndex(hits), embedQuery: okEmbed }
86
+ }).orThrow();
87
+ expect(await r.retrieve({ semantic: 'q' })).toSucceedAndSatisfy(
88
+ (result: ReadonlyArray<IVectorQueryHit>) => {
89
+ expect(result).toEqual(hits);
90
+ expect(result[0].locator).toEqual(loc(0, 5));
91
+ expect(result[1].locator).toEqual(loc(20, 25));
92
+ }
93
+ );
94
+ });
95
+
96
+ test('forwards topK and maxPerRecord to the fragment index', async () => {
97
+ const fragmentIndex = new FakeFragmentIndex([hit('knowledge', 'doc-a', 0, 5, 0.9)]);
98
+ const r = FragmentSemanticRetriever.create({
99
+ backend: { fragmentIndex, embedQuery: okEmbed }
100
+ }).orThrow();
101
+ expect(await r.retrieve({ semantic: 'q', topK: 3, maxPerRecord: 1 })).toSucceed();
102
+ expect(fragmentIndex.lastTopK).toBe(3);
103
+ expect(fragmentIndex.lastMaxPerRecord).toBe(1);
104
+ });
105
+
106
+ test('defaults topK to 10 and leaves maxPerRecord undefined when the query omits them', async () => {
107
+ const fragmentIndex = new FakeFragmentIndex([hit('knowledge', 'doc-a', 0, 5, 0.9)]);
108
+ const r = FragmentSemanticRetriever.create({
109
+ backend: { fragmentIndex, embedQuery: okEmbed }
110
+ }).orThrow();
111
+ expect(await r.retrieve({ semantic: 'q' })).toSucceed();
112
+ expect(fragmentIndex.lastTopK).toBe(10);
113
+ expect(fragmentIndex.lastMaxPerRecord).toBeUndefined();
114
+ });
115
+
116
+ test('fails loudly when the embedder fails', async () => {
117
+ const r = FragmentSemanticRetriever.create({
118
+ backend: {
119
+ fragmentIndex: new FakeFragmentIndex([]),
120
+ embedQuery: () => Promise.resolve(fail('no embed model'))
121
+ }
122
+ }).orThrow();
123
+ expect(await r.retrieve({ semantic: 'q' })).toFailWith(
124
+ /fragment recall: query embedding failed: no embed model/i
125
+ );
126
+ });
127
+
128
+ test('fails loudly when the fragment backend fails', async () => {
129
+ const r = FragmentSemanticRetriever.create({
130
+ backend: { fragmentIndex: new FakeFragmentIndex([], true), embedQuery: okEmbed }
131
+ }).orThrow();
132
+ expect(await r.retrieve({ semantic: 'q' })).toFailWith(
133
+ /fragment recall: fragment query failed: fragment backend down/i
134
+ );
135
+ });
136
+
137
+ test('normalizes a rejecting embedder into a Failure (never escapes as a rejection)', async () => {
138
+ const r = FragmentSemanticRetriever.create({
139
+ backend: {
140
+ fragmentIndex: new FakeFragmentIndex([]),
141
+ embedQuery: () => Promise.reject(new Error('embedder blew up'))
142
+ }
143
+ }).orThrow();
144
+ expect(await r.retrieve({ semantic: 'q' })).toFailWith(
145
+ /fragment recall: query embedding failed: .*embedder blew up/i
146
+ );
147
+ });
148
+
149
+ test('normalizes a rejecting fragment backend into a Failure', async () => {
150
+ const rejectingIndex: IFragmentVectorIndex = {
151
+ addFragments: (__t: IEdgeTarget, f: ReadonlyArray<IEmbeddedFragment>) =>
152
+ Promise.resolve(succeed(f.length)),
153
+ remove: (t: IEdgeTarget) => Promise.resolve(succeed(t)),
154
+ query: () => Promise.reject(new Error('socket hangup'))
155
+ };
156
+ const r = FragmentSemanticRetriever.create({
157
+ backend: { fragmentIndex: rejectingIndex, embedQuery: okEmbed }
158
+ }).orThrow();
159
+ expect(await r.retrieve({ semantic: 'q' })).toFailWith(
160
+ /fragment recall: fragment query failed: .*socket hangup/i
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,349 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import '@fgv/ts-utils-jest';
7
+ import { Converters, Logging, Result, fail, succeed } from '@fgv/ts-utils';
8
+ import { FileTree } from '@fgv/ts-json-base';
9
+ import {
10
+ BodyConverterRegistry,
11
+ EntityId,
12
+ FileTreeMemoryStore,
13
+ FragmentSemanticRetriever,
14
+ IBodyConverterRegistry,
15
+ IEdgeTarget,
16
+ IEmbeddedFragment,
17
+ IFragmentVectorIndex,
18
+ IIdentityCodec,
19
+ IMemoryRecord,
20
+ IVectorQueryHit,
21
+ InMemoryFragmentCosineIndex,
22
+ Kind,
23
+ KnowledgeIdentityCodec,
24
+ MemoryCapCullPolicy,
25
+ MemoryId,
26
+ MemoryScopeKey,
27
+ MtmIdentityCodec,
28
+ IWritePolicy,
29
+ envelopeConverter
30
+ } from '../../../index';
31
+
32
+ const knowledgeKind: Kind = 'knowledge' as Kind;
33
+ const mtmKind: Kind = 'mtm' as Kind;
34
+
35
+ function mutableRoot(): FileTree.IMutableFileTreeDirectoryItem {
36
+ const tree = FileTree.inMemory([], { mutable: true }).orThrow();
37
+ const root = tree.getDirectory('/').orThrow();
38
+ if (!FileTree.isMutableDirectoryItem(root)) {
39
+ throw new Error('expected a mutable root directory');
40
+ }
41
+ return root;
42
+ }
43
+
44
+ function makeRecord(
45
+ id: string,
46
+ body: string,
47
+ kind: string = 'knowledge',
48
+ entityId?: string
49
+ ): IMemoryRecord<unknown> {
50
+ return {
51
+ envelope: envelopeConverter
52
+ .convert({
53
+ id,
54
+ entityId: entityId ?? id,
55
+ kind,
56
+ tags: [],
57
+ links: [],
58
+ created: 0,
59
+ updated: 0,
60
+ seq: 0,
61
+ contentHash: '',
62
+ provenance: { source: 'agent' }
63
+ })
64
+ .orThrow(),
65
+ body
66
+ };
67
+ }
68
+
69
+ /** Deterministic keyword-count feature space (no network), shared with the query side. */
70
+ const MARKERS: ReadonlyArray<string> = ['cat', 'dog', 'fish'];
71
+ function featureVector(text: string): Float32Array {
72
+ const lower: string = text.toLowerCase();
73
+ return Float32Array.from(MARKERS.map((m) => lower.split(m).length - 1));
74
+ }
75
+
76
+ /**
77
+ * Fragment embedder: chunks the body into whitespace-delimited words, emitting one
78
+ * fragment per word with its true `[start, end)` character offsets and a feature
79
+ * vector for that word. Models the consumer-owned chunking policy.
80
+ */
81
+ const fragmentEmbed = (r: IMemoryRecord<unknown>): Promise<Result<ReadonlyArray<IEmbeddedFragment>>> =>
82
+ Promise.resolve(
83
+ Converters.string
84
+ .convert(r.body)
85
+ .withErrorFormat((msg) => `cannot embed fragments: body is not a string: ${msg}`)
86
+ .onSuccess((body) => {
87
+ const fragments: IEmbeddedFragment[] = [];
88
+ const re: RegExp = /\S+/g;
89
+ let m: RegExpExecArray | null = re.exec(body);
90
+ while (m !== null) {
91
+ fragments.push({
92
+ locator: { start: m.index, end: m.index + m[0].length },
93
+ vector: featureVector(m[0])
94
+ });
95
+ m = re.exec(body);
96
+ }
97
+ return succeed(fragments);
98
+ })
99
+ );
100
+
101
+ const queryEmbed = (text: string): Promise<Result<Float32Array>> =>
102
+ Promise.resolve(succeed(featureVector(text)));
103
+
104
+ /** Records addFragments/remove call order and can be configured to fail either op. */
105
+ class SpyFragmentIndex implements IFragmentVectorIndex {
106
+ public readonly calls: string[] = [];
107
+ public failAdd: boolean = false;
108
+ public failRemove: boolean = false;
109
+ private readonly _inner: InMemoryFragmentCosineIndex = InMemoryFragmentCosineIndex.create().orThrow();
110
+
111
+ public async addFragments(
112
+ target: IEdgeTarget,
113
+ fragments: ReadonlyArray<IEmbeddedFragment>
114
+ ): Promise<Result<number>> {
115
+ this.calls.push(`add:${target.id}:${fragments.length}`);
116
+ return this.failAdd ? fail('add boom') : this._inner.addFragments(target, fragments);
117
+ }
118
+ public async remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
119
+ this.calls.push(`remove:${target.id}`);
120
+ return this.failRemove ? fail('remove boom') : this._inner.remove(target);
121
+ }
122
+ public query(
123
+ vector: Float32Array,
124
+ topK: number,
125
+ maxPerRecord?: number
126
+ ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
127
+ return this._inner.query(vector, topK, maxPerRecord);
128
+ }
129
+ }
130
+
131
+ function knowledgeStore(params: {
132
+ fragmentIndex?: IFragmentVectorIndex;
133
+ fragmentEmbedder?: (r: IMemoryRecord<unknown>) => Promise<Result<ReadonlyArray<IEmbeddedFragment>>>;
134
+ logger?: Logging.ILogger;
135
+ }): FileTreeMemoryStore {
136
+ const registry: IBodyConverterRegistry = BodyConverterRegistry.create().orThrow();
137
+ registry.register(knowledgeKind, Converters.string);
138
+ return FileTreeMemoryStore.create({
139
+ root: mutableRoot(),
140
+ registry,
141
+ codecs: new Map<Kind, IIdentityCodec>([[knowledgeKind, new KnowledgeIdentityCodec()]]),
142
+ fragmentIndex: params.fragmentIndex,
143
+ fragmentEmbedder: params.fragmentEmbedder,
144
+ logger: params.logger
145
+ }).orThrow();
146
+ }
147
+
148
+ describe('FileTreeMemoryStore fragment-embed-on-write', () => {
149
+ describe('when wired', () => {
150
+ test('embeds fragments on write and indexes them (queryable by span)', async () => {
151
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
152
+ const store = knowledgeStore({ fragmentIndex: index, fragmentEmbedder: fragmentEmbed });
153
+ expect(await store.put(makeRecord('doc-a', 'cat dog'))).toSucceed();
154
+ // Two words → two fragments.
155
+ expect(index.recordCount).toBe(1);
156
+ expect(index.fragmentCount).toBe(2);
157
+ // A query for 'cat' surfaces the 'cat' span [0,3], not the 'dog' span.
158
+ expect(await index.query(featureVector('cat'), 1)).toSucceedAndSatisfy(
159
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
160
+ expect(hits[0].target.id).toBe('doc-a');
161
+ expect(hits[0].locator).toEqual({ start: 0, end: 3 });
162
+ }
163
+ );
164
+ });
165
+
166
+ test('stamps nothing on the record (fragments have no per-record embeddingRef)', async () => {
167
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
168
+ const store = knowledgeStore({ fragmentIndex: index, fragmentEmbedder: fragmentEmbed });
169
+ expect(await store.put(makeRecord('doc-a', 'cat dog'))).toSucceedAndSatisfy(
170
+ (record: IMemoryRecord<unknown>) => {
171
+ expect(record.envelope.embeddingRef).toBeUndefined();
172
+ }
173
+ );
174
+ });
175
+
176
+ test('re-embeds on a content change via whole-record replace', async () => {
177
+ const spy = new SpyFragmentIndex();
178
+ const store = knowledgeStore({ fragmentIndex: spy, fragmentEmbedder: fragmentEmbed });
179
+ (await store.put(makeRecord('doc-a', 'cat'))).orThrow();
180
+ (await store.put(makeRecord('doc-a', 'dog fish'))).orThrow();
181
+ // First write: 1 fragment; second: 2 fragments, replacing the first.
182
+ expect(spy.calls).toEqual(['add:doc-a:1', 'add:doc-a:2']);
183
+ // The 'cat' fragment is gone — the new 'dog'/'fish' spans are orthogonal to a
184
+ // 'cat' query (best score 0), proving the whole-record replace dropped it.
185
+ expect(await spy.query(featureVector('cat'), 5)).toSucceedAndSatisfy(
186
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
187
+ expect(hits.every((h) => h.score === 0)).toBe(true);
188
+ }
189
+ );
190
+ expect(await spy.query(featureVector('fish'), 1)).toSucceedAndSatisfy(
191
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
192
+ expect(hits[0].score).toBeCloseTo(1);
193
+ }
194
+ );
195
+ });
196
+
197
+ test('does not re-embed a dedup no-op (identical content)', async () => {
198
+ const spy = new SpyFragmentIndex();
199
+ const store = knowledgeStore({ fragmentIndex: spy, fragmentEmbedder: fragmentEmbed });
200
+ (await store.put(makeRecord('doc-a', 'cat'))).orThrow();
201
+ (await store.put(makeRecord('doc-a', 'cat'))).orThrow();
202
+ expect(spy.calls).toEqual(['add:doc-a:1']);
203
+ });
204
+
205
+ test('removes the fragments on delete', async () => {
206
+ const spy = new SpyFragmentIndex();
207
+ const store = knowledgeStore({ fragmentIndex: spy, fragmentEmbedder: fragmentEmbed });
208
+ (await store.put(makeRecord('doc-a', 'cat dog'))).orThrow();
209
+ expect(await store.delete(knowledgeKind, 'doc-a' as unknown as EntityId)).toSucceedWith(
210
+ 'doc-a' as MemoryId
211
+ );
212
+ expect(spy.calls).toEqual(['add:doc-a:2', 'remove:doc-a']);
213
+ expect(await spy.query(featureVector('cat'), 5)).toSucceedWith([]);
214
+ });
215
+
216
+ test('removes evicted fragments on cap-cull eviction', async () => {
217
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
218
+ const capCull: IWritePolicy = MemoryCapCullPolicy.create({
219
+ maxRecords: 2,
220
+ mutableFields: ['body', 'tags', 'links', 'provenance', 'embeddingRef']
221
+ }).orThrow();
222
+ const registry: IBodyConverterRegistry = BodyConverterRegistry.create().orThrow();
223
+ registry.register(mtmKind, Converters.string);
224
+ const store = FileTreeMemoryStore.create({
225
+ root: mutableRoot(),
226
+ registry,
227
+ codecs: new Map<Kind, IIdentityCodec>([[mtmKind, new MtmIdentityCodec()]]),
228
+ writePolicies: new Map<Kind, IWritePolicy>([[mtmKind, capCull]]),
229
+ fragmentIndex: index,
230
+ fragmentEmbedder: fragmentEmbed
231
+ }).orThrow();
232
+
233
+ (await store.put(makeRecord('turn-0', 'cat', 'mtm', 'conv-1:0'))).orThrow();
234
+ (await store.put(makeRecord('turn-1', 'dog', 'mtm', 'conv-1:1'))).orThrow();
235
+ (await store.put(makeRecord('turn-2', 'fish', 'mtm', 'conv-1:2'))).orThrow();
236
+ // turn-0 evicted from the store AND its fragments from the index.
237
+ expect(index.recordCount).toBe(2);
238
+ expect(await index.query(featureVector('cat'), 5)).toSucceedAndSatisfy(
239
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
240
+ expect(hits.map((h) => h.target.id)).not.toContain('turn-0');
241
+ }
242
+ );
243
+ });
244
+
245
+ test('persists the record (best-effort) and logs when fragment embedding fails', async () => {
246
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
247
+ const logger = new Logging.InMemoryLogger();
248
+ const failEmbed = (): Promise<Result<ReadonlyArray<IEmbeddedFragment>>> =>
249
+ Promise.resolve(fail('no model'));
250
+ const store = knowledgeStore({ fragmentIndex: index, fragmentEmbedder: failEmbed, logger });
251
+ expect(await store.put(makeRecord('doc-a', 'cat'))).toSucceed();
252
+ expect(await store.getById('knowledge' as MemoryScopeKey, 'doc-a' as MemoryId)).toSucceedAndSatisfy(
253
+ (record: IMemoryRecord<unknown> | undefined) => {
254
+ expect(record?.envelope.id).toBe('doc-a');
255
+ }
256
+ );
257
+ expect(index.recordCount).toBe(0);
258
+ expect(logger.logged.some((m) => /fragment embedding 'doc-a' failed.*no model/i.test(m))).toBe(true);
259
+ });
260
+
261
+ test('persists the record (best-effort) and logs when the fragment add fails', async () => {
262
+ const spy = new SpyFragmentIndex();
263
+ spy.failAdd = true;
264
+ const logger = new Logging.InMemoryLogger();
265
+ const store = knowledgeStore({ fragmentIndex: spy, fragmentEmbedder: fragmentEmbed, logger });
266
+ expect(await store.put(makeRecord('doc-a', 'cat'))).toSucceed();
267
+ expect(await store.getById('knowledge' as MemoryScopeKey, 'doc-a' as MemoryId)).toSucceedAndSatisfy(
268
+ (record: IMemoryRecord<unknown> | undefined) => {
269
+ expect(record?.envelope.id).toBe('doc-a');
270
+ }
271
+ );
272
+ expect(logger.logged.some((m) => /fragment add for 'doc-a' failed.*add boom/i.test(m))).toBe(true);
273
+ });
274
+
275
+ test('best-effort embed survives a throwing/rejecting fragment embedder', async () => {
276
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
277
+ const logger = new Logging.InMemoryLogger();
278
+ const throwingEmbed = (): Promise<Result<ReadonlyArray<IEmbeddedFragment>>> =>
279
+ Promise.reject(new Error('embedder kaboom'));
280
+ const store = knowledgeStore({ fragmentIndex: index, fragmentEmbedder: throwingEmbed, logger });
281
+ expect(await store.put(makeRecord('doc-a', 'cat'))).toSucceed();
282
+ expect(index.recordCount).toBe(0);
283
+ expect(logger.logged.some((m) => /fragment embedding 'doc-a' threw.*embedder kaboom/i.test(m))).toBe(
284
+ true
285
+ );
286
+ });
287
+
288
+ test('a committed delete succeeds (best-effort) and logs when fragment removal fails', async () => {
289
+ const spy = new SpyFragmentIndex();
290
+ const logger = new Logging.InMemoryLogger();
291
+ const store = knowledgeStore({ fragmentIndex: spy, fragmentEmbedder: fragmentEmbed, logger });
292
+ (await store.put(makeRecord('doc-a', 'cat'))).orThrow();
293
+ spy.failRemove = true;
294
+ expect(await store.delete(knowledgeKind, 'doc-a' as unknown as EntityId)).toSucceedWith(
295
+ 'doc-a' as MemoryId
296
+ );
297
+ expect(await store.getById('knowledge' as MemoryScopeKey, 'doc-a' as MemoryId)).toSucceedWith(
298
+ undefined
299
+ );
300
+ expect(logger.logged.some((m) => /fragment removal for 'doc-a' failed.*remove boom/i.test(m))).toBe(
301
+ true
302
+ );
303
+ });
304
+ });
305
+
306
+ describe('when unwired', () => {
307
+ test('a store without a fragment index or embedder does no fragment work', async () => {
308
+ const store = knowledgeStore({});
309
+ expect(await store.put(makeRecord('doc-a', 'cat'))).toSucceedAndSatisfy(
310
+ (record: IMemoryRecord<unknown>) => {
311
+ expect(record.envelope.embeddingRef).toBeUndefined();
312
+ }
313
+ );
314
+ });
315
+
316
+ test('a fragment index without an embedder is fully inert (no add or remove)', async () => {
317
+ const spy = new SpyFragmentIndex();
318
+ const store = knowledgeStore({ fragmentIndex: spy });
319
+ (await store.put(makeRecord('doc-a', 'cat'))).orThrow();
320
+ (await store.delete(knowledgeKind, 'doc-a' as unknown as EntityId)).orThrow();
321
+ expect(spy.calls).toEqual([]);
322
+ });
323
+ });
324
+
325
+ describe('fragment discovery end-to-end', () => {
326
+ test('a wired store + InMemoryFragmentCosineIndex + FragmentSemanticRetriever returns per-span hits', async () => {
327
+ const index = InMemoryFragmentCosineIndex.create().orThrow();
328
+ const store = knowledgeStore({ fragmentIndex: index, fragmentEmbedder: fragmentEmbed });
329
+ (await store.put(makeRecord('doc-a', 'cat cat dog'))).orThrow();
330
+ (await store.put(makeRecord('doc-b', 'fish fish'))).orThrow();
331
+
332
+ const retriever = FragmentSemanticRetriever.create({
333
+ backend: { fragmentIndex: index, embedQuery: queryEmbed }
334
+ }).orThrow();
335
+ expect(retriever.capabilities.supportsFragmentRecall).toBe(true);
336
+
337
+ // maxPerRecord=1 keeps doc-a's two 'cat' spans from crowding out the field.
338
+ expect(await retriever.retrieve({ semantic: 'cat', topK: 5, maxPerRecord: 1 })).toSucceedAndSatisfy(
339
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
340
+ // The top hit is a 'cat' span in doc-a; the doc-a cap is honored.
341
+ expect(hits[0].target.id).toBe('doc-a');
342
+ expect(hits[0].locator).toEqual({ start: 0, end: 3 });
343
+ const perRecord = hits.filter((h) => h.target.id === 'doc-a');
344
+ expect(perRecord).toHaveLength(1);
345
+ }
346
+ );
347
+ });
348
+ });
349
+ });