@fgv/ts-agent-memory 5.1.0-48 → 5.1.0-50

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 (164) hide show
  1. package/dist/packlets/index/memoryIndex.js +23 -16
  2. package/dist/packlets/index/memoryIndex.js.map +1 -1
  3. package/dist/packlets/ingest/orchestrator.js +13 -1
  4. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  5. package/dist/packlets/retrieve/linkTraversalRetriever.js +13 -27
  6. package/dist/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  7. package/dist/packlets/retrieve/recencyRetriever.js +8 -8
  8. package/dist/packlets/retrieve/recencyRetriever.js.map +1 -1
  9. package/dist/packlets/retrieve/retriever.js +98 -11
  10. package/dist/packlets/retrieve/retriever.js.map +1 -1
  11. package/dist/packlets/retrieve/semanticRetriever.js +16 -16
  12. package/dist/packlets/retrieve/semanticRetriever.js.map +1 -1
  13. package/dist/packlets/retrieve/structuredFilterRetriever.js +34 -12
  14. package/dist/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  15. package/dist/packlets/retrieve/tagRetriever.js +8 -8
  16. package/dist/packlets/retrieve/tagRetriever.js.map +1 -1
  17. package/dist/packlets/retrieve/temporalRetrievers.js +24 -21
  18. package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -1
  19. package/dist/packlets/store/coverage.js +6 -0
  20. package/dist/packlets/store/coverage.js.map +1 -0
  21. package/dist/packlets/store/fileTreeMemoryStore.js +303 -62
  22. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  23. package/dist/packlets/store/index.js +4 -0
  24. package/dist/packlets/store/index.js.map +1 -1
  25. package/dist/packlets/store/listSelection.js +36 -0
  26. package/dist/packlets/store/listSelection.js.map +1 -0
  27. package/dist/packlets/store/memoryStore.js +6 -0
  28. package/dist/packlets/store/memoryStore.js.map +1 -0
  29. package/dist/packlets/store/reconcile.js +6 -0
  30. package/dist/packlets/store/reconcile.js.map +1 -0
  31. package/dist/packlets/store/storeCoverage.js +102 -0
  32. package/dist/packlets/store/storeCoverage.js.map +1 -0
  33. package/dist/packlets/store/storeReconcile.js +122 -0
  34. package/dist/packlets/store/storeReconcile.js.map +1 -0
  35. package/dist/packlets/store/vectorMaintenance.js +116 -8
  36. package/dist/packlets/store/vectorMaintenance.js.map +1 -1
  37. package/dist/packlets/store/vectorRecordSource.js +44 -0
  38. package/dist/packlets/store/vectorRecordSource.js.map +1 -0
  39. package/dist/packlets/tools/memoryTools.js +25 -2
  40. package/dist/packlets/tools/memoryTools.js.map +1 -1
  41. package/dist/packlets/types/envelope.js +25 -0
  42. package/dist/packlets/types/envelope.js.map +1 -1
  43. package/dist/packlets/types/index.js +1 -0
  44. package/dist/packlets/types/index.js.map +1 -1
  45. package/dist/packlets/types/recordResolver.js +6 -0
  46. package/dist/packlets/types/recordResolver.js.map +1 -0
  47. package/dist/packlets/types/temporal.js.map +1 -1
  48. package/dist/packlets/vector/inMemoryCosineIndex.js +39 -18
  49. package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  50. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +67 -12
  51. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  52. package/dist/packlets/vector/rebuildHelpers.js +38 -0
  53. package/dist/packlets/vector/rebuildHelpers.js.map +1 -0
  54. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  55. package/dist/ts-agent-memory.d.ts +1140 -90
  56. package/lib/packlets/index/memoryIndex.d.ts +118 -27
  57. package/lib/packlets/index/memoryIndex.d.ts.map +1 -1
  58. package/lib/packlets/index/memoryIndex.js +23 -16
  59. package/lib/packlets/index/memoryIndex.js.map +1 -1
  60. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  61. package/lib/packlets/ingest/orchestrator.js +13 -1
  62. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  63. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts +4 -11
  64. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts.map +1 -1
  65. package/lib/packlets/retrieve/linkTraversalRetriever.js +12 -26
  66. package/lib/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  67. package/lib/packlets/retrieve/recencyRetriever.d.ts +4 -4
  68. package/lib/packlets/retrieve/recencyRetriever.d.ts.map +1 -1
  69. package/lib/packlets/retrieve/recencyRetriever.js +7 -7
  70. package/lib/packlets/retrieve/recencyRetriever.js.map +1 -1
  71. package/lib/packlets/retrieve/retriever.d.ts +113 -13
  72. package/lib/packlets/retrieve/retriever.d.ts.map +1 -1
  73. package/lib/packlets/retrieve/retriever.js +101 -10
  74. package/lib/packlets/retrieve/retriever.js.map +1 -1
  75. package/lib/packlets/retrieve/semanticRetriever.d.ts +3 -5
  76. package/lib/packlets/retrieve/semanticRetriever.d.ts.map +1 -1
  77. package/lib/packlets/retrieve/semanticRetriever.js +15 -15
  78. package/lib/packlets/retrieve/semanticRetriever.js.map +1 -1
  79. package/lib/packlets/retrieve/structuredFilterRetriever.d.ts +29 -7
  80. package/lib/packlets/retrieve/structuredFilterRetriever.d.ts.map +1 -1
  81. package/lib/packlets/retrieve/structuredFilterRetriever.js +33 -11
  82. package/lib/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  83. package/lib/packlets/retrieve/tagRetriever.d.ts +4 -4
  84. package/lib/packlets/retrieve/tagRetriever.d.ts.map +1 -1
  85. package/lib/packlets/retrieve/tagRetriever.js +7 -7
  86. package/lib/packlets/retrieve/tagRetriever.js.map +1 -1
  87. package/lib/packlets/retrieve/temporalRetrievers.d.ts +7 -5
  88. package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -1
  89. package/lib/packlets/retrieve/temporalRetrievers.js +23 -20
  90. package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -1
  91. package/lib/packlets/store/coverage.d.ts +102 -0
  92. package/lib/packlets/store/coverage.d.ts.map +1 -0
  93. package/lib/packlets/store/coverage.js +7 -0
  94. package/lib/packlets/store/coverage.js.map +1 -0
  95. package/lib/packlets/store/fileTreeMemoryStore.d.ts +88 -132
  96. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  97. package/lib/packlets/store/fileTreeMemoryStore.js +302 -61
  98. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  99. package/lib/packlets/store/index.d.ts +4 -0
  100. package/lib/packlets/store/index.d.ts.map +1 -1
  101. package/lib/packlets/store/index.js +4 -0
  102. package/lib/packlets/store/index.js.map +1 -1
  103. package/lib/packlets/store/listSelection.d.ts +101 -0
  104. package/lib/packlets/store/listSelection.d.ts.map +1 -0
  105. package/lib/packlets/store/listSelection.js +40 -0
  106. package/lib/packlets/store/listSelection.js.map +1 -0
  107. package/lib/packlets/store/memoryStore.d.ts +237 -0
  108. package/lib/packlets/store/memoryStore.d.ts.map +1 -0
  109. package/lib/packlets/store/memoryStore.js +7 -0
  110. package/lib/packlets/store/memoryStore.js.map +1 -0
  111. package/lib/packlets/store/reconcile.d.ts +82 -0
  112. package/lib/packlets/store/reconcile.d.ts.map +1 -0
  113. package/lib/packlets/store/reconcile.js +7 -0
  114. package/lib/packlets/store/reconcile.js.map +1 -0
  115. package/lib/packlets/store/storeCoverage.d.ts +45 -0
  116. package/lib/packlets/store/storeCoverage.d.ts.map +1 -0
  117. package/lib/packlets/store/storeCoverage.js +105 -0
  118. package/lib/packlets/store/storeCoverage.js.map +1 -0
  119. package/lib/packlets/store/storeReconcile.d.ts +41 -0
  120. package/lib/packlets/store/storeReconcile.d.ts.map +1 -0
  121. package/lib/packlets/store/storeReconcile.js +125 -0
  122. package/lib/packlets/store/storeReconcile.js.map +1 -0
  123. package/lib/packlets/store/vectorMaintenance.d.ts +74 -0
  124. package/lib/packlets/store/vectorMaintenance.d.ts.map +1 -1
  125. package/lib/packlets/store/vectorMaintenance.js +117 -8
  126. package/lib/packlets/store/vectorMaintenance.js.map +1 -1
  127. package/lib/packlets/store/vectorRecordSource.d.ts +36 -0
  128. package/lib/packlets/store/vectorRecordSource.d.ts.map +1 -0
  129. package/lib/packlets/store/vectorRecordSource.js +47 -0
  130. package/lib/packlets/store/vectorRecordSource.js.map +1 -0
  131. package/lib/packlets/tools/memoryTools.d.ts.map +1 -1
  132. package/lib/packlets/tools/memoryTools.js +25 -2
  133. package/lib/packlets/tools/memoryTools.js.map +1 -1
  134. package/lib/packlets/types/envelope.d.ts +33 -0
  135. package/lib/packlets/types/envelope.d.ts.map +1 -1
  136. package/lib/packlets/types/envelope.js +26 -0
  137. package/lib/packlets/types/envelope.js.map +1 -1
  138. package/lib/packlets/types/index.d.ts +1 -0
  139. package/lib/packlets/types/index.d.ts.map +1 -1
  140. package/lib/packlets/types/index.js +1 -0
  141. package/lib/packlets/types/index.js.map +1 -1
  142. package/lib/packlets/types/recordResolver.d.ts +39 -0
  143. package/lib/packlets/types/recordResolver.d.ts.map +1 -0
  144. package/lib/packlets/types/recordResolver.js +7 -0
  145. package/lib/packlets/types/recordResolver.js.map +1 -0
  146. package/lib/packlets/types/temporal.d.ts +26 -6
  147. package/lib/packlets/types/temporal.d.ts.map +1 -1
  148. package/lib/packlets/types/temporal.js.map +1 -1
  149. package/lib/packlets/vector/inMemoryCosineIndex.d.ts +9 -2
  150. package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -1
  151. package/lib/packlets/vector/inMemoryCosineIndex.js +40 -19
  152. package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  153. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +6 -3
  154. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  155. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +66 -11
  156. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  157. package/lib/packlets/vector/rebuildHelpers.d.ts +30 -0
  158. package/lib/packlets/vector/rebuildHelpers.d.ts.map +1 -0
  159. package/lib/packlets/vector/rebuildHelpers.js +42 -0
  160. package/lib/packlets/vector/rebuildHelpers.js.map +1 -0
  161. package/lib/packlets/vector/vectorIndex.d.ts +270 -15
  162. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  163. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  164. package/package.json +7 -7
