@fgv/ts-agent-memory 5.1.0-36 → 5.1.0-39

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 (197) hide show
  1. package/.rush/temp/{f6a88bfdd66517ccb98c2c7ae1be6e6fe9e15d38.tar.log → b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log} +116 -2
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +4 -4
  3. package/.rush/temp/operation/build/all.log +4 -4
  4. package/.rush/temp/operation/build/log-chunks.jsonl +4 -4
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/.rush/temp/shrinkwrap-deps.json +222 -221
  7. package/config/jest.config.json +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/packlets/ingest/cycleGuard.js +111 -0
  11. package/dist/packlets/ingest/cycleGuard.js.map +1 -0
  12. package/dist/packlets/ingest/hostStages.js +6 -0
  13. package/dist/packlets/ingest/hostStages.js.map +1 -0
  14. package/dist/packlets/ingest/index.js +9 -0
  15. package/dist/packlets/ingest/index.js.map +1 -0
  16. package/dist/packlets/ingest/model.js +6 -0
  17. package/dist/packlets/ingest/model.js.map +1 -0
  18. package/dist/packlets/ingest/orchestrator.js +438 -0
  19. package/dist/packlets/ingest/orchestrator.js.map +1 -0
  20. package/dist/packlets/retrieve/index.js +1 -0
  21. package/dist/packlets/retrieve/index.js.map +1 -1
  22. package/dist/packlets/retrieve/temporalRetrievers.js +172 -0
  23. package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -0
  24. package/dist/packlets/store/fileTreeMemoryStore.js +323 -41
  25. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  26. package/dist/packlets/tools/index.js +6 -0
  27. package/dist/packlets/tools/index.js.map +1 -0
  28. package/dist/packlets/tools/memoryTools.js +337 -0
  29. package/dist/packlets/tools/memoryTools.js.map +1 -0
  30. package/dist/packlets/types/identityCodec.js +115 -0
  31. package/dist/packlets/types/identityCodec.js.map +1 -1
  32. package/dist/packlets/types/index.js +1 -0
  33. package/dist/packlets/types/index.js.map +1 -1
  34. package/dist/packlets/types/temporal.js +85 -0
  35. package/dist/packlets/types/temporal.js.map +1 -0
  36. package/dist/packlets/types/writePolicy.js +96 -0
  37. package/dist/packlets/types/writePolicy.js.map +1 -1
  38. package/dist/test/unit/converters/antagonistRoundTrip.test.js +95 -0
  39. package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
  40. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +273 -0
  41. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
  42. package/dist/test/unit/ingest/cycleGuard.test.js +54 -0
  43. package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -0
  44. package/dist/test/unit/ingest/orchestrator.test.js +913 -0
  45. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -0
  46. package/dist/test/unit/retrieve/temporalRetrievers.test.js +182 -0
  47. package/dist/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
  48. package/dist/test/unit/store/antagonistTemporalBoundary.test.js +120 -0
  49. package/dist/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
  50. package/dist/test/unit/store/fileTreeMemoryStore.test.js +74 -7
  51. package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  52. package/dist/test/unit/store/temporalStore.test.js +398 -0
  53. package/dist/test/unit/store/temporalStore.test.js.map +1 -0
  54. package/dist/test/unit/tools/memoryTools.test.js +572 -0
  55. package/dist/test/unit/tools/memoryTools.test.js.map +1 -0
  56. package/dist/test/unit/types/temporalCodec.test.js +203 -0
  57. package/dist/test/unit/types/temporalCodec.test.js.map +1 -0
  58. package/dist/test/unit/types/temporalPolicy.test.js +62 -0
  59. package/dist/test/unit/types/temporalPolicy.test.js.map +1 -0
  60. package/dist/ts-agent-memory.d.ts +1042 -10
  61. package/dist/tsdoc-metadata.json +1 -1
  62. package/etc/ts-agent-memory.api.md +284 -0
  63. package/lib/index.d.ts +2 -0
  64. package/lib/index.d.ts.map +1 -1
  65. package/lib/index.js +2 -0
  66. package/lib/index.js.map +1 -1
  67. package/lib/packlets/ingest/cycleGuard.d.ts +41 -0
  68. package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -0
  69. package/lib/packlets/ingest/cycleGuard.js +115 -0
  70. package/lib/packlets/ingest/cycleGuard.js.map +1 -0
  71. package/lib/packlets/ingest/hostStages.d.ts +88 -0
  72. package/lib/packlets/ingest/hostStages.d.ts.map +1 -0
  73. package/lib/packlets/ingest/hostStages.js +7 -0
  74. package/lib/packlets/ingest/hostStages.js.map +1 -0
  75. package/lib/packlets/ingest/index.d.ts +5 -0
  76. package/lib/packlets/ingest/index.d.ts.map +1 -0
  77. package/lib/packlets/ingest/index.js +25 -0
  78. package/lib/packlets/ingest/index.js.map +1 -0
  79. package/lib/packlets/ingest/model.d.ts +177 -0
  80. package/lib/packlets/ingest/model.d.ts.map +1 -0
  81. package/lib/packlets/ingest/model.js +7 -0
  82. package/lib/packlets/ingest/model.js.map +1 -0
  83. package/lib/packlets/ingest/orchestrator.d.ts +206 -0
  84. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -0
  85. package/lib/packlets/ingest/orchestrator.js +442 -0
  86. package/lib/packlets/ingest/orchestrator.js.map +1 -0
  87. package/lib/packlets/retrieve/index.d.ts +1 -0
  88. package/lib/packlets/retrieve/index.d.ts.map +1 -1
  89. package/lib/packlets/retrieve/index.js +1 -0
  90. package/lib/packlets/retrieve/index.js.map +1 -1
  91. package/lib/packlets/retrieve/temporalRetrievers.d.ts +78 -0
  92. package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -0
  93. package/lib/packlets/retrieve/temporalRetrievers.js +178 -0
  94. package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -0
  95. package/lib/packlets/store/fileTreeMemoryStore.d.ts +118 -9
  96. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  97. package/lib/packlets/store/fileTreeMemoryStore.js +322 -40
  98. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  99. package/lib/packlets/tools/index.d.ts +2 -0
  100. package/lib/packlets/tools/index.d.ts.map +1 -0
  101. package/lib/packlets/tools/index.js +22 -0
  102. package/lib/packlets/tools/index.js.map +1 -0
  103. package/lib/packlets/tools/memoryTools.d.ts +139 -0
  104. package/lib/packlets/tools/memoryTools.d.ts.map +1 -0
  105. package/lib/packlets/tools/memoryTools.js +341 -0
  106. package/lib/packlets/tools/memoryTools.js.map +1 -0
  107. package/lib/packlets/types/identityCodec.d.ts +86 -0
  108. package/lib/packlets/types/identityCodec.d.ts.map +1 -1
  109. package/lib/packlets/types/identityCodec.js +118 -1
  110. package/lib/packlets/types/identityCodec.js.map +1 -1
  111. package/lib/packlets/types/index.d.ts +1 -0
  112. package/lib/packlets/types/index.d.ts.map +1 -1
  113. package/lib/packlets/types/index.js +1 -0
  114. package/lib/packlets/types/index.js.map +1 -1
  115. package/lib/packlets/types/temporal.d.ts +40 -0
  116. package/lib/packlets/types/temporal.d.ts.map +1 -0
  117. package/lib/packlets/types/temporal.js +92 -0
  118. package/lib/packlets/types/temporal.js.map +1 -0
  119. package/lib/packlets/types/writePolicy.d.ts +49 -0
  120. package/lib/packlets/types/writePolicy.d.ts.map +1 -1
  121. package/lib/packlets/types/writePolicy.js +98 -1
  122. package/lib/packlets/types/writePolicy.js.map +1 -1
  123. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts +10 -0
  124. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts.map +1 -0
  125. package/lib/test/unit/converters/antagonistRoundTrip.test.js +97 -0
  126. package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
  127. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts +9 -0
  128. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts.map +1 -0
  129. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +275 -0
  130. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
  131. package/lib/test/unit/ingest/cycleGuard.test.d.ts +2 -0
  132. package/lib/test/unit/ingest/cycleGuard.test.d.ts.map +1 -0
  133. package/lib/test/unit/ingest/cycleGuard.test.js +56 -0
  134. package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -0
  135. package/lib/test/unit/ingest/orchestrator.test.d.ts +2 -0
  136. package/lib/test/unit/ingest/orchestrator.test.d.ts.map +1 -0
  137. package/lib/test/unit/ingest/orchestrator.test.js +915 -0
  138. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -0
  139. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts +2 -0
  140. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts.map +1 -0
  141. package/lib/test/unit/retrieve/temporalRetrievers.test.js +184 -0
  142. package/lib/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
  143. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts +9 -0
  144. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts.map +1 -0
  145. package/lib/test/unit/store/antagonistTemporalBoundary.test.js +122 -0
  146. package/lib/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
  147. package/lib/test/unit/store/fileTreeMemoryStore.test.js +74 -7
  148. package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  149. package/lib/test/unit/store/temporalStore.test.d.ts +2 -0
  150. package/lib/test/unit/store/temporalStore.test.d.ts.map +1 -0
  151. package/lib/test/unit/store/temporalStore.test.js +400 -0
  152. package/lib/test/unit/store/temporalStore.test.js.map +1 -0
  153. package/lib/test/unit/tools/memoryTools.test.d.ts +2 -0
  154. package/lib/test/unit/tools/memoryTools.test.d.ts.map +1 -0
  155. package/lib/test/unit/tools/memoryTools.test.js +574 -0
  156. package/lib/test/unit/tools/memoryTools.test.js.map +1 -0
  157. package/lib/test/unit/types/temporalCodec.test.d.ts +2 -0
  158. package/lib/test/unit/types/temporalCodec.test.d.ts.map +1 -0
  159. package/lib/test/unit/types/temporalCodec.test.js +205 -0
  160. package/lib/test/unit/types/temporalCodec.test.js.map +1 -0
  161. package/lib/test/unit/types/temporalPolicy.test.d.ts +2 -0
  162. package/lib/test/unit/types/temporalPolicy.test.d.ts.map +1 -0
  163. package/lib/test/unit/types/temporalPolicy.test.js +64 -0
  164. package/lib/test/unit/types/temporalPolicy.test.js.map +1 -0
  165. package/package.json +19 -19
  166. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  167. package/rush-logs/ts-agent-memory.build.log +4 -4
  168. package/src/index.ts +2 -0
  169. package/src/packlets/ingest/cycleGuard.ts +142 -0
  170. package/src/packlets/ingest/hostStages.ts +111 -0
  171. package/src/packlets/ingest/index.ts +9 -0
  172. package/src/packlets/ingest/model.ts +184 -0
  173. package/src/packlets/ingest/orchestrator.ts +797 -0
  174. package/src/packlets/retrieve/index.ts +1 -0
  175. package/src/packlets/retrieve/temporalRetrievers.ts +210 -0
  176. package/src/packlets/store/fileTreeMemoryStore.ts +460 -66
  177. package/src/packlets/tools/index.ts +6 -0
  178. package/src/packlets/tools/memoryTools.ts +579 -0
  179. package/src/packlets/types/identityCodec.ts +184 -0
  180. package/src/packlets/types/index.ts +1 -0
  181. package/src/packlets/types/temporal.ts +96 -0
  182. package/src/packlets/types/writePolicy.ts +127 -0
  183. package/src/test/unit/converters/antagonistRoundTrip.test.ts +110 -0
  184. package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +362 -0
  185. package/src/test/unit/ingest/cycleGuard.test.ts +68 -0
  186. package/src/test/unit/ingest/orchestrator.test.ts +1158 -0
  187. package/src/test/unit/retrieve/temporalRetrievers.test.ts +226 -0
  188. package/src/test/unit/store/antagonistTemporalBoundary.test.ts +158 -0
  189. package/src/test/unit/store/fileTreeMemoryStore.test.ts +98 -7
  190. package/src/test/unit/store/temporalStore.test.ts +469 -0
  191. package/src/test/unit/tools/memoryTools.test.ts +771 -0
  192. package/src/test/unit/types/temporalCodec.test.ts +259 -0
  193. package/src/test/unit/types/temporalPolicy.test.ts +96 -0
  194. package/temp/build/lint/_eslint-5eVG3S6w.json +122 -46
  195. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  196. package/temp/ts-agent-memory.api.json +11984 -6314
  197. package/temp/ts-agent-memory.api.md +284 -0
