@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.
- package/.rush/temp/{cbbdbe09515171b4eba8f2592be2dace1e4e8142.tar.log → 285f03271c27ef724d49e730c0db58d9e4ac1a44.tar.log} +38 -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/dist/packlets/retrieve/fragmentSemanticRetriever.js +78 -0
- package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -0
- package/dist/packlets/retrieve/index.js +1 -0
- package/dist/packlets/retrieve/index.js.map +1 -1
- package/dist/packlets/store/fileTreeMemoryStore.js +105 -14
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +200 -0
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -0
- package/dist/packlets/vector/index.js +1 -0
- package/dist/packlets/vector/index.js.map +1 -1
- package/dist/packlets/vector/vectorIndex.js.map +1 -1
- package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js +116 -0
- package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +1 -0
- package/dist/test/unit/store/fragmentEmbedOnWrite.test.js +255 -0
- package/dist/test/unit/store/fragmentEmbedOnWrite.test.js.map +1 -0
- package/dist/test/unit/store/lenientOpen.test.js +248 -0
- package/dist/test/unit/store/lenientOpen.test.js.map +1 -0
- package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +297 -0
- package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -0
- package/dist/ts-agent-memory.d.ts +367 -4
- package/etc/ts-agent-memory.api.md +78 -0
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +90 -0
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js +82 -0
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -0
- package/lib/packlets/retrieve/index.d.ts +1 -0
- package/lib/packlets/retrieve/index.d.ts.map +1 -1
- package/lib/packlets/retrieve/index.js +1 -0
- package/lib/packlets/retrieve/index.js.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +115 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +104 -13
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +74 -0
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -0
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +204 -0
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -0
- package/lib/packlets/vector/index.d.ts +1 -0
- package/lib/packlets/vector/index.d.ts.map +1 -1
- package/lib/packlets/vector/index.js +1 -0
- package/lib/packlets/vector/index.js.map +1 -1
- package/lib/packlets/vector/vectorIndex.d.ts +85 -4
- package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
- package/lib/packlets/vector/vectorIndex.js.map +1 -1
- package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts +2 -0
- package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts.map +1 -0
- package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js +118 -0
- package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +1 -0
- package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts +2 -0
- package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts.map +1 -0
- package/lib/test/unit/store/fragmentEmbedOnWrite.test.js +257 -0
- package/lib/test/unit/store/fragmentEmbedOnWrite.test.js.map +1 -0
- package/lib/test/unit/store/lenientOpen.test.d.ts +2 -0
- package/lib/test/unit/store/lenientOpen.test.d.ts.map +1 -0
- package/lib/test/unit/store/lenientOpen.test.js +250 -0
- package/lib/test/unit/store/lenientOpen.test.js.map +1 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts +2 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts.map +1 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +299 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -0
- 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/retrieve/fragmentSemanticRetriever.ts +135 -0
- package/src/packlets/retrieve/index.ts +1 -0
- package/src/packlets/store/fileTreeMemoryStore.ts +208 -16
- package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +262 -0
- package/src/packlets/vector/index.ts +1 -0
- package/src/packlets/vector/vectorIndex.ts +97 -4
- package/src/test/unit/retrieve/fragmentSemanticRetriever.test.ts +163 -0
- package/src/test/unit/store/fragmentEmbedOnWrite.test.ts +349 -0
- package/src/test/unit/store/lenientOpen.test.ts +292 -0
- package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +389 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +29 -5
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +4584 -2859
- 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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
});
|