@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
@@ -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 { IMemoryRecordSource, IScopedMemoryRecord, IVectorIndex, MemoryEmbedder } from '../vector';
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; vector index left for rebuild): ${result.message}`
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
+ }
@@ -5,3 +5,4 @@
5
5
 
6
6
  export * from './vectorIndex';
7
7
  export * from './inMemoryCosineIndex';
8
+ export * from './inMemoryFragmentCosineIndex';