@@ -5,10 +5,14 @@
5
5
  import { Hash, Logging, fail, mapResults, mapSuccess, succeed } from '@fgv/ts-utils';
6
6
  import { FileTree } from '@fgv/ts-json-base';
7
7
  import { DEFAULT_DEDUP_SCOPE, KnowledgeLwwPolicy, isTemporalIdentityCodec, isTemporalRecord, isVersionCurrent, selectCurrentVersion, selectVersionAsOf } from '../types';
8
- import { parseMemoryFile, serializeMemoryFile } from '../converters';
8
+ import { parseMemoryFile, serializeMemoryFile, splitFrontmatter } from '../converters';
9
9
  import { VectorMaintenance } from './vectorMaintenance';
10
+ import { computeCoverage } from './storeCoverage';
11
+ import { reconcileVectors } from './storeReconcile';
10
12
  import { MemoryIndex } from '../index';
11
13
  import { defaultMemoryScopeEncoding } from './scopeEncoding';
14
+ import { isWholeVaultScan } from './listSelection';
15
+ import { vectorRecordSource } from './vectorRecordSource';
12
16
  /** The on-disk extension for a memory record file. */
13
17
  const MEMORY_FILE_EXTENSION = '.md';
14
18
  /**
@@ -124,7 +128,7 @@ export class FileTreeMemoryStore {
124
128
  // version whose `invalid_at` is null/absent) from the entity subtree,
125
129
  // read off the derived index. `asOf` resolution is via `list({ asOf })`
126
130
  // and the temporal retrievers.
127
- return succeed(this._readVersionedCurrent(addr.scope));
131
+ return this._readVersionedCurrent(addr.scope);
128
132
  }
129
133
  return this._readRecord(addr.scope, addr.idStem);
130
134
  }));
@@ -140,50 +144,130 @@ export class FileTreeMemoryStore {
140
144
  return this._readRecord(scope, id);
141
145
  }
142
146
  /** {@inheritDoc IMemoryStore.list} */