@@ -14,12 +14,18 @@ import {
14
14
  IMemoryEnvelope,
15
15
  IMemoryRecord,
16
16
  IProvenance,
17
+ ITemporalIdentityCodec,
17
18
  IWritePolicy,
18
19
  Kind,
19
20
  KnowledgeLwwPolicy,
20
21
  MemoryId,
21
22
  MemoryScopeKey,
22
- Tag
23
+ Tag,
24
+ isTemporalIdentityCodec,
25
+ isTemporalRecord,
26
+ isVersionCurrent,
27
+ selectCurrentVersion,
28
+ selectVersionAsOf
23
29
  } from '../types';
24
30
  import { IBodyConverterRegistry as IRegistry, parseMemoryFile, serializeMemoryFile } from '../converters';
25
31
  import { IIndexedMemoryRecord, IMemoryIndex, MemoryIndex } from '../index';
@@ -47,8 +53,9 @@ export interface IMemoryStoreListFilter {
47
53
  /** Restrict to records carrying this tag (exact match). */
48
54
  readonly tag?: Tag;
49
55
  /**
50
- * For temporal kinds: return only records valid at this epoch ms. No-op in
51
- * B1 (no temporal kinds wired).
56
+ * For temporal (versioned) kinds: collapse each entity to the single version
57
+ * valid at this epoch ms. Non-temporal records are timeless and pass through
58
+ * unchanged. Absent = no temporal projection (every version is returned).
52
59
  */
53
60
  readonly asOf?: number;
54
61
  }
