@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
|
@@ -9,5 +9,6 @@ export * from './linkTraversalRetriever';
|
|
|
9
9
|
export * from './tagRetriever';
|
|
10
10
|
export * from './structuredFilterRetriever';
|
|
11
11
|
export * from './semanticRetriever';
|
|
12
|
+
export * from './fragmentSemanticRetriever';
|
|
12
13
|
export * from './temporalRetrievers';
|
|
13
14
|
export * from './hybridRetriever';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* SPDX-License-Identifier: MIT
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Hash, Logging, Result, fail, mapResults, succeed } from '@fgv/ts-utils';
|
|
6
|
+
import { Hash, Logging, Result, fail, mapResults, mapSuccess, succeed } from '@fgv/ts-utils';
|
|
7
7
|
import { FileTree } from '@fgv/ts-json-base';
|
|
8
8
|
import {
|
|
9
9
|
AdmissionDecision,
|
|
@@ -37,7 +37,15 @@ import {
|
|
|
37
37
|
MemoryObservationOutcome,
|
|
38
38
|
MemoryObservationPhase
|
|
39
39
|
} from '../observe';
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
FragmentEmbedder,
|
|
42
|
+
IEmbeddedFragment,
|
|
43
|
+
IFragmentVectorIndex,
|
|
44
|
+
IMemoryRecordSource,
|
|
45
|
+
IScopedMemoryRecord,
|
|
46
|
+
IVectorIndex,
|
|
47
|
+
MemoryEmbedder
|
|
48
|
+
} from '../vector';
|
|
41
49
|
import { defaultMemoryScopeEncoding } from './scopeEncoding';
|
|
42
50
|
|
|
43
51
|
/** The on-disk extension for a memory record file. */
|
|
@@ -62,6 +70,39 @@ export interface IMemoryStoreListFilter {
|
|
|
62
70
|
readonly asOf?: number;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Policy for how {@link FileTreeMemoryStore.create}'s initial vault walk reacts
|
|
75
|
+
* to a record that fails to parse or validate.
|
|
76
|
+
*
|
|
77
|
+
* - `'fail'` (the default) — one unreadable record fails the whole open. The
|
|
78
|
+
* walk collapses per-record results with `mapResults`, so any single failure
|
|
79
|
+
* aborts `create()`. This is the historical behavior, preserved byte-for-byte.
|
|
80
|
+
* - `'skip'` — an unreadable record is quarantined (not indexed) rather than
|
|
81
|
+
* failing the open. Every record that DOES parse loads normally; each skip is
|
|
82
|
+
* logged at `warn` and surfaced structurally on
|
|
83
|
+
* {@link FileTreeMemoryStore.skippedRecords}. The offending file is never
|
|
84
|
+
* deleted or mutated, so a later open (after the body converter is fixed)
|
|
85
|
+
* re-indexes it. A vault holds every kind in one store, so a required-field
|
|
86
|
+
* migration on one kind must not make every other record unreadable.
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
export type MemoryRecordErrorMode = 'skip' | 'fail';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A record that {@link FileTreeMemoryStore.create} could not load and
|
|
93
|
+
* quarantined (only produced in {@link MemoryRecordErrorMode | `'skip'` mode}).
|
|
94
|
+
* The `path` identifies WHICH record was skipped so a host can repair it.
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export interface ISkippedRecord {
|
|
98
|
+
/** The record file's path within the vault (`<scope>/<filename>.md`). */
|
|
99
|
+
readonly path: string;
|
|
100
|
+
/** The scope the record lives under (its parent directory path). */
|
|
101
|
+
readonly scope: MemoryScopeKey;
|
|
102
|
+
/** The parse/validation failure message (includes the record path). */
|
|
103
|
+
readonly error: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
/**
|
|
66
107
|
* The writable, FileTree-backed, content-hash-deduped memory store.
|
|
67
108
|
* @public
|
|
@@ -196,6 +237,41 @@ export interface IFileTreeMemoryStoreCreateParams {
|
|
|
196
237
|
* `rebuild` reconciles, so a vector failure never fails an authoritative write.
|
|
197
238
|
*/
|
|
198
239
|
readonly embed?: MemoryEmbedder;
|
|
240
|
+
/**
|
|
241
|
+
* Optional fragment-granular vector index for sub-document semantic search.
|
|
242
|
+
* Wired together with
|
|
243
|
+
* {@link IFileTreeMemoryStoreCreateParams.fragmentEmbedder | fragmentEmbedder}:
|
|
244
|
+
* when both are present the store chunks + embeds each written record and
|
|
245
|
+
* maintains the fragment index on `put` / `delete` / cap-cull eviction — the
|
|
246
|
+
* "discovery" half of a search-then-read contract, queried through a
|
|
247
|
+
* {@link FragmentSemanticRetriever}. Independent of the record-granular
|
|
248
|
+
* {@link IFileTreeMemoryStoreCreateParams.vectorIndex | vectorIndex} pair: a
|
|
249
|
+
* store may wire record vectors, fragment vectors, both, or neither. Absent (or
|
|
250
|
+
* `fragmentEmbedder` absent) → no fragment work happens and the store behaves
|
|
251
|
+
* byte-identically (the additive, zero-overhead-when-unwired default).
|
|
252
|
+
*/
|
|
253
|
+
readonly fragmentIndex?: IFragmentVectorIndex;
|
|
254
|
+
/**
|
|
255
|
+
* Optional fragment embedder applied to each record on write, wired together
|
|
256
|
+
* with {@link IFileTreeMemoryStoreCreateParams.fragmentIndex | fragmentIndex}.
|
|
257
|
+
* The consumer owns the chunking policy (window size, overlap) and the embedding
|
|
258
|
+
* call; the store stays chunking- and embedder-agnostic. Fragment index
|
|
259
|
+
* maintenance is **best-effort**, exactly like the record-vector path: a failed
|
|
260
|
+
* (or throwing) `fragmentEmbedder` / `addFragments` / `remove` is logged at
|
|
261
|
+
* `warn` and the record operation still succeeds — the fragment index is a
|
|
262
|
+
* derived view a later `rebuild` reconciles.
|
|
263
|
+
*/
|
|
264
|
+
readonly fragmentEmbedder?: FragmentEmbedder;
|
|
265
|
+
/**
|
|
266
|
+
* How the initial vault walk reacts to a record that fails to parse or
|
|
267
|
+
* validate. Defaults to `'fail'` — one bad record fails the whole open, the
|
|
268
|
+
* historical behavior, preserved byte-for-byte. Set `'skip'` to quarantine
|
|
269
|
+
* unreadable records instead: valid records still load, each skip is logged
|
|
270
|
+
* at `warn` and surfaced on {@link FileTreeMemoryStore.skippedRecords}, and
|
|
271
|
+
* the offending file is left untouched for a later (post-fix) re-index. See
|
|
272
|
+
* {@link MemoryRecordErrorMode}.
|
|
273
|
+
*/
|
|
274
|
+
readonly onRecordError?: MemoryRecordErrorMode;
|
|
199
275
|
}
|
|
200
276
|
|
|
201
277
|
/**
|
|
@@ -227,6 +303,8 @@ interface IInternalParams {
|
|
|
227
303
|
readonly logger: Logging.ILogger;
|
|
228
304
|
readonly vectorIndex?: IVectorIndex;
|
|
229
305
|
readonly embed?: MemoryEmbedder;
|
|
306
|
+
readonly fragmentIndex?: IFragmentVectorIndex;
|
|
307
|
+
readonly fragmentEmbedder?: FragmentEmbedder;
|
|
230
308
|
}
|
|
231
309
|
|
|
232
310
|
/**
|
|
@@ -259,6 +337,13 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
259
337
|
private readonly _logger: Logging.ILogger;
|
|
260
338
|
private readonly _vectorIndex: IVectorIndex | undefined;
|
|
261
339
|
private readonly _embed: MemoryEmbedder | undefined;
|
|
340
|
+
private readonly _fragmentIndex: IFragmentVectorIndex | undefined;
|
|
341
|
+
private readonly _fragmentEmbedder: FragmentEmbedder | undefined;
|
|
342
|
+
/**
|
|
343
|
+
* Records the initial walk could not load. Populated during `create()` in
|
|
344
|
+
* {@link MemoryRecordErrorMode | `'skip'` mode}; empty otherwise.
|
|
345
|
+
*/
|
|
346
|
+
private readonly _skippedRecords: ISkippedRecord[];
|
|
262
347
|
|
|
263
348
|
/** Monotonic write counter; incremented inside the write-lock on each put. */
|
|
264
349
|
private _seq: number;
|
|
@@ -306,11 +391,26 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
306
391
|
this._logger = params.logger;
|
|
307
392
|
this._vectorIndex = params.vectorIndex;
|
|
308
393
|
this._embed = params.embed;
|
|
394
|
+
this._fragmentIndex = params.fragmentIndex;
|
|
395
|
+
this._fragmentEmbedder = params.fragmentEmbedder;
|
|
396
|
+
this._skippedRecords = [];
|
|
309
397
|
this._seq = 0;
|
|
310
398
|
this._observationSeq = 0;
|
|
311
399
|
this._writeTail = Promise.resolve();
|
|
312
400
|
}
|
|
313
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Records the initial vault walk could not parse or validate and quarantined
|
|
404
|
+
* (not indexed). Non-empty only when the store was opened with
|
|
405
|
+
* {@link MemoryRecordErrorMode | `onRecordError: 'skip'`} AND at least one
|
|
406
|
+
* record failed to load. Each entry identifies the offending file so a host
|
|
407
|
+
* can repair it; the file itself is never deleted or mutated, so a later open
|
|
408
|
+
* (after the body converter is fixed) re-indexes it.
|
|
409
|
+
*/
|
|
410
|
+
public get skippedRecords(): ReadonlyArray<ISkippedRecord> {
|
|
411
|
+
return this._skippedRecords;
|
|
412
|
+
}
|
|
413
|
+
|
|
314
414
|
/**
|
|
315
415
|
* Family-convention factory. Builds the derived index and a default LWW
|
|
316
416
|
* policy, then performs an initial FileTree walk so an existing vault is
|
|
@@ -333,9 +433,11 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
333
433
|
observers: params.observers ?? [],
|
|
334
434
|
logger: params.logger ?? new Logging.NoOpLogger(),
|
|
335
435
|
vectorIndex: params.vectorIndex,
|
|
336
|
-
embed: params.embed
|
|
436
|
+
embed: params.embed,
|
|
437
|
+
fragmentIndex: params.fragmentIndex,
|
|
438
|
+
fragmentEmbedder: params.fragmentEmbedder
|
|
337
439
|
});
|
|
338
|
-
return store._initialIndex().onSuccess(() => succeed(store));
|
|
440
|
+
return store._initialIndex(params.onRecordError ?? 'fail').onSuccess(() => succeed(store));
|
|
339
441
|
})
|
|
340
442
|
);
|
|
341
443
|
}
|
|
@@ -729,6 +831,7 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
729
831
|
return this._buildRecord(record, body, existing, policy, hash)
|
|
730
832
|
.onSuccess((built) => succeed(this._stampRank(built)))
|
|
731
833
|
.thenOnSuccess((built) => this._embedOnWrite(built, scope))
|
|
834
|
+
.thenOnSuccess((built) => this._embedFragmentsOnWrite(built, scope))
|
|
732
835
|
.onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, idStem))
|
|
733
836
|
.thenOnSuccess(async (persisted) => {
|
|
734
837
|
// Everything after the authoritative `_persist` commit is best-effort and
|
|
@@ -782,6 +885,56 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
782
885
|
return succeed({ envelope: { ...built.envelope, embeddingRef: added.value }, body: built.body });
|
|
783
886
|
}
|
|
784
887
|
|
|
888
|
+
/**
|
|
889
|
+
* Best-effort fragment-embed-on-write. When a fragment index AND a fragment
|
|
890
|
+
* embedder are wired, chunks + embeds the built record and replaces its
|
|
891
|
+
* fragments in the index (`addFragments` is whole-record-replace, so a re-authored
|
|
892
|
+
* document never leaves stale fragments behind — no explicit remove needed). A
|
|
893
|
+
* failure (returned `fail` OR a thrown/rejected hook) is logged and the record is
|
|
894
|
+
* returned unchanged — the put still persists, and the fragment index is a derived
|
|
895
|
+
* view a later `rebuild` reconciles. Unlike {@link FileTreeMemoryStore._embedOnWrite}
|
|
896
|
+
* it stamps nothing on the record (fragments have no per-record `embeddingRef`
|
|
897
|
+
* analog). A pass-through no-op when unwired (byte-identical record).
|
|
898
|
+
*/
|
|
899
|
+
private async _embedFragmentsOnWrite(
|
|
900
|
+
built: IMemoryRecord<string>,
|
|
901
|
+
scope: MemoryScopeKey
|
|
902
|
+
): Promise<Result<IMemoryRecord<string>>> {
|
|
903
|
+
if (this._fragmentIndex === undefined || this._fragmentEmbedder === undefined) {
|
|
904
|
+
return succeed(built);
|
|
905
|
+
}
|
|
906
|
+
const fragmentIndex: IFragmentVectorIndex = this._fragmentIndex;
|
|
907
|
+
const fragmentEmbedder: FragmentEmbedder = this._fragmentEmbedder;
|
|
908
|
+
const target: IEdgeTarget = { scope, id: built.envelope.id };
|
|
909
|
+
const embedded: Result<ReadonlyArray<IEmbeddedFragment>> = await this._tryVectorOp(
|
|
910
|
+
() => fragmentEmbedder(built),
|
|
911
|
+
`fragment embedding '${built.envelope.id}'`
|
|
912
|
+
);
|
|
913
|
+
if (embedded.isFailure()) {
|
|
914
|
+
return succeed(built);
|
|
915
|
+
}
|
|
916
|
+
await this._tryVectorOp(
|
|
917
|
+
() => fragmentIndex.addFragments(target, embedded.value),
|
|
918
|
+
`fragment add for '${built.envelope.id}'`
|
|
919
|
+
);
|
|
920
|
+
return succeed(built);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Best-effort fragment removal. A no-op unless the full fragment lifecycle is
|
|
925
|
+
* wired (both an index AND an embedder), so an unwired store does no fragment
|
|
926
|
+
* work and behaves byte-identically. Failures are logged, never surfaced — a
|
|
927
|
+
* committed delete/eviction must not fail because a derived fragment index could
|
|
928
|
+
* not be pruned.
|
|
929
|
+
*/
|
|
930
|
+
private async _removeFragmentsBestEffort(target: IEdgeTarget): Promise<void> {
|
|
931
|
+
if (this._fragmentIndex === undefined || this._fragmentEmbedder === undefined) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const fragmentIndex: IFragmentVectorIndex = this._fragmentIndex;
|
|
935
|
+
await this._tryVectorOp(() => fragmentIndex.remove(target), `fragment removal for '${target.id}'`);
|
|
936
|
+
}
|
|
937
|
+
|
|
785
938
|
/**
|
|
786
939
|
* Evict the records named by a `cull-oldest` decision, best-effort. Runs only
|
|
787
940
|
* after the authoritative `_persist`, so a failed eviction is logged (never
|
|
@@ -819,6 +972,7 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
819
972
|
): Promise<void> {
|
|
820
973
|
for (const id of evicted) {
|
|
821
974
|
await this._removeVectorBestEffort({ scope, id });
|
|
975
|
+
await this._removeFragmentsBestEffort({ scope, id });
|
|
822
976
|
}
|
|
823
977
|
}
|
|
824
978
|
|
|
@@ -836,7 +990,7 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
836
990
|
}
|
|
837
991
|
if (result.isFailure()) {
|
|
838
992
|
this._warnSwallowed(
|
|
839
|
-
`memory: ${label} failed (best-effort;
|
|
993
|
+
`memory: ${label} failed (best-effort; derived index left for rebuild): ${result.message}`
|
|
840
994
|
);
|
|
841
995
|
}
|
|
842
996
|
return result;
|
|
@@ -960,6 +1114,7 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
960
1114
|
.onSuccess(() => this._index.patch('delete', { scope, record: existing }))
|
|
961
1115
|
.thenOnSuccess(async () => {
|
|
962
1116
|
await this._removeVectorBestEffort({ scope, id: existing.envelope.id });
|
|
1117
|
+
await this._removeFragmentsBestEffort({ scope, id: existing.envelope.id });
|
|
963
1118
|
return succeed(existing.envelope.id);
|
|
964
1119
|
});
|
|
965
1120
|
});
|
|
@@ -1055,6 +1210,7 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1055
1210
|
this._buildVersionedRecord(record, body, current, policy, hash, versionStem, validAt, now, seq)
|
|
1056
1211
|
.onSuccess((built) => succeed(this._stampRank(built)))
|
|
1057
1212
|
.thenOnSuccess((built) => this._embedOnWrite(built, scope))
|
|
1213
|
+
.thenOnSuccess((built) => this._embedFragmentsOnWrite(built, scope))
|
|
1058
1214
|
.onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, versionStem))
|
|
1059
1215
|
.onSuccess((persisted) =>
|
|
1060
1216
|
this._invalidateCurrents(scope, priorCurrents, validAt, now).onSuccess(() =>
|
|
@@ -1526,9 +1682,13 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1526
1682
|
/**
|
|
1527
1683
|
* Walk the FileTree once and rebuild the index. Also resumes the `seq`
|
|
1528
1684
|
* counter past the highest persisted `seq` so new writes stay monotonic.
|
|
1685
|
+
*
|
|
1686
|
+
* In `'skip'` mode each per-record failure is captured structurally on
|
|
1687
|
+
* `this._skippedRecords` (path + scope + path-tagged error) at its failure
|
|
1688
|
+
* site and logged at `warn`; the walk keeps every record that loaded.
|
|
1529
1689
|
*/
|
|
1530
|
-
private _initialIndex(): Result<true> {
|
|
1531
|
-
return this._collectEntries(this._root, []).onSuccess((entries) =>
|
|
1690
|
+
private _initialIndex(onRecordError: MemoryRecordErrorMode): Result<true> {
|
|
1691
|
+
return this._collectEntries(this._root, [], onRecordError).onSuccess((entries) =>
|
|
1532
1692
|
this._index.rebuild(entries).onSuccess(() => {
|
|
1533
1693
|
for (const entry of entries) {
|
|
1534
1694
|
if (entry.record.envelope.seq > this._seq) {
|
|
@@ -1543,12 +1703,13 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1543
1703
|
/** Recursively collect every `.md` record under `dir` (scope = path segments). */
|
|
1544
1704
|
private _collectEntries(
|
|
1545
1705
|
dir: FileTree.IFileTreeDirectoryItem,
|
|
1546
|
-
scopeSegments: ReadonlyArray<string
|
|
1706
|
+
scopeSegments: ReadonlyArray<string>,
|
|
1707
|
+
onRecordError: MemoryRecordErrorMode
|
|
1547
1708
|
): Result<ReadonlyArray<IIndexedMemoryRecord>> {
|
|
1548
1709
|
return dir.getChildren().onSuccess((children) => {
|
|
1549
1710
|
const results: Result<ReadonlyArray<IIndexedMemoryRecord>>[] = children.map((child) => {
|
|
1550
1711
|
if (child.type === 'directory') {
|
|
1551
|
-
return this._collectEntries(child, [...scopeSegments, child.name]);
|
|
1712
|
+
return this._collectEntries(child, [...scopeSegments, child.name], onRecordError);
|
|
1552
1713
|
}
|
|
1553
1714
|
if (!child.name.endsWith(MEMORY_FILE_EXTENSION) || scopeSegments.length === 0) {
|
|
1554
1715
|
// Skip non-record files and any record-shaped file sitting at the root
|
|
@@ -1556,17 +1717,48 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1556
1717
|
return succeed<ReadonlyArray<IIndexedMemoryRecord>>([]);
|
|
1557
1718
|
}
|
|
1558
1719
|
const scope: MemoryScopeKey = scopeSegments.join('/') as MemoryScopeKey;
|
|
1559
|
-
return child
|
|
1560
|
-
.getRawContents()
|
|
1561
|
-
.onSuccess((raw) => parseMemoryFile(raw, this._registry))
|
|
1562
|
-
.onSuccess((parsedRecord) => this._verifyLoaded(scope, child, parsedRecord))
|
|
1563
|
-
.onSuccess((verified) =>
|
|
1564
|
-
succeed<ReadonlyArray<IIndexedMemoryRecord>>([{ scope, record: verified }])
|
|
1565
|
-
);
|
|
1720
|
+
return this._loadRecordFile(scope, child, onRecordError);
|
|
1566
1721
|
});
|
|
1722
|
+
// `'skip'` mode: keep every record that parsed, drop the ones that failed
|
|
1723
|
+
// in a single pass (each failure is captured on `this._skippedRecords` and
|
|
1724
|
+
// warn-logged at its site in `_loadRecordFile`). `.orDefault([])` covers
|
|
1725
|
+
// the all-invalid-subtree edge where `mapSuccess` returns Failure because
|
|
1726
|
+
// no element succeeded. `'fail'` mode: `mapResults` fails the whole open on
|
|
1727
|
+
// any bad record — byte-identical to the historical load path.
|
|
1728
|
+
if (onRecordError === 'skip') {
|
|
1729
|
+
return succeed<ReadonlyArray<IIndexedMemoryRecord>>(mapSuccess(results).orDefault([]).flat());
|
|
1730
|
+
}
|
|
1567
1731
|
return mapResults(results).onSuccess((perChild) =>
|
|
1568
1732
|
succeed<ReadonlyArray<IIndexedMemoryRecord>>(perChild.flat())
|
|
1569
1733
|
);
|
|
1570
1734
|
});
|
|
1571
1735
|
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Load and verify one record file. On failure in `'skip'` mode, records the
|
|
1739
|
+
* structured {@link ISkippedRecord} identity (path + scope + path-tagged
|
|
1740
|
+
* error) and logs the skip at `warn`; the failure is still returned so the
|
|
1741
|
+
* caller's `mapSuccess` drops it from the loaded set. In `'fail'` mode the
|
|
1742
|
+
* failure passes through untouched so the historical error is byte-identical.
|
|
1743
|
+
*/
|
|
1744
|
+
private _loadRecordFile(
|
|
1745
|
+
scope: MemoryScopeKey,
|
|
1746
|
+
child: FileTree.IFileTreeFileItem,
|
|
1747
|
+
onRecordError: MemoryRecordErrorMode
|
|
1748
|
+
): Result<ReadonlyArray<IIndexedMemoryRecord>> {
|
|
1749
|
+
return child
|
|
1750
|
+
.getRawContents()
|
|
1751
|
+
.onSuccess((raw) => parseMemoryFile(raw, this._registry))
|
|
1752
|
+
.onSuccess((parsedRecord) => this._verifyLoaded(scope, child, parsedRecord))
|
|
1753
|
+
.onSuccess((verified) => succeed<ReadonlyArray<IIndexedMemoryRecord>>([{ scope, record: verified }]))
|
|
1754
|
+
.onFailure((message) => {
|
|
1755
|
+
if (onRecordError === 'skip') {
|
|
1756
|
+
const path: string = `${scope}/${child.name}`;
|
|
1757
|
+
const error: string = `memory record '${path}': ${message}`;
|
|
1758
|
+
this._skippedRecords.push({ path, scope, error });
|
|
1759
|
+
this._warnSwallowed(error);
|
|
1760
|
+
}
|
|
1761
|
+
return fail(message);
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1572
1764
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
7
|
+
import { IEdgeTarget, edgeTargetKey } from '../types';
|
|
8
|
+
import {
|
|
9
|
+
FragmentEmbedder,
|
|
10
|
+
IEmbeddedFragment,
|
|
11
|
+
IFragmentLocator,
|
|
12
|
+
IFragmentVectorIndex,
|
|
13
|
+
IMemoryRecordSource,
|
|
14
|
+
IScopedMemoryRecord,
|
|
15
|
+
IVectorQueryHit
|
|
16
|
+
} from './vectorIndex';
|
|
17
|
+
|
|
18
|
+
/** One stored fragment: its in-record locator plus the vector for that span. */
|
|
19
|
+
interface IStoredFragment {
|
|
20
|
+
readonly locator: IFragmentLocator;
|
|
21
|
+
readonly vector: Float32Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every stored fragment for one record, tagged with the record's scoped address. */
|
|
25
|
+
interface IStoredRecordFragments {
|
|
26
|
+
readonly target: IEdgeTarget;
|
|
27
|
+
readonly fragments: IStoredFragment[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A candidate hit carried through selection: the fragment's key, hit, and score. */
|
|
31
|
+
interface IScoredFragment {
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly hit: IVectorQueryHit;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the
|
|
38
|
+
* fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many
|
|
39
|
+
* `Float32Array`s per record (one per in-record {@link IFragmentLocator | span})
|
|
40
|
+
* and answers a query by computing cosine similarity against every stored
|
|
41
|
+
* fragment, returning the top-k fragment hits by descending score.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Same regime and same non-goals as {@link InMemoryCosineIndex}: no external
|
|
45
|
+
* dependency, no ANN structure, a linear scan over the stored fragments — the seam
|
|
46
|
+
* ({@link IFragmentVectorIndex}) stays open for a consumer to swap a persistent /
|
|
47
|
+
* ANN backend once N grows. `addFragments` is whole-record-replace, so re-authoring
|
|
48
|
+
* a document never leaves stale fragments behind. The index has a single dimension
|
|
49
|
+
* established by the first fragment added; every subsequent fragment and every
|
|
50
|
+
* `query` vector must match it or fail loudly — a mismatched dimension is an
|
|
51
|
+
* embedder-wiring bug, never a silent zero-similarity result.
|
|
52
|
+
* {@link InMemoryFragmentCosineIndex.rebuild | rebuild} clears the index (and the
|
|
53
|
+
* established dimension), so a re-embed with a different model is supported.
|
|
54
|
+
*
|
|
55
|
+
* The optional `maxPerRecord` cap on `query` is applied **during selection**, before
|
|
56
|
+
* the `topK` cut, so one long document with many strong fragments cannot crowd every
|
|
57
|
+
* other record out of the result.
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
|
|
61
|
+
/**
|
|
62
|
+
* Stored fragments keyed by the canonical {@link edgeTargetKey} of the record's
|
|
63
|
+
* scope-qualified address, so two records that share a filename stem across
|
|
64
|
+
* scopes occupy distinct entries and never overwrite each other's fragments.
|
|
65
|
+
*/
|
|
66
|
+
private readonly _records: Map<string, IStoredRecordFragments>;
|
|
67
|
+
/** The dimension of every stored fragment vector; `undefined` until the first `add`. */
|
|
68
|
+
private _dimension: number | undefined;
|
|
69
|
+
|
|
70
|
+
private constructor() {
|
|
71
|
+
this._records = new Map<string, IStoredRecordFragments>();
|
|
72
|
+
this._dimension = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The number of records that currently have at least one stored fragment. */
|
|
76
|
+
public get recordCount(): number {
|
|
77
|
+
return this._records.size;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The total number of fragments currently held across all records. */
|
|
81
|
+
public get fragmentCount(): number {
|
|
82
|
+
let total: number = 0;
|
|
83
|
+
for (const record of this._records.values()) {
|
|
84
|
+
total += record.fragments.length;
|
|
85
|
+
}
|
|
86
|
+
return total;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Family-convention factory. */
|
|
90
|
+
public static create(): Result<InMemoryFragmentCosineIndex> {
|
|
91
|
+
return succeed(new InMemoryFragmentCosineIndex());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** {@inheritDoc IFragmentVectorIndex.addFragments} */
|
|
95
|
+
public addFragments(
|
|
96
|
+
target: IEdgeTarget,
|
|
97
|
+
fragments: ReadonlyArray<IEmbeddedFragment>
|
|
98
|
+
): Promise<Result<number>> {
|
|
99
|
+
const key: string = edgeTargetKey(target);
|
|
100
|
+
// Validate every fragment before mutating any state, so a bad fragment never
|
|
101
|
+
// leaves the record half-replaced OR the index dimension half-established
|
|
102
|
+
// (whole-record-replace must be all-or-nothing). The effective dimension is the
|
|
103
|
+
// established one, or — on a still-dimensionless index — the first fragment's
|
|
104
|
+
// length; it is only committed to `this._dimension` once the whole batch passes.
|
|
105
|
+
const stored: IStoredFragment[] = [];
|
|
106
|
+
let dimension: number | undefined = this._dimension;
|
|
107
|
+
for (const fragment of fragments) {
|
|
108
|
+
if (fragment.vector.length === 0) {
|
|
109
|
+
return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
|
|
110
|
+
}
|
|
111
|
+
if (dimension === undefined) {
|
|
112
|
+
dimension = fragment.vector.length;
|
|
113
|
+
} else if (fragment.vector.length !== dimension) {
|
|
114
|
+
return Promise.resolve(
|
|
115
|
+
fail(
|
|
116
|
+
`fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.
|
|
121
|
+
stored.push({ locator: fragment.locator, vector: Float32Array.from(fragment.vector) });
|
|
122
|
+
}
|
|
123
|
+
// Whole-record replace: an empty `fragments` array drops the record entirely
|
|
124
|
+
// rather than leaving an empty shell behind. Commit the (possibly newly-derived)
|
|
125
|
+
// dimension only alongside a successful, non-empty store.
|
|
126
|
+
if (stored.length === 0) {
|
|
127
|
+
this._records.delete(key);
|
|
128
|
+
} else {
|
|
129
|
+
this._dimension = dimension;
|
|
130
|
+
this._records.set(key, { target, fragments: stored });
|
|
131
|
+
}
|
|
132
|
+
return Promise.resolve(succeed(stored.length));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** {@inheritDoc IFragmentVectorIndex.remove} */
|
|
136
|
+
public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
|
|
137
|
+
this._records.delete(edgeTargetKey(target));
|
|
138
|
+
return Promise.resolve(succeed(target));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** {@inheritDoc IFragmentVectorIndex.query} */
|
|
142
|
+
public query(
|
|
143
|
+
vector: Float32Array,
|
|
144
|
+
topK: number,
|
|
145
|
+
maxPerRecord?: number
|
|
146
|
+
): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
|
|
147
|
+
if (topK <= 0 || this._records.size === 0) {
|
|
148
|
+
return Promise.resolve(succeed([]));
|
|
149
|
+
}
|
|
150
|
+
if (vector.length !== this._dimension) {
|
|
151
|
+
return Promise.resolve(
|
|
152
|
+
fail(
|
|
153
|
+
`fragment index: query dimension ${vector.length} does not match index dimension ${this._dimension}`
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const queryMagnitude: number = InMemoryFragmentCosineIndex._magnitude(vector);
|
|
158
|
+
const scored: IScoredFragment[] = [];
|
|
159
|
+
for (const record of this._records.values()) {
|
|
160
|
+
for (const fragment of record.fragments) {
|
|
161
|
+
scored.push({
|
|
162
|
+
key: edgeTargetKey(record.target),
|
|
163
|
+
hit: {
|
|
164
|
+
target: record.target,
|
|
165
|
+
score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),
|
|
166
|
+
locator: fragment.locator
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Descending by score; the caller re-resolves each `(target, locator)` hit.
|
|
172
|
+
scored.sort((a, b) => b.hit.score - a.hit.score);
|
|
173
|
+
|
|
174
|
+
const hits: IVectorQueryHit[] = [];
|
|
175
|
+
// Apply the per-record cap during selection (before the topK cut) so a single
|
|
176
|
+
// long document cannot monopolize the result. `undefined` maxPerRecord means
|
|
177
|
+
// uncapped; the counter map is always allocated (tiny) so the guard narrows
|
|
178
|
+
// `maxPerRecord` directly without a non-null assertion.
|
|
179
|
+
const perRecord: Map<string, number> = new Map<string, number>();
|
|
180
|
+
for (const candidate of scored) {
|
|
181
|
+
if (hits.length >= topK) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (maxPerRecord !== undefined) {
|
|
185
|
+
const used: number = perRecord.get(candidate.key) ?? 0;
|
|
186
|
+
if (used >= maxPerRecord) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
perRecord.set(candidate.key, used + 1);
|
|
190
|
+
}
|
|
191
|
+
hits.push(candidate.hit);
|
|
192
|
+
}
|
|
193
|
+
return Promise.resolve(succeed(hits));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Re-embed every record from `source` and rebuild the fragment index from
|
|
198
|
+
* scratch. Clears the current contents (and the established dimension) first, so
|
|
199
|
+
* a re-embed with a different model is supported. Returns the total number of
|
|
200
|
+
* fragments indexed.
|
|
201
|
+
*
|
|
202
|
+
* On any failure (list, embed, or add) the index is rolled back to empty rather
|
|
203
|
+
* than left in a partially-rebuilt state.
|
|
204
|
+
*
|
|
205
|
+
* @param source - The scope-qualified record source to re-embed.
|
|
206
|
+
* @param embed - The fragment embedder applied to each record.
|
|
207
|
+
*/
|
|
208
|
+
public async rebuild(source: IMemoryRecordSource, embed: FragmentEmbedder): Promise<Result<number>> {
|
|
209
|
+
this._reset();
|
|
210
|
+
const listed: Result<ReadonlyArray<IScopedMemoryRecord>> = await source.list();
|
|
211
|
+
if (listed.isFailure()) {
|
|
212
|
+
return fail(`fragment index rebuild: failed to list records: ${listed.message}`);
|
|
213
|
+
}
|
|
214
|
+
for (const scoped of listed.value) {
|
|
215
|
+
const embedded: Result<ReadonlyArray<IEmbeddedFragment>> = await embed(scoped.record);
|
|
216
|
+
if (embedded.isFailure()) {
|
|
217
|
+
this._reset();
|
|
218
|
+
return fail(
|
|
219
|
+
`fragment index rebuild: embedding '${edgeTargetKey(scoped.target)}' failed: ${embedded.message}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const added: Result<number> = await this.addFragments(scoped.target, embedded.value);
|
|
223
|
+
if (added.isFailure()) {
|
|
224
|
+
this._reset();
|
|
225
|
+
return fail(`fragment index rebuild: ${added.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return succeed(this.fragmentCount);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Empty the index and forget the established dimension. */
|
|
232
|
+
private _reset(): void {
|
|
233
|
+
this._records.clear();
|
|
234
|
+
this._dimension = undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The Euclidean magnitude (L2 norm) of a vector. */
|
|
238
|
+
private static _magnitude(vector: Float32Array): number {
|
|
239
|
+
let sum: number = 0;
|
|
240
|
+
for (let i: number = 0; i < vector.length; i++) {
|
|
241
|
+
sum += vector[i] * vector[i];
|
|
242
|
+
}
|
|
243
|
+
return Math.sqrt(sum);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Cosine similarity between the query (whose magnitude is precomputed once and
|
|
248
|
+
* reused across the scan) and a stored fragment vector. A zero-magnitude vector
|
|
249
|
+
* on either side yields `0` rather than `NaN`.
|
|
250
|
+
*/
|
|
251
|
+
private static _cosine(query: Float32Array, queryMagnitude: number, stored: Float32Array): number {
|
|
252
|
+
const storedMagnitude: number = InMemoryFragmentCosineIndex._magnitude(stored);
|
|
253
|
+
if (queryMagnitude === 0 || storedMagnitude === 0) {
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
let dot: number = 0;
|
|
257
|
+
for (let i: number = 0; i < query.length; i++) {
|
|
258
|
+
dot += query[i] * stored[i];
|
|
259
|
+
}
|
|
260
|
+
return dot / (queryMagnitude * storedMagnitude);
|
|
261
|
+
}
|
|
262
|
+
}
|