143
- async list(filter) {
144
- const matches = this._index
145
- .entries()
146
- .filter((entry) => {
147
- if ((filter === null || filter === void 0 ? void 0 : filter.scope) !== undefined && entry.scope !== filter.scope) {
147
+ async list(selection) {
148
+ // Deliberately NOT annotated `: boolean`. An explicit annotation discards the
149
+ // type predicate, and TypeScript's aliased-condition narrowing is what lets
150
+ // the ternary below see `selection` as a filter on the false branch. With the
151
+ // annotation this line compiles and the next one does not.
152
+ const scan = isWholeVaultScan(selection);
153
+ const filter = scan ? {} : selection;
154
+ if (!scan && filter.scope === undefined && filter.kind === undefined && filter.tag === undefined) {
155
+ // `asOf` alone does not narrow — it collapses versions, it does not exclude
156
+ // entities — so a selection carrying only `asOf` lands here too.
157
+ return fail('memory list: a selection must narrow by scope, kind or tag; ' +
158
+ 'pass scanEveryRecord() to read every record in the vault deliberately');
159
+ }
160
+ // Select over envelopes, project temporally, and materialize LAST — so a
161
+ // narrowed list reads only the files it is going to return.
162
+ const selected = this._index.entries().filter((entry) => {
163
+ if (filter.scope !== undefined && entry.scope !== filter.scope) {
148
164
  return false;
149
165
  }
150
- if ((filter === null || filter === void 0 ? void 0 : filter.kind) !== undefined && entry.record.envelope.kind !== filter.kind) {
166
+ if (filter.kind !== undefined && entry.envelope.kind !== filter.kind) {
151
167
  return false;
152
168
  }
153
- if ((filter === null || filter === void 0 ? void 0 : filter.tag) !== undefined && !entry.record.envelope.tags.includes(filter.tag)) {
169
+ if (filter.tag !== undefined && !entry.envelope.tags.includes(filter.tag)) {
154
170
  return false;
155
171
  }
156
172
  return true;
157
- })
158
- .map((entry) => entry.record);
159
- if ((filter === null || filter === void 0 ? void 0 : filter.asOf) === undefined) {
160
- // No temporal projection requested: byte-identical to the pre-temporal
161
- // behavior (the flat-path guarantee — every version is returned).
162
- return succeed(matches);
163
- }
164
- return succeed(FileTreeMemoryStore._projectAsOf(matches, filter.asOf));
173
+ });
174
+ const projected = selection.asOf === undefined
175
+ ? // No temporal projection requested: the flat-path guarantee every
176
+ // version is returned.
177
+ selected
178
+ : FileTreeMemoryStore._projectAsOf(selected, selection.asOf);
179
+ return this._materialize(projected);
180
+ }
181
+ /** {@inheritDoc IMemoryStore.coverage} */
182
+ coverage() {
183
+ return Promise.resolve(computeCoverage({
184
+ entries: this._index.entries(),
185
+ hasRankProjector: (kind) => this._rankProjectors.has(kind),
186
+ anyRankProjector: this._rankProjectors.size > 0,
187
+ embedsKind: (kind) => this.embedsKind(kind),
188
+ vectorIndex: this._vectors.vectorIndex,
189
+ fragmentIndex: this._vectors.fragmentIndex
190
+ }));
191
+ }
192
+ /** {@inheritDoc IMemoryStore.listEntries} */
193
+ async listEntries() {
194
+ return succeed(this._index.entries());
165
195
  }
166
196
  /** {@inheritDoc IMemoryStore.listScoped} */
167
197
  async listScoped() {
168
- // The derived index already carries each record's scope
169
- // ({@link IIndexedMemoryRecord.scope}), so the scoped projection is a direct
170
- // map the record's `(scope, id)` is exactly the address the vector index
171
- // keys on. No filter/temporal projection: the seam re-embeds the whole vault.
172
- return succeed(this._index.entries().map((entry) => ({
173
- target: { scope: entry.scope, id: entry.record.envelope.id },
174
- record: entry.record
175
- })));
198
+ // The derived index already carries each record's scope, so the scoped
199
+ // projection keys straight off it — `(scope, id)` is exactly the address the
200
+ // vector index uses. Bodies ARE materialized here: this seam feeds an
201
+ // embedder, which is the one consumer that genuinely needs every body. No
202
+ // filter/temporal projection — it re-embeds the whole vault by contract.
203
+ //
204
+ // **Complete or failed — never quietly short.** This call is the sole feed
205
+ // for `asRecordSource()`, and therefore for `IVectorIndex.rebuild`'s coverage
206
+ // report. A record dropped here would land in none of `records` / `excluded`
207
+ // / `indexed` / `declined` / `skipped`, so a caller computing coverage would
208
+ // undercount *in the direction of looking healthier* — which is precisely the
209
+ // failure `vectorRecordSource`'s own tally exists to prevent, reintroduced one
210
+ // layer down. A loud failure is recoverable; a silent undercount is not.
211
+ //
212
+ // Two consequences worth stating rather than discovering. Before the index
213
+ // was projected this method read nothing and could not fail, because the
214
+ // index held the records themselves; it now costs a full-vault read and is
215
+ // fallible. And it is NOT write-locked, so a concurrent `put` whose cap-cull
216
+ // physically evicts a record between the snapshot and the read will fail it.
217
+ // That window is real and is tracked in `docs/FUTURE.md` — the answer is to
218
+ // make the loss *countable*, not to swallow it.
219
+ return mapResults(this._index.entries().map((entry) => {
220
+ const target = { scope: entry.scope, id: entry.envelope.id };
221
+ return this._resolveRequired(entry).onSuccess((record) => succeed({ target, record }));
222
+ }));
223
+ }
224
+ /** {@inheritDoc IMemoryRecordResolver.resolveRecord} */
225
+ resolveRecord(scope, id) {
226
+ return this._readRecord(scope, id);
227
+ }
228
+ /**
229
+ * Materialize a selected set of entries into records, dropping any that have
230
+ * vanished since selection.
231
+ *
232
+ * @remarks
233
+ * A miss is not a failure. Selection reads the in-memory index and
234
+ * materialization reads storage, so a record deleted in between is a legitimate
235
+ * race and yields a shorter list rather than an error. A read that FAILS is a
236
+ * real fault and propagates.
237
+ */
238
+ _materialize(entries) {
239
+ return mapResults(entries.map((entry) => this._readRecord(entry.scope, entry.envelope.id))).onSuccess((records) => succeed(records.filter((r) => r !== undefined)));
240
+ }
241
+ /**
242
+ * Materialize one entry, treating "gone" as a fault rather than a miss.
243
+ *
244
+ * @remarks
245
+ * For paths where a vanished record really does mean the index and the vault
246
+ * disagree, rather than that something legitimately removed it in between.
247
+ *
248
+ * Three of the four callers hold the write lock, so nothing can have removed
249
+ * the record since the entry was read. `get()`'s versioned path does not, and
250
+ * is safe only because temporal kinds never physically delete a version — they
251
+ * invalidate in place, and cap-cull does not apply to them. **If eviction is
252
+ * ever added to the temporal path, that caller must change**, or it
253
+ * reintroduces the race this method exists to detect.
254
+ *
255
+ * `listScoped` also does not hold the lock, and uses this deliberately anyway:
256
+ * it feeds a coverage report, so a silent drop there is worse than a loud
257
+ * failure. See its comment, and `docs/FUTURE.md` for the eviction window.
258
+ *
259
+ * The drop-tolerant counterpart is {@link FileTreeMemoryStore._materialize},
260
+ * for readers where a record that vanished between selection and
261
+ * materialization is a miss rather than a fault.
262
+ */
263
+ _resolveRequired(entry) {
264
+ return this._readRecord(entry.scope, entry.envelope.id).onSuccess((record) => record === undefined
265
+ ? fail(`memory: index entry '${entry.scope}/${entry.envelope.id}' has no record in the vault`)
266
+ : succeed(record));
176
267
  }
177
268
  /** {@inheritDoc IMemoryStore.asRecordSource} */
178
269
  asRecordSource() {
179
- return {
180
- // Filtered to the kinds that participate in the record vector index. This
181
- // source exists to drive `IVectorIndex` rebuilds, so a kind excluded from the
182
- // index has no business being re-embedded on open — which is where the cost
183
- // is worst, since a rebuild embeds the whole vault serially. With no
184
- // `embedKinds` declaration every kind passes and this is the identity filter.
185
- list: async () => (await this.listScoped()).onSuccess((scoped) => succeed(scoped.filter((s) => this.embedsKind(s.record.envelope.kind))))
186
- };
270
+ return vectorRecordSource(this);
187
271
  }
188
272
  /**
189
273
  * Collapse temporal records to the single version valid at `asOf` per entity;
@@ -192,21 +276,21 @@ export class FileTreeMemoryStore {
192
276
  * filtering is deferred — OQ-9). An entity with no version valid at `asOf`
193
277
  * contributes nothing.
194
278
  */
195
- static _projectAsOf(records, asOf) {
279
+ static _projectAsOf(entries, asOf) {
196
280
  const passthrough = [];
197
281
  const groups = new Map();
198
- for (const record of records) {
199
- if (!isTemporalRecord(record)) {
200
- passthrough.push(record);
282
+ for (const entry of entries) {
283
+ if (!isTemporalRecord(entry)) {
284
+ passthrough.push(entry);
201
285
  continue;
202
286
  }
203
- const key = `${record.envelope.kind}\0${record.envelope.entityId}`;
287
+ const key = `${entry.envelope.kind}\0${entry.envelope.entityId}`;
204
288
  const existing = groups.get(key);
205
289
  if (existing === undefined) {
206
- groups.set(key, [record]);
290
+ groups.set(key, [entry]);
207
291
  }
208
292
  else {
209
- existing.push(record);
293
+ existing.push(entry);
210
294
  }
211
295
  }
212
296
  const result = [...passthrough];
@@ -218,6 +302,10 @@ export class FileTreeMemoryStore {
218
302
  }
219
303
  return result;
220
304
  }
305
+ /** {@inheritDoc IMemoryStore.reconcile} */
306
+ async reconcile(kind, artifact) {
307
+ return this._enqueue(() => this._reconcileLocked(kind, artifact));
308
+ }
221
309
  /** {@inheritDoc IMemoryStore.put} */
222
310
  async put(record) {
223
311
  const result = await this._enqueue(() => this._putLocked(record));
@@ -387,8 +475,11 @@ export class FileTreeMemoryStore {
387
475
  // mutableFields) actually applies. Content-dedup must never shadow LWW for
388
476
  // the same entity.
389
477
  const duplicate = this._findByContentHash(scope, hash, record.envelope.id);
390
- if (duplicate !== undefined) {
391
- return succeed({ record: duplicate, evicted: [] });
478
+ if (duplicate.isFailure()) {
479
+ return fail(duplicate.message);
480
+ }
481
+ if (duplicate.value !== undefined) {
482
+ return succeed({ record: duplicate.value, evicted: [] });
392
483
  }
393
484
  }
394
485
  return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
@@ -409,9 +500,8 @@ export class FileTreeMemoryStore {
409
500
  // (a replace, not a grow). The same-id `existing` record is threaded
410
501
  // separately into `_buildRecord` for the merge-patch. Knowledge LWW ignores
411
502
  // this argument, so its behavior is unchanged by the wider cohort.
412
- const cohort = this._admissionCohort(scope, record.envelope.kind, idStem);
413
- return policy
414
- .admit(record, cohort)
503
+ return this._admissionCohort(scope, record.envelope.kind, idStem)
504
+ .onSuccess((cohort) => policy.admit(record, cohort))
415
505
  .thenOnSuccess((decision) => this._admitWrite(record, body, scope, idStem, hash, policy, existing, decision));
416
506
  });
417
507
  }
@@ -561,7 +651,16 @@ export class FileTreeMemoryStore {
561
651
  * written, or fully invalidated / soft-deleted).
562
652
  */
563
653
  _readVersionedCurrent(scope) {
564
- return selectCurrentVersion(this._versionsForEntity(scope));
654
+ // Select over ENVELOPES, then materialize the one winner — which is what
655
+ // `IMemoryStore.get`'s docstring promises and what `selectCurrentVersion`
656
+ // being generic over `IEnvelopeCarrier` exists for. Materializing every
657
+ // version first (as this did) cost N file reads and N body validations to
658
+ // return one record, and made that docstring false.
659
+ const current = selectCurrentVersion(this._index.entries().filter((entry) => entry.scope === scope));
660
+ if (current === undefined) {
661
+ return succeed(undefined);
662
+ }
663
+ return this._resolveRequired(current);
565
664
  }
566
665
  /**
567
666
  * Every persisted version of the entity whose subtree is `scope`. All version
@@ -569,10 +668,13 @@ export class FileTreeMemoryStore {
569
668
  * entityId), so a scope filter over the index isolates one entity's versions.
570
669
  */
571
670
  _versionsForEntity(scope) {
572
- return this._index
671
+ // Selected on the envelope (scope), materialized after — so a versioned write
672
+ // reads only that entity's versions, never the vault. Bounded by the entity's
673
+ // version count.
674
+ return mapResults(this._index
573
675
  .entries()
574
676
  .filter((entry) => entry.scope === scope)
575
- .map((entry) => entry.record);
677
+ .map((entry) => this._resolveRequired(entry)));
576
678
  }
577
679
  /**
578
680
  * Versioned write (invalidate-don't-delete). Builds the new version's content
@@ -594,7 +696,11 @@ export class FileTreeMemoryStore {
594
696
  // still-current version at snapshot time — normally one, but two-or-more if a
595
697
  // prior invalidation partially failed; invalidating all of them lets the write
596
698
  // self-heal a stuck state (P2-7).
597
- const versions = this._versionsForEntity(scope);
699
+ const snapshot = this._versionsForEntity(scope);
700
+ if (snapshot.isFailure()) {
701
+ return fail(snapshot.message);
702
+ }
703
+ const versions = snapshot.value;
598
704
  const priorCurrents = versions.filter(isVersionCurrent);
599
705
  const current = selectCurrentVersion(versions);
600
706
  const policy = this._policyFor(kind);
@@ -720,7 +826,11 @@ export class FileTreeMemoryStore {
720
826
  * builds on it), so a hard delete would defeat the purpose.
721
827
  */
722
828
  async _deleteVersioned(entityId, scope) {
723
- const versions = this._versionsForEntity(scope);
829
+ const snapshot = this._versionsForEntity(scope);
830
+ if (snapshot.isFailure()) {
831
+ return fail(snapshot.message);
832
+ }
833
+ const versions = snapshot.value;
724
834
  const currents = versions.filter(isVersionCurrent);
725
835
  const current = selectCurrentVersion(versions);
726
836
  if (current === undefined) {
@@ -767,10 +877,12 @@ export class FileTreeMemoryStore {
767
877
  * across first-writes and updates.
768
878
  */
769
879
  _admissionCohort(scope, kind, idStem) {
770
- return this._index
880
+ // Envelope-only selection; the cohort is per-(scope, kind) and bounded by the
881
+ // cull cap, so materializing it is small by construction.
882
+ return mapResults(this._index
771
883
  .entries()
772
- .filter((entry) => entry.scope === scope && entry.record.envelope.kind === kind && entry.record.envelope.id !== idStem)
773
- .map((entry) => entry.record);
884
+ .filter((entry) => entry.scope === scope && entry.envelope.kind === kind && entry.envelope.id !== idStem)
885
+ .map((entry) => this._resolveRequired(entry)));
774
886
  }
775
887
  /**
776
888
  * Find a record in `scope` whose `contentHash` equals `hash`, if any,
@@ -778,12 +890,13 @@ export class FileTreeMemoryStore {
778
890
  * dedup skip the same-id record so it does not shadow the LWW update path.
779
891
  */
780
892
  _findByContentHash(scope, hash, excludeId) {
893
+ // The clearest win of the projection: the hash lives on the envelope, so this
894
+ // scans envelopes and reads exactly ONE file — the match — where it used to
895
+ // hold every body in the scope to look at one field.
781
896
  const match = this._index
782
897
  .entries()
783
- .find((entry) => entry.scope === scope &&
784
- entry.record.envelope.contentHash === hash &&
785
- entry.record.envelope.id !== excludeId);
786
- return match === null || match === void 0 ? void 0 : match.record;
898
+ .find((entry) => entry.scope === scope && entry.envelope.contentHash === hash && entry.envelope.id !== excludeId);
899
+ return match === undefined ? succeed(undefined) : this._resolveRequired(match);
787
900
  }
788
901
  /**
789
902
  * True when `incoming`'s caller-authored mutable metadata (`tags` / `provenance`)
@@ -808,6 +921,128 @@ export class FileTreeMemoryStore {
808
921
  _contentHash(kind, body, links) {
809
922
  return this._hasher.computeHash({ kind, body, links });
810
923
  }
924
+ /**
925
+ * The rank branch of {@link FileTreeMemoryStore.reconcile}, under the write lock.
926
+ *
927
+ * @remarks
928
+ * Re-reads each record's file rather than trusting the in-memory index, for
929
+ * two reasons: the index holds converted bodies on some paths and raw ones on
930
+ * others, and re-serializing a *converted* body could change the bytes on disk
931
+ * — a reconcile of an ordering field has no business rewriting content.
932
+ * `splitFrontmatter` hands back the body text unconverted, so the round trip
933
+ * carries the authored characters through untouched and only the envelope
934
+ * moves. The parsed record is additionally put through `_verifyLoaded`, the
935
+ * same id-vs-filename and scope-derived-entityId check the load paths apply —
936
+ * without it, reconcile would be the one path that accepts and rewrites a file
937
+ * the store would refuse to load.
938
+ *
939
+ * "Untouched" is not quite "byte-identical", and the exception is line
940
+ * endings: `splitFrontmatter` strips a trailing `\r` per line and
941
+ * `joinFrontmatter` writes `\n` delimiters, so a CRLF-authored file comes back
942
+ * LF-normalized. That is **the store's behavior on every write path, not
943
+ * something reconcile introduces** — an ordinary `put` normalizes the same way
944
+ * — so reconcile does not rewrite content that a subsequent write would have
945
+ * left alone. The property being claimed here is the narrower and load-bearing
946
+ * one: no body is round-tripped through its registered Converter.
947
+ *
948
+ * The projector is fed an `IMemoryRecord<string>` carrying that raw body,
949
+ * which is exactly the shape {@link FileTreeMemoryStore._stampRank} hands it
950
+ * on the write path — so a projector cannot see one thing on a write and
951
+ * another on a reconcile. `_stampRank` itself is reused verbatim, which also
952
+ * inherits its throw semantics (logged at `warn`, `rank` cleared).
953
+ */
954
+ async _reconcileLocked(kind, artifact) {
955
+ // Envelope-only selection: the walk needs `(scope, id)` and the kind, and
956
+ // each branch materializes only the records it decides to repair.
957
+ const targets = this._index
958
+ .entries()
959
+ .filter((entry) => entry.envelope.kind === kind);
960
+ if (artifact === 'rank') {
961
+ return this._reconcileRankLocked(kind, targets);
962
+ }
963
+ return reconcileVectors({
964
+ kind,
965
+ artifact,
966
+ targets,
967
+ maintenance: this._vectors,
968
+ embedsKind: (k) => this.embedsKind(k),
969
+ resolve: (scope, id) => this._readRecord(scope, id),
970
+ stampRef: (scope, id, ref) => this._rewriteEnvelope(scope, id, (r) => r.envelope.embeddingRef === ref
971
+ ? undefined
972
+ : { envelope: Object.assign(Object.assign({}, r.envelope), { embeddingRef: ref }), body: r.body })
973
+ });
974
+ }
975
+ /** The rank branch of {@link FileTreeMemoryStore._reconcileLocked}. */
976
+ async _reconcileRankLocked(kind, targets) {
977
+ if (!this._rankProjectors.has(kind)) {
978
+ return fail(`memory reconcile '${kind}' rank: no rank projector is registered for this kind`);
979
+ }
980
+ // No materialization at all: `_rewriteEnvelope` re-reads each record itself.
981
+ let repaired = 0;
982
+ for (const target of targets) {
983
+ const applied = this._rewriteEnvelope(target.scope, target.envelope.id, (r) => {
984
+ const stamped = this._stampRank(r);
985
+ return stamped.envelope.rank === r.envelope.rank ? undefined : stamped;
986
+ });
987
+ if (applied.isFailure()) {
988
+ return fail(`memory reconcile '${kind}' rank: ${applied.message}`);
989
+ }
990
+ if (applied.value) {
991
+ repaired++;
992
+ }
993
+ }
994
+ return succeed({ artifact: 'rank', kind, examined: targets.length, repaired, failed: [] });
995
+ }
996
+ /**
997
+ * Re-apply the rank projector to one record on disk. Returns whether `rank`
998
+ * actually changed — an unchanged rank writes nothing, so a reconcile over an
999
+ * already-consistent store touches no files.
1000
+ */
1001
+ _rewriteEnvelope(scope, id, mutate) {
1002
+ return this._resolveScopeDir(scope).onSuccess((scopeDir) => {
1003
+ /* c8 ignore next 3 - defensive: the scope dir exists for any indexed record */
1004
+ if (scopeDir === undefined) {
1005
+ return fail(`'${id}': scope '${scope}' not found`);
1006
+ }
1007
+ return scopeDir.getChildren().onSuccess((children) => {
1008
+ const targetName = `${id}${MEMORY_FILE_EXTENSION}`;
1009
+ const file = children.find((c) => c.type === 'file' && c.name === targetName);
1010
+ /* c8 ignore next 3 - defensive: the file exists for any indexed record */
1011
+ if (file === undefined) {
1012
+ return fail(`'${id}': file not found`);
1013
+ }
1014
+ return file.getRawContents().onSuccess((raw) =>
1015
+ // `parseMemoryFile` validates the body through the registered Converter,
1016
+ // so a corrupt record is refused rather than silently rewritten, and
1017
+ // `_verifyLoaded` re-applies the same id-vs-filename and
1018
+ // scope-derived-entityId checks the two load paths apply. Reconcile is a
1019
+ // read-then-write, and a file can change on disk after the index was
1020
+ // built, so skipping them would make this the one path that accepts —
1021
+ // and rewrites — a record the store would otherwise refuse to load.
1022
+ // `splitFrontmatter` then supplies the body text for the write.
1023
+ parseMemoryFile(raw, this._registry)
1024
+ .withErrorFormat((msg) => `'${id}': ${msg}`)
1025
+ .onSuccess((parsedRecord) => this._verifyLoaded(scope, file, parsedRecord))
1026
+ .onSuccess((parsed) => splitFrontmatter(raw)
1027
+ .withErrorFormat((msg) => `'${id}': ${msg}`)
1028
+ .onSuccess((parts) => {
1029
+ // The mutator says "nothing to change" with `undefined` rather
1030
+ // than by returning an equal record: persisting unconditionally
1031
+ // would bump `updated` on every record of the kind, trading a
1032
+ // wrong value for a wrong timestamp, and a deep comparison here
1033
+ // would have to know which fields each caller touches.
1034
+ const stamped = mutate({
1035
+ envelope: parsed.envelope,
1036
+ body: parts.body
1037
+ });
1038
+ if (stamped === undefined) {
1039
+ return succeed(false);
1040
+ }
1041
+ return this._persist(stamped, scope, id).onSuccess(() => succeed(true));
1042
+ })));
1043
+ });
1044
+ });
1045
+ }
811
1046
  /**
812
1047
  * Stamp the store-computed {@link IMemoryEnvelope.rank} onto a fully-built,
813
1048
  * fully-stamped record by running the kind's registered {@link RankProjector}.
@@ -996,8 +1231,8 @@ export class FileTreeMemoryStore {
996
1231
  _initialIndex(onRecordError) {
997
1232
  return this._collectEntries(this._root, [], onRecordError).onSuccess((entries) => this._index.rebuild(entries).onSuccess(() => {
998
1233
  for (const entry of entries) {
999
- if (entry.record.envelope.seq > this._seq) {
1000
- this._seq = entry.record.envelope.seq;
1234
+ if (entry.envelope.seq > this._seq) {
1235
+ this._seq = entry.envelope.seq;
1001
1236
  }
1002
1237
  }
1003
1238
  return succeed(true);
@@ -1038,11 +1273,17 @@ export class FileTreeMemoryStore {
1038
1273
  * failure passes through untouched so the historical error is byte-identical.
1039
1274
  */
1040
1275
  _loadRecordFile(scope, child, onRecordError) {
1041
- return child
1276
+ return (child
1042
1277
  .getRawContents()
1043
1278
  .onSuccess((raw) => parseMemoryFile(raw, this._registry))
1044
1279
  .onSuccess((parsedRecord) => this._verifyLoaded(scope, child, parsedRecord))
1045
- .onSuccess((verified) => succeed([{ scope, record: verified }]))
1280
+ // Parse validate → PROJECT → discard, per file. The body is read and
1281
+ // fully validated (which is what gives `onRecordError` its meaning), then
1282
+ // dropped here rather than carried into the index. Peak body residency
1283
+ // across the whole open is therefore ONE record, not N — which is the
1284
+ // resident-memory moment this whole surface exists to fix, and it is why
1285
+ // the projection had to reach `rebuild` and not just the read methods.
1286
+ .onSuccess((verified) => succeed([{ scope, envelope: verified.envelope }]))
1046
1287
  .onFailure((message) => {
1047
1288
  if (onRecordError === 'skip') {
1048
1289
  const path = `${scope}/${child.name}`;
@@ -1051,7 +1292,7 @@ export class FileTreeMemoryStore {
1051
1292
  this._warnSwallowed(error);
1052
1293
  }
1053
1294
  return fail(message);
1054
- });
1295
+ }));
1055
1296
  }
1056
1297
  }
1057
1298
  /**