@@ -61,7 +68,9 @@ export interface IMemoryStore {
61
68
  /**
62
69
  * Keyed read by entity id. Resolves `entityId` to a storage address via the
63
70
  * registered {@link IIdentityCodec} for `kind`. Returns `undefined` when no
64
- * record exists.
71
+ * record exists. For a versioned (temporal) kind this returns the current
72
+ * version, resolved from the derived in-memory index (not re-read/re-verified
73
+ * from disk per call — the index is kept in sync with every write).
65
74
  */
66
75
  get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>>;
67
76
 
@@ -86,7 +95,10 @@ export interface IMemoryStore {
86
95
 
87
96
  /**
88
97
  * Delete a record by `(kind, entityId)`. Non-temporal kinds physically delete
89
- * the file. Returns the {@link MemoryId} of the deleted record.
98
+ * the file and return the deleted record's {@link MemoryId}. Temporal
99
+ * (versioned) kinds SOFT-delete: the current version is invalidated
100
+ * (`invalid_at` set), history is retained, and the invalidated version's
101
+ * {@link MemoryId} is returned.
90
102
  */
91
103
  delete(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>>;
92
104
  }
@@ -165,16 +177,6 @@ interface IPutOutcome {
165
177
  readonly evicted: ReadonlyArray<MemoryId>;
166
178
  }
167
179
 
168
- /**
169
- * The resolved storage address + content hash for a put, produced by the
170
- * synchronous prefix of `_putLocked` and handed to the async write tail.
171
- */
172
- interface IResolvedWriteAddress {
173
- readonly scope: MemoryScopeKey;
174
- readonly idStem: string;
175
- readonly hash: string;
176
- }
177
-
178
180
  interface IInternalParams {
179
181
  readonly root: FileTree.IMutableFileTreeDirectoryItem;
180
182
  readonly registry: IRegistry;
@@ -198,9 +200,11 @@ interface IInternalParams {
198
200
  * write-lock so the index and the on-disk files never interleave.
199
201
  *
200
202
  * @remarks
201
- * B1 supports flat (non-versioned) layout only and string (markdown) bodies.
202
- * A codec reporting `isVersioned: true`, or a non-string body, fails loudly —
203
- * the versioned/temporal write path is a fast-follow.
203
+ * Bodies are string (markdown). Both layouts are supported: flat
204
+ * (one-file-per-entity, non-versioned) and versioned (subtree-per-entity,
205
+ * invalidate-don't-delete), dispatched per kind by the codec's `isVersioned`
206
+ * flag. A kind is flat with zero behavioral impact unless it opts into a
207
+ * {@link ITemporalIdentityCodec}.
204
208
  * @public
205
209
  */
206
210
  export class FileTreeMemoryStore implements IMemoryStore {
@@ -302,7 +306,16 @@ export class FileTreeMemoryStore implements IMemoryStore {
302
306
  const result: Result<IMemoryRecord<unknown> | undefined> = this._codecFor(kind).onSuccess((codec) =>
303
307
  codec.encode(entityId).onSuccess((addr) => {
304
308
  if (addr.isVersioned) {
305
- return fail(`memory get '${entityId}': versioned/temporal layout not yet supported`);
309
+ if (!isTemporalIdentityCodec(codec)) {
310
+ return fail(
311
+ `memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
312
+ );
313
+ }
314
+ // Versioned keyed read resolves the CURRENT version (highest-seq
315
+ // version whose `invalid_at` is null/absent) from the entity subtree,
316
+ // read off the derived index. `asOf` resolution is via `list({ asOf })`
317
+ // and the temporal retrievers.
318
+ return succeed(this._readVersionedCurrent(addr.scope));
306
319
  }
307
320
  return this._readRecord(addr.scope, addr.idStem);
308
321
  })
@@ -340,7 +353,48 @@ export class FileTreeMemoryStore implements IMemoryStore {
340
353
  return true;
341
354
  })
342
355
  .map((entry) => entry.record);
343
- return succeed(matches);
356
+ if (filter?.asOf === undefined) {
357
+ // No temporal projection requested: byte-identical to the pre-temporal
358
+ // behavior (the flat-path guarantee — every version is returned).
359
+ return succeed(matches);
360
+ }
361
+ return succeed(FileTreeMemoryStore._projectAsOf(matches, filter.asOf));
362
+ }
363
+
364
+ /**
365
+ * Collapse temporal records to the single version valid at `asOf` per entity;
366
+ * non-temporal records are timeless and pass through unchanged (valid-time
367
+ * `asOf` applies only to versioned kinds; transaction-time / full bi-temporal
368
+ * filtering is deferred — OQ-9). An entity with no version valid at `asOf`
369
+ * contributes nothing.
370
+ */
371
+ private static _projectAsOf(
372
+ records: ReadonlyArray<IMemoryRecord<unknown>>,
373
+ asOf: number
374
+ ): ReadonlyArray<IMemoryRecord<unknown>> {
375
+ const passthrough: IMemoryRecord<unknown>[] = [];
376
+ const groups: Map<string, IMemoryRecord<unknown>[]> = new Map<string, IMemoryRecord<unknown>[]>();
377
+ for (const record of records) {
378
+ if (!isTemporalRecord(record)) {
379
+ passthrough.push(record);
380
+ continue;
381
+ }
382
+ const key: string = `${record.envelope.kind}\0${record.envelope.entityId}`;
383
+ const existing: IMemoryRecord<unknown>[] | undefined = groups.get(key);
384
+ if (existing === undefined) {
385
+ groups.set(key, [record]);
386
+ } else {
387
+ existing.push(record);
388
+ }
389
+ }
390
+ const result: IMemoryRecord<unknown>[] = [...passthrough];
391
+ for (const versions of groups.values()) {
392
+ const valid: IMemoryRecord<unknown> | undefined = selectVersionAsOf(versions, asOf);
393
+ if (valid !== undefined) {
394
+ result.push(valid);
395
+ }
396
+ }
397
+ return result;
344
398
  }
345
399
 
346
400
  /** {@inheritDoc IMemoryStore.put} */
@@ -479,32 +533,37 @@ export class FileTreeMemoryStore implements IMemoryStore {
479
533
  );
480
534
  }
481
535
  const body: string = record.body;
482
- // Resolve the synchronous prefix (body validation codec address content
483
- // hash) into a small descriptor, then bridge to the async write tail (which
484
- // embeds on write). Keeping the sync prefix intact preserves the exact
485
- // dedup/policy ordering of the original; only the embed step is new.
536
+ // Resolve the body converter + codec, then dispatch on layout. The flat
537
+ // (non-versioned) path keeps its exact dedup/policy ordering; the versioned
538
+ // path is a wholly separate branch so the flat path is behaviorally
539
+ // unchanged (the consumer adoption guarantee).
486
540
  return this._registry
487
541
  .convert(envelope.kind, body)
488
542
  .withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
489
543
  .onSuccess(() => this._codecFor(envelope.kind))
490
- .onSuccess(
491
- (codec): Result<IResolvedWriteAddress> =>
492
- codec.encode(envelope.entityId).onSuccess((addr): Result<IResolvedWriteAddress> => {
493
- if (addr.isVersioned) {
494
- return fail(`memory put '${envelope.entityId}': versioned/temporal layout not yet supported`);
495
- }
496
- if (envelope.id !== addr.idStem) {
497
- return fail(
498
- `memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`
544
+ .thenOnSuccess((codec) =>
545
+ codec.encode(envelope.entityId).thenOnSuccess((addr) => {
546
+ if (addr.isVersioned) {
547
+ if (!isTemporalIdentityCodec(codec)) {
548
+ return Promise.resolve(
549
+ fail<IPutOutcome>(
550
+ `memory put '${envelope.entityId}': codec for versioned kind '${envelope.kind}' does not implement the temporal codec interface`
551
+ )
499
552
  );
500
553
  }
501
- return this._contentHash(envelope.kind, body, envelope.links).onSuccess((hash) =>
502
- succeed({ scope: addr.scope, idStem: addr.idStem, hash })
554
+ return this._putVersioned(record, body, codec, addr.scope);
555
+ }
556
+ if (envelope.id !== addr.idStem) {
557
+ return Promise.resolve(
558
+ fail<IPutOutcome>(
559
+ `memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`
560
+ )
503
561
  );
504
- })
505
- )
506
- .thenOnSuccess((resolved) =>
507
- this._writeResolved(record, body, resolved.scope, resolved.idStem, resolved.hash)
562
+ }
563
+ return this._contentHash(envelope.kind, body, envelope.links).thenOnSuccess((hash) =>
564
+ this._writeResolved(record, body, addr.scope, addr.idStem, hash)
565
+ );
566
+ })
508
567
  );
509
568
  }
510
569
 
@@ -533,14 +592,32 @@ export class FileTreeMemoryStore implements IMemoryStore {
533
592
  // (Tags / provenance are metadata and are NOT part of the hash; see
534
593
  // design-lock §2.5.)
535
594
  if (dedupScope === 'content') {
536
- const duplicate: IMemoryRecord<unknown> | undefined = this._findByContentHash(scope, hash);
595
+ // Cross-id content collapse: an identical { kind, body, links } triple under
596
+ // a DIFFERENT id is a no-op (knowledge family). A same-id match is excluded
597
+ // here and falls through to the LWW path below so a metadata-only revision
598
+ // (tags / provenance — outside the content hash but inside the policy's
599
+ // mutableFields) actually applies. Content-dedup must never shadow LWW for
600
+ // the same entity.
601
+ const duplicate: IMemoryRecord<unknown> | undefined = this._findByContentHash(
602
+ scope,
603
+ hash,
604
+ record.envelope.id
605
+ );
537
606
  if (duplicate !== undefined) {
538
607
  return succeed({ record: duplicate, evicted: [] });
539
608
  }
540
609
  }
541
610
  return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
542
- if (dedupScope === 'entity' && existing !== undefined && existing.envelope.contentHash === hash) {
543
- // Entity-scoped dedup: an identical re-put of the same entity is a no-op.
611
+ // Same-id re-put is a no-op ONLY when the content hash matches AND the
612
+ // mutable metadata is also unchanged. The content hash covers
613
+ // { kind, body, links }; a matching hash with revised tags/provenance is a
614
+ // real update that must reach applyUpdate, not be swallowed as a duplicate
615
+ // (both dedup scopes).
616
+ if (
617
+ existing !== undefined &&
618
+ existing.envelope.contentHash === hash &&
619
+ this._isMutableMetadataUnchanged(existing, record)
620
+ ) {
544
621
  return Promise.resolve(succeed({ record: existing, evicted: [] }));
545
622
  }
546
623
  // The admission cohort is the set of records the policy's cap applies to:
@@ -774,31 +851,293 @@ export class FileTreeMemoryStore implements IMemoryStore {
774
851
  }
775
852
 
776
853
  private async _deleteLocked(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>> {
777
- return this._codecFor(kind)
778
- .onSuccess((codec) => codec.encode(entityId))
779
- .thenOnSuccess((addr) => {
854
+ return this._codecFor(kind).thenOnSuccess((codec) =>
855
+ codec.encode(entityId).thenOnSuccess((addr) => {
780
856
  if (addr.isVersioned) {
781
- return Promise.resolve(
782
- fail<MemoryId>(`memory delete '${entityId}': versioned/temporal layout not yet supported`)
857
+ if (!isTemporalIdentityCodec(codec)) {
858
+ return Promise.resolve(
859
+ fail<MemoryId>(
860
+ `memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
861
+ )
862
+ );
863
+ }
864
+ return this._deleteVersioned(entityId, addr.scope);
865
+ }
866
+ return this._deleteFlat(entityId, addr.scope, addr.idStem);
867
+ })
868
+ );
869
+ }
870
+
871
+ /**
872
+ * Flat (non-versioned) delete: physically remove the record file + index
873
+ * entry, then prune the vector best-effort. Structurally unchanged from the
874
+ * pre-temporal delete path.
875
+ */
876
+ private async _deleteFlat(
877
+ entityId: EntityId,
878
+ scope: MemoryScopeKey,
879
+ idStem: string
880
+ ): Promise<Result<MemoryId>> {
881
+ return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
882
+ if (existing === undefined) {
883
+ return Promise.resolve(fail<MemoryId>(`memory delete '${entityId}': no record found`));
884
+ }
885
+ // Delete the record file + index entry (authoritative), then prune the
886
+ // vector best-effort: a committed delete must not fail because the
887
+ // derived index could not be pruned.
888
+ return this._deleteFile(scope, idStem)
889
+ .onSuccess(() => this._index.patch('delete', { scope, record: existing }))
890
+ .thenOnSuccess(async () => {
891
+ await this._removeVectorBestEffort(existing.envelope.id);
892
+ return succeed(existing.envelope.id);
893
+ });
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Resolve the current version of a temporal entity from the derived index: the
899
+ * highest-`seq` version under the entity subtree `scope` whose `invalid_at` is
900
+ * null/absent. `undefined` when the entity has no current version (never
901
+ * written, or fully invalidated / soft-deleted).
902
+ */
903
+ private _readVersionedCurrent(scope: MemoryScopeKey): IMemoryRecord<unknown> | undefined {
904
+ return selectCurrentVersion(this._versionsForEntity(scope));
905
+ }
906
+
907
+ /**
908
+ * Every persisted version of the entity whose subtree is `scope`. All version
909
+ * files for one entity live under exactly that scope (which encodes the
910
+ * entityId), so a scope filter over the index isolates one entity's versions.
911
+ */
912
+ private _versionsForEntity(scope: MemoryScopeKey): ReadonlyArray<IMemoryRecord<unknown>> {
913
+ return this._index
914
+ .entries()
915
+ .filter((entry) => entry.scope === scope)
916
+ .map((entry) => entry.record);
917
+ }
918
+
919
+ /**
920
+ * Versioned write (invalidate-don't-delete). Builds the new version's content
921
+ * (a first version from the incoming record, or a merge of the incoming patch
922
+ * over the current version), persists it as a NEW version file, then sets
923
+ * `invalid_at` on the prior current version.
924
+ *
925
+ * Durability order: the new version is persisted FIRST. It carries the highest
926
+ * `seq`, so a crash before the prior-version invalidation completes still
927
+ * resolves the new version as current (`selectCurrentVersion` breaks a
928
+ * two-current tie by highest `seq`), and `asOf` reads stay correct because each
929
+ * version's `valid_at` lower-bounds its interval.
930
+ */
931
+ private async _putVersioned(
932
+ record: IMemoryRecord<unknown>,
933
+ body: string,
934
+ codec: ITemporalIdentityCodec,
935
+ scope: MemoryScopeKey
936
+ ): Promise<Result<IPutOutcome>> {
937
+ const envelope: IMemoryEnvelope = record.envelope;
938
+ const entityId: EntityId = envelope.entityId;
939
+ const kind: Kind = envelope.kind;
940
+ // Snapshot the entity's versions BEFORE the write. `priorCurrents` is every
941
+ // still-current version at snapshot time — normally one, but two-or-more if a
942
+ // prior invalidation partially failed; invalidating all of them lets the write
943
+ // self-heal a stuck state (P2-7).
944
+ const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
945
+ const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
946
+ const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
947
+ const policy: IWritePolicy = this._policyFor(kind);
948
+ const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
949
+ return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
950
+ // Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
951
+ // its mutable metadata are unchanged (does not spawn a redundant version).
952
+ // A metadata-only revision (tags/provenance — declared mutable by
953
+ // TemporalVersionedPolicy) must NOT be swallowed here: it mints a new
954
+ // version via the applyUpdate merge in `_buildVersionedRecord`, mirroring
955
+ // the flat path. Content-scoped dedup is not a versioning concern, so only
956
+ // the entity granularity is honored here.
957
+ if (
958
+ dedupScope === 'entity' &&
959
+ current !== undefined &&
960
+ current.envelope.contentHash === hash &&
961
+ this._isMutableMetadataUnchanged(current, record)
962
+ ) {
963
+ return Promise.resolve(succeed<IPutOutcome>({ record: current, evicted: [] }));
964
+ }
965
+ // On the versioned path the admission cohort is the entity's ENTIRE version
966
+ // history (no target id to exclude — the new version does not exist yet),
967
+ // which differs from the flat path's "cohort excluding target id" shape.
968
+ return policy.admit(record, versions).thenOnSuccess((decision) => {
969
+ if (decision.decision === 'reject') {
970
+ return Promise.resolve(fail<IPutOutcome>(`memory put: rejected by policy: ${decision.reason}`));
971
+ }
972
+ // No culling on the versioned path — history is retained (invalidate-don't-delete).
973
+ const now: number = this._clock();
974
+ const seq: number = ++this._seq;
975
+ // World-truth start of the new version. Each prior current version's
976
+ // world-truth interval CLOSES at this same instant (not at `now`) so a
977
+ // backdated/future-dated `valid_at` leaves no gap or overlap on the
978
+ // valid-time axis.
979
+ const validAt: number = envelope.temporal?.valid_at ?? now;
980
+ return codec
981
+ .encodeVersion(entityId, seq)
982
+ .withErrorFormat((msg) => `memory put '${entityId}': ${msg}`)
983
+ .thenOnSuccess((versionStem) =>
984
+ this._buildVersionedRecord(record, body, current, policy, hash, versionStem, validAt, now, seq)
985
+ .thenOnSuccess((built) => this._embedOnWrite(built))
986
+ .onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, versionStem))
987
+ .onSuccess((persisted) =>
988
+ this._invalidateCurrents(scope, priorCurrents, validAt, now).onSuccess(() =>
989
+ succeed(persisted)
990
+ )
991
+ )
992
+ .onSuccess((persisted) => succeed({ record: persisted, evicted: [] }))
993
+ );
994
+ });
995
+ });
996
+ }
997
+
998
+ /**
999
+ * Invalidate a set of still-current versions (close each world-truth interval at
1000
+ * `invalidAt`; stamp transaction-time `updated` = `now`). Chained so a mid-list
1001
+ * failure propagates. Invalidating every prior current — not just the
1002
+ * highest-`seq` pick — self-heals a state where a previous write's invalidation
1003
+ * only partially completed (P2-7).
1004
+ */
1005
+ private _invalidateCurrents(
1006
+ scope: MemoryScopeKey,
1007
+ currents: ReadonlyArray<IMemoryRecord<unknown>>,
1008
+ invalidAt: number,
1009
+ now: number
1010
+ ): Result<true> {
1011
+ return currents.reduce<Result<true>>(
1012
+ (acc, version) =>
1013
+ acc.onSuccess(() =>
1014
+ this._invalidateVersion(scope, version, invalidAt, now).onSuccess(() => succeed(true))
1015
+ ),
1016
+ succeed(true)
1017
+ );
1018
+ }
1019
+
1020
+ /**
1021
+ * Build the new version to persist. A first version takes the incoming
1022
+ * content verbatim (its dedup `hash` is reused); a subsequent version projects
1023
+ * the incoming record's mutable fields into a merge-patch and lets the policy
1024
+ * merge them over the CURRENT version (the merge-patch-under-versioning
1025
+ * contract), recomputing the content hash from the policy's output. Each
1026
+ * version is its own record with its own transaction time (`created` = `now`)
1027
+ * and a store-minted `id` = the version stem, so `id === filename stem` holds.
1028
+ */
1029
+ private _buildVersionedRecord(
1030
+ incoming: IMemoryRecord<unknown>,
1031
+ body: string,
1032
+ current: IMemoryRecord<unknown> | undefined,
1033
+ policy: IWritePolicy,
1034
+ // Reused only for the first-version branch (its content is taken verbatim); a
1035
+ // subsequent version recomputes its hash from the merge-patched content.
1036
+ firstVersionHash: string,
1037
+ versionStem: string,
1038
+ validAt: number,
1039
+ now: number,
1040
+ seq: number
1041
+ ): Result<IMemoryRecord<string>> {
1042
+ const mintedId: MemoryId = versionStem as MemoryId;
1043
+ if (current === undefined) {
1044
+ const envelope: IMemoryEnvelope = {
1045
+ ...incoming.envelope,
1046
+ id: mintedId,
1047
+ entityId: incoming.envelope.entityId,
1048
+ created: now,
1049
+ updated: now,
1050
+ seq,
1051
+ contentHash: firstVersionHash,
1052
+ temporal: { valid_at: validAt }
1053
+ };
1054
+ return succeed({ envelope, body });
1055
+ }
1056
+ const patch: Record<string, unknown> = this._projectMutablePatch(incoming, policy.mutableFields);
1057
+ return policy
1058
+ .applyUpdate(current, patch)
1059
+ .withErrorFormat((msg) => `memory put '${incoming.envelope.entityId}': update failed: ${msg}`)
1060
+ .onSuccess((updated) => {
1061
+ if (typeof updated.body !== 'string') {
1062
+ return fail(
1063
+ `memory put '${
1064
+ incoming.envelope.entityId
1065
+ }': policy returned a non-string body (${typeof updated.body})`
783
1066
  );
784
1067
  }
785
- return this._readRecord(addr.scope, addr.idStem).thenOnSuccess((existing) => {
786
- if (existing === undefined) {
787
- return Promise.resolve(fail<MemoryId>(`memory delete '${entityId}': no record found`));
1068
+ const finalBody: string = updated.body;
1069
+ return this._contentHash(updated.envelope.kind, finalBody, updated.envelope.links).onSuccess(
1070
+ (finalHash) => {
1071
+ const envelope: IMemoryEnvelope = {
1072
+ ...updated.envelope,
1073
+ id: mintedId,
1074
+ entityId: incoming.envelope.entityId,
1075
+ created: now,
1076
+ updated: now,
1077
+ seq,
1078
+ contentHash: finalHash,
1079
+ temporal: { valid_at: validAt }
1080
+ };
1081
+ return succeed({ envelope, body: finalBody });
788
1082
  }
789
- // Delete the record file + index entry (authoritative), then prune the
790
- // vector best-effort: a committed delete must not fail because the
791
- // derived index could not be pruned.
792
- return this._deleteFile(addr.scope, addr.idStem)
793
- .onSuccess(() => this._index.patch('delete', { scope: addr.scope, record: existing }))
794
- .thenOnSuccess(async () => {
795
- await this._removeVectorBestEffort(existing.envelope.id);
796
- return succeed(existing.envelope.id);
797
- });
798
- });
1083
+ );
799
1084
  });
800
1085
  }
801
1086
 
1087
+ /**
1088
+ * Set `invalid_at` on a prior current version (invalidate-don't-delete) and
1089
+ * rewrite its file + index entry. The content hash is unchanged — `invalid_at`
1090
+ * is temporal metadata, not part of `{ kind, body, links }` — so the version's
1091
+ * identity is stable.
1092
+ */
1093
+ private _invalidateVersion(
1094
+ scope: MemoryScopeKey,
1095
+ version: IMemoryRecord<unknown>,
1096
+ invalidAt: number,
1097
+ now: number
1098
+ ): Result<IMemoryRecord<unknown>> {
1099
+ /* c8 ignore start -- defensive: every persisted version carries a string body (only string bodies are written) */
1100
+ if (typeof version.body !== 'string') {
1101
+ return fail(`memory put: cannot invalidate version '${version.envelope.id}': non-string body`);
1102
+ }
1103
+ /* c8 ignore stop */
1104
+ // `invalid_at` is the WORLD-TRUTH close of the interval (the superseding version's
1105
+ // `valid_at`, or the delete instant); `updated` is the transaction-time stamp.
1106
+ const invalidated: IMemoryRecord<string> = {
1107
+ envelope: {
1108
+ ...version.envelope,
1109
+ updated: now,
1110
+ temporal: { ...version.envelope.temporal, invalid_at: invalidAt }
1111
+ },
1112
+ body: version.body
1113
+ };
1114
+ return this._persist(invalidated, scope, version.envelope.id);
1115
+ }
1116
+
1117
+ /**
1118
+ * Versioned delete: SOFT delete (invalidate-don't-delete). Sets `invalid_at` on
1119
+ * the current version, leaving the entity's history intact and the entity with
1120
+ * no current version. Returns the invalidated version's {@link MemoryId}. Fails
1121
+ * with "no record found" when there is no current version — matching the flat
1122
+ * delete's not-found semantics. History is retained deliberately: temporal
1123
+ * kinds exist to preserve the audit trail (and the L3 `contradicts` interlock
1124
+ * builds on it), so a hard delete would defeat the purpose.
1125
+ */
1126
+ private async _deleteVersioned(entityId: EntityId, scope: MemoryScopeKey): Promise<Result<MemoryId>> {
1127
+ const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
1128
+ const currents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
1129
+ const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
1130
+ if (current === undefined) {
1131
+ return fail(`memory delete '${entityId}': no record found`);
1132
+ }
1133
+ // A delete closes the world-truth interval at the delete instant (`now` for both
1134
+ // the transaction stamp and the `invalid_at` boundary). Every still-current
1135
+ // version is invalidated (self-heals a stuck two-current state — P2-7); the
1136
+ // highest-`seq` current's id is returned.
1137
+ const now: number = this._clock();
1138
+ return this._invalidateCurrents(scope, currents, now, now).onSuccess(() => succeed(current.envelope.id));
1139
+ }
1140
+
802
1141
  /** Evict (physically delete) a single record file by id, patching the index. */
803
1142
  private _evict(scope: MemoryScopeKey, id: MemoryId): Result<MemoryId> {
804
1143
  return this._readRecord(scope, id).onSuccess((existing) => {
@@ -852,14 +1191,52 @@ export class FileTreeMemoryStore implements IMemoryStore {
852
1191
  .map((entry) => entry.record);
853
1192
  }
854
1193
 
855
- /** Find a record in `scope` whose `contentHash` equals `hash`, if any. */
856
- private _findByContentHash(scope: MemoryScopeKey, hash: string): IMemoryRecord<unknown> | undefined {
1194
+ /**
1195
+ * Find a record in `scope` whose `contentHash` equals `hash`, if any,
1196
+ * optionally excluding a specific id. `excludeId` lets the content-scoped
1197
+ * dedup skip the same-id record so it does not shadow the LWW update path.
1198
+ */
1199
+ private _findByContentHash(
1200
+ scope: MemoryScopeKey,
1201
+ hash: string,
1202
+ excludeId?: string
1203
+ ): IMemoryRecord<unknown> | undefined {
857
1204
  const match: IIndexedMemoryRecord | undefined = this._index
858
1205
  .entries()
859
- .find((entry) => entry.scope === scope && entry.record.envelope.contentHash === hash);
1206
+ .find(
1207
+ (entry) =>
1208
+ entry.scope === scope &&
1209
+ entry.record.envelope.contentHash === hash &&
1210
+ entry.record.envelope.id !== excludeId
1211
+ );
860
1212
  return match?.record;
861
1213
  }
862
1214
 
1215
+ /**
1216
+ * True when `incoming`'s caller-authored mutable metadata (`tags` / `provenance`)
1217
+ * canonically equals `existing`'s. `body` and `links` are covered by the content
1218
+ * hash; `embeddingRef` is store-derived (not caller metadata) and is deliberately
1219
+ * excluded so a same-content re-put is not treated as changed merely because the
1220
+ * store already stamped an embedding. Used to keep an identical re-put a no-op
1221
+ * without swallowing a genuine metadata revision.
1222
+ *
1223
+ * Canonicalization never fails for a validated record (`tags`/`provenance` are
1224
+ * always plain JSON); a failure is defaulted to a non-matching sentinel so the
1225
+ * write flows to `applyUpdate` (which re-validates) rather than silently
1226
+ * no-op-ing on an un-canonicalizable value.
1227
+ */
1228
+ private _isMutableMetadataUnchanged(
1229
+ existing: IMemoryRecord<unknown>,
1230
+ incoming: IMemoryRecord<unknown>
1231
+ ): boolean {
1232
+ const key = (record: IMemoryRecord<unknown>): string =>
1233
+ this._hasher
1234
+ .canonicalize({ tags: record.envelope.tags, provenance: record.envelope.provenance })
1235
+ .orDefault('');
1236
+ const existingKey: string = key(existing);
1237
+ return existingKey !== '' && existingKey === key(incoming);
1238
+ }
1239
+
863
1240
  private _contentHash(kind: Kind, body: string, links: IMemoryEnvelope['links']): Result<string> {
864
1241
  return this._hasher.computeHash({ kind, body, links });
865
1242
  }
@@ -902,7 +1279,15 @@ export class FileTreeMemoryStore implements IMemoryStore {
902
1279
  });
903
1280
  }
904
1281
 
905
- /** Enforce `envelope.id === filename stem` and the codec round-trip on load. */
1282
+ /**
1283
+ * Enforce `envelope.id === filename stem`, the codec round-trip, AND that the
1284
+ * envelope's own `entityId` agrees with the id decoded from the subtree scope.
1285
+ * The round-trip only validates (scope, stem) consistency; the codec derives
1286
+ * `entityId` from the scope path and never reads the envelope's `entityId`
1287
+ * field, so a tampered/corrupt file whose frontmatter declares a foreign
1288
+ * `entityId` would otherwise load undetected — and `entityId` is trusted
1289
+ * verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
1290
+ */
906
1291
  private _verifyLoaded(
907
1292
  scope: MemoryScopeKey,
908
1293
  file: FileTree.IFileTreeFileItem,
@@ -914,9 +1299,18 @@ export class FileTreeMemoryStore implements IMemoryStore {
914
1299
  );
915
1300
  }
916
1301
  return this._codecFor(record.envelope.kind)
917
- .onSuccess((codec) => codec.verifyRoundTrip(scope, file.baseName))
1302
+ .onSuccess((codec) =>
1303
+ codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName))
1304
+ )
918
1305
  .withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
919
- .onSuccess(() => succeed(record));
1306
+ .onSuccess((decodedEntityId) => {
1307
+ if (decodedEntityId !== record.envelope.entityId) {
1308
+ return fail(
1309
+ `memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`
1310
+ );
1311
+ }
1312
+ return succeed(record);
1313
+ });
920
1314
  }
921
1315
 
922
1316
  /**
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ export * from './memoryTools';