@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { Hash, Logging, fail, mapResults, succeed } from '@fgv/ts-utils';
6
6
  import { FileTree } from '@fgv/ts-json-base';
7
- import { DEFAULT_DEDUP_SCOPE, KnowledgeLwwPolicy } from '../types';
7
+ import { DEFAULT_DEDUP_SCOPE, KnowledgeLwwPolicy, isTemporalIdentityCodec, isTemporalRecord, isVersionCurrent, selectCurrentVersion, selectVersionAsOf } from '../types';
8
8
  import { parseMemoryFile, serializeMemoryFile } from '../converters';
9
9
  import { MemoryIndex } from '../index';
10
10
  import { defaultMemoryScopeEncoding } from './scopeEncoding';
@@ -17,9 +17,11 @@ const MEMORY_FILE_EXTENSION = '.md';
17
17
  * write-lock so the index and the on-disk files never interleave.
18
18
  *
19
19
  * @remarks
20
- * B1 supports flat (non-versioned) layout only and string (markdown) bodies.
21
- * A codec reporting `isVersioned: true`, or a non-string body, fails loudly —
22
- * the versioned/temporal write path is a fast-follow.
20
+ * Bodies are string (markdown). Both layouts are supported: flat
21
+ * (one-file-per-entity, non-versioned) and versioned (subtree-per-entity,
22
+ * invalidate-don't-delete), dispatched per kind by the codec's `isVersioned`
23
+ * flag. A kind is flat with zero behavioral impact unless it opts into a
24
+ * {@link ITemporalIdentityCodec}.
23
25
  * @public
24
26
  */
25
27
  export class FileTreeMemoryStore {
@@ -73,7 +75,14 @@ export class FileTreeMemoryStore {
73
75
  var _a;
74
76
  const result = this._codecFor(kind).onSuccess((codec) => codec.encode(entityId).onSuccess((addr) => {
75
77
  if (addr.isVersioned) {
76
- return fail(`memory get '${entityId}': versioned/temporal layout not yet supported`);
78
+ if (!isTemporalIdentityCodec(codec)) {
79
+ return fail(`memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`);
80
+ }
81
+ // Versioned keyed read resolves the CURRENT version (highest-seq
82
+ // version whose `invalid_at` is null/absent) from the entity subtree,
83
+ // read off the derived index. `asOf` resolution is via `list({ asOf })`
84
+ // and the temporal retrievers.
85
+ return succeed(this._readVersionedCurrent(addr.scope));
77
86
  }
78
87
  return this._readRecord(addr.scope, addr.idStem);
79
88
  }));
@@ -105,7 +114,45 @@ export class FileTreeMemoryStore {
105
114
  return true;
106
115
  })
107
116
  .map((entry) => entry.record);
108
- return succeed(matches);
117
+ if ((filter === null || filter === void 0 ? void 0 : filter.asOf) === undefined) {
118
+ // No temporal projection requested: byte-identical to the pre-temporal
119
+ // behavior (the flat-path guarantee — every version is returned).
120
+ return succeed(matches);
121
+ }
122
+ return succeed(FileTreeMemoryStore._projectAsOf(matches, filter.asOf));
123
+ }
124
+ /**
125
+ * Collapse temporal records to the single version valid at `asOf` per entity;
126
+ * non-temporal records are timeless and pass through unchanged (valid-time
127
+ * `asOf` applies only to versioned kinds; transaction-time / full bi-temporal
128
+ * filtering is deferred — OQ-9). An entity with no version valid at `asOf`
129
+ * contributes nothing.
130
+ */
131
+ static _projectAsOf(records, asOf) {
132
+ const passthrough = [];
133
+ const groups = new Map();
134
+ for (const record of records) {
135
+ if (!isTemporalRecord(record)) {
136
+ passthrough.push(record);
137
+ continue;
138
+ }
139
+ const key = `${record.envelope.kind}\0${record.envelope.entityId}`;
140
+ const existing = groups.get(key);
141
+ if (existing === undefined) {
142
+ groups.set(key, [record]);
143
+ }
144
+ else {
145
+ existing.push(record);
146
+ }
147
+ }
148
+ const result = [...passthrough];
149
+ for (const versions of groups.values()) {
150
+ const valid = selectVersionAsOf(versions, asOf);
151
+ if (valid !== undefined) {
152
+ result.push(valid);
153
+ }
154
+ }
155
+ return result;
109
156
  }
110
157
  /** {@inheritDoc IMemoryStore.put} */
111
158
  async put(record) {
@@ -224,24 +271,26 @@ export class FileTreeMemoryStore {
224
271
  return fail(`memory put '${envelope.id}': only string (markdown) bodies are supported (got ${typeof record.body})`);
225
272
  }
226
273
  const body = record.body;
227
- // Resolve the synchronous prefix (body validation codec address content
228
- // hash) into a small descriptor, then bridge to the async write tail (which
229
- // embeds on write). Keeping the sync prefix intact preserves the exact
230
- // dedup/policy ordering of the original; only the embed step is new.
274
+ // Resolve the body converter + codec, then dispatch on layout. The flat
275
+ // (non-versioned) path keeps its exact dedup/policy ordering; the versioned
276
+ // path is a wholly separate branch so the flat path is behaviorally
277
+ // unchanged (the consumer adoption guarantee).
231
278
  return this._registry
232
279
  .convert(envelope.kind, body)
233
280
  .withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
234
281
  .onSuccess(() => this._codecFor(envelope.kind))
235
- .onSuccess((codec) => codec.encode(envelope.entityId).onSuccess((addr) => {
282
+ .thenOnSuccess((codec) => codec.encode(envelope.entityId).thenOnSuccess((addr) => {
236
283
  if (addr.isVersioned) {
237
- return fail(`memory put '${envelope.entityId}': versioned/temporal layout not yet supported`);
284
+ if (!isTemporalIdentityCodec(codec)) {
285
+ return Promise.resolve(fail(`memory put '${envelope.entityId}': codec for versioned kind '${envelope.kind}' does not implement the temporal codec interface`));
286
+ }
287
+ return this._putVersioned(record, body, codec, addr.scope);
238
288
  }
239
289
  if (envelope.id !== addr.idStem) {
240
- return fail(`memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`);
290
+ return Promise.resolve(fail(`memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`));
241
291
  }
242
- return this._contentHash(envelope.kind, body, envelope.links).onSuccess((hash) => succeed({ scope: addr.scope, idStem: addr.idStem, hash }));
243
- }))
244
- .thenOnSuccess((resolved) => this._writeResolved(record, body, resolved.scope, resolved.idStem, resolved.hash));
292
+ return this._contentHash(envelope.kind, body, envelope.links).thenOnSuccess((hash) => this._writeResolved(record, body, addr.scope, addr.idStem, hash));
293
+ }));
245
294
  }
246
295
  /**
247
296
  * Run dedup → policy → stamp → embed → write for a resolved address and content
@@ -263,14 +312,26 @@ export class FileTreeMemoryStore {
263
312
  // (Tags / provenance are metadata and are NOT part of the hash; see
264
313
  // design-lock §2.5.)
265
314
  if (dedupScope === 'content') {
266
- const duplicate = this._findByContentHash(scope, hash);
315
+ // Cross-id content collapse: an identical { kind, body, links } triple under
316
+ // a DIFFERENT id is a no-op (knowledge family). A same-id match is excluded
317
+ // here and falls through to the LWW path below so a metadata-only revision
318
+ // (tags / provenance — outside the content hash but inside the policy's
319
+ // mutableFields) actually applies. Content-dedup must never shadow LWW for
320
+ // the same entity.
321
+ const duplicate = this._findByContentHash(scope, hash, record.envelope.id);
267
322
  if (duplicate !== undefined) {
268
323
  return succeed({ record: duplicate, evicted: [] });
269
324
  }
270
325
  }
271
326
  return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
272
- if (dedupScope === 'entity' && existing !== undefined && existing.envelope.contentHash === hash) {
273
- // Entity-scoped dedup: an identical re-put of the same entity is a no-op.
327
+ // Same-id re-put is a no-op ONLY when the content hash matches AND the
328
+ // mutable metadata is also unchanged. The content hash covers
329
+ // { kind, body, links }; a matching hash with revised tags/provenance is a
330
+ // real update that must reach applyUpdate, not be swallowed as a duplicate
331
+ // (both dedup scopes).
332
+ if (existing !== undefined &&
333
+ existing.envelope.contentHash === hash &&
334
+ this._isMutableMetadataUnchanged(existing, record)) {
274
335
  return Promise.resolve(succeed({ record: existing, evicted: [] }));
275
336
  }
276
337
  // The admission cohort is the set of records the policy's cap applies to:
@@ -451,28 +512,210 @@ export class FileTreeMemoryStore {
451
512
  .onSuccess(() => succeed(record));
452
513
  }
453
514
  async _deleteLocked(kind, entityId) {
454
- return this._codecFor(kind)
455
- .onSuccess((codec) => codec.encode(entityId))
456
- .thenOnSuccess((addr) => {
515
+ return this._codecFor(kind).thenOnSuccess((codec) => codec.encode(entityId).thenOnSuccess((addr) => {
457
516
  if (addr.isVersioned) {
458
- return Promise.resolve(fail(`memory delete '${entityId}': versioned/temporal layout not yet supported`));
517
+ if (!isTemporalIdentityCodec(codec)) {
518
+ return Promise.resolve(fail(`memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`));
519
+ }
520
+ return this._deleteVersioned(entityId, addr.scope);
459
521
  }
460
- return this._readRecord(addr.scope, addr.idStem).thenOnSuccess((existing) => {
461
- if (existing === undefined) {
462
- return Promise.resolve(fail(`memory delete '${entityId}': no record found`));
522
+ return this._deleteFlat(entityId, addr.scope, addr.idStem);
523
+ }));
524
+ }
525
+ /**
526
+ * Flat (non-versioned) delete: physically remove the record file + index
527
+ * entry, then prune the vector best-effort. Structurally unchanged from the
528
+ * pre-temporal delete path.
529
+ */
530
+ async _deleteFlat(entityId, scope, idStem) {
531
+ return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
532
+ if (existing === undefined) {
533
+ return Promise.resolve(fail(`memory delete '${entityId}': no record found`));
534
+ }
535
+ // Delete the record file + index entry (authoritative), then prune the
536
+ // vector best-effort: a committed delete must not fail because the
537
+ // derived index could not be pruned.
538
+ return this._deleteFile(scope, idStem)
539
+ .onSuccess(() => this._index.patch('delete', { scope, record: existing }))
540
+ .thenOnSuccess(async () => {
541
+ await this._removeVectorBestEffort(existing.envelope.id);
542
+ return succeed(existing.envelope.id);
543
+ });
544
+ });
545
+ }
546
+ /**
547
+ * Resolve the current version of a temporal entity from the derived index: the
548
+ * highest-`seq` version under the entity subtree `scope` whose `invalid_at` is
549
+ * null/absent. `undefined` when the entity has no current version (never
550
+ * written, or fully invalidated / soft-deleted).
551
+ */
552
+ _readVersionedCurrent(scope) {
553
+ return selectCurrentVersion(this._versionsForEntity(scope));
554
+ }
555
+ /**
556
+ * Every persisted version of the entity whose subtree is `scope`. All version
557
+ * files for one entity live under exactly that scope (which encodes the
558
+ * entityId), so a scope filter over the index isolates one entity's versions.
559
+ */
560
+ _versionsForEntity(scope) {
561
+ return this._index
562
+ .entries()
563
+ .filter((entry) => entry.scope === scope)
564
+ .map((entry) => entry.record);
565
+ }
566
+ /**
567
+ * Versioned write (invalidate-don't-delete). Builds the new version's content
568
+ * (a first version from the incoming record, or a merge of the incoming patch
569
+ * over the current version), persists it as a NEW version file, then sets
570
+ * `invalid_at` on the prior current version.
571
+ *
572
+ * Durability order: the new version is persisted FIRST. It carries the highest
573
+ * `seq`, so a crash before the prior-version invalidation completes still
574
+ * resolves the new version as current (`selectCurrentVersion` breaks a
575
+ * two-current tie by highest `seq`), and `asOf` reads stay correct because each
576
+ * version's `valid_at` lower-bounds its interval.
577
+ */
578
+ async _putVersioned(record, body, codec, scope) {
579
+ var _a;
580
+ const envelope = record.envelope;
581
+ const entityId = envelope.entityId;
582
+ const kind = envelope.kind;
583
+ // Snapshot the entity's versions BEFORE the write. `priorCurrents` is every
584
+ // still-current version at snapshot time — normally one, but two-or-more if a
585
+ // prior invalidation partially failed; invalidating all of them lets the write
586
+ // self-heal a stuck state (P2-7).
587
+ const versions = this._versionsForEntity(scope);
588
+ const priorCurrents = versions.filter(isVersionCurrent);
589
+ const current = selectCurrentVersion(versions);
590
+ const policy = this._policyFor(kind);
591
+ const dedupScope = (_a = policy.dedupScope) !== null && _a !== void 0 ? _a : DEFAULT_DEDUP_SCOPE;
592
+ return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
593
+ // Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
594
+ // its mutable metadata are unchanged (does not spawn a redundant version).
595
+ // A metadata-only revision (tags/provenance — declared mutable by
596
+ // TemporalVersionedPolicy) must NOT be swallowed here: it mints a new
597
+ // version via the applyUpdate merge in `_buildVersionedRecord`, mirroring
598
+ // the flat path. Content-scoped dedup is not a versioning concern, so only
599
+ // the entity granularity is honored here.
600
+ if (dedupScope === 'entity' &&
601
+ current !== undefined &&
602
+ current.envelope.contentHash === hash &&
603
+ this._isMutableMetadataUnchanged(current, record)) {
604
+ return Promise.resolve(succeed({ record: current, evicted: [] }));
605
+ }
606
+ // On the versioned path the admission cohort is the entity's ENTIRE version
607
+ // history (no target id to exclude — the new version does not exist yet),
608
+ // which differs from the flat path's "cohort excluding target id" shape.
609
+ return policy.admit(record, versions).thenOnSuccess((decision) => {
610
+ var _a, _b;
611
+ if (decision.decision === 'reject') {
612
+ return Promise.resolve(fail(`memory put: rejected by policy: ${decision.reason}`));
463
613
  }
464
- // Delete the record file + index entry (authoritative), then prune the
465
- // vector best-effort: a committed delete must not fail because the
466
- // derived index could not be pruned.
467
- return this._deleteFile(addr.scope, addr.idStem)
468
- .onSuccess(() => this._index.patch('delete', { scope: addr.scope, record: existing }))
469
- .thenOnSuccess(async () => {
470
- await this._removeVectorBestEffort(existing.envelope.id);
471
- return succeed(existing.envelope.id);
472
- });
614
+ // No culling on the versioned path history is retained (invalidate-don't-delete).
615
+ const now = this._clock();
616
+ const seq = ++this._seq;
617
+ // World-truth start of the new version. Each prior current version's
618
+ // world-truth interval CLOSES at this same instant (not at `now`) so a
619
+ // backdated/future-dated `valid_at` leaves no gap or overlap on the
620
+ // valid-time axis.
621
+ const validAt = (_b = (_a = envelope.temporal) === null || _a === void 0 ? void 0 : _a.valid_at) !== null && _b !== void 0 ? _b : now;
622
+ return codec
623
+ .encodeVersion(entityId, seq)
624
+ .withErrorFormat((msg) => `memory put '${entityId}': ${msg}`)
625
+ .thenOnSuccess((versionStem) => this._buildVersionedRecord(record, body, current, policy, hash, versionStem, validAt, now, seq)
626
+ .thenOnSuccess((built) => this._embedOnWrite(built))
627
+ .onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, versionStem))
628
+ .onSuccess((persisted) => this._invalidateCurrents(scope, priorCurrents, validAt, now).onSuccess(() => succeed(persisted)))
629
+ .onSuccess((persisted) => succeed({ record: persisted, evicted: [] })));
630
+ });
631
+ });
632
+ }
633
+ /**
634
+ * Invalidate a set of still-current versions (close each world-truth interval at
635
+ * `invalidAt`; stamp transaction-time `updated` = `now`). Chained so a mid-list
636
+ * failure propagates. Invalidating every prior current — not just the
637
+ * highest-`seq` pick — self-heals a state where a previous write's invalidation
638
+ * only partially completed (P2-7).
639
+ */
640
+ _invalidateCurrents(scope, currents, invalidAt, now) {
641
+ return currents.reduce((acc, version) => acc.onSuccess(() => this._invalidateVersion(scope, version, invalidAt, now).onSuccess(() => succeed(true))), succeed(true));
642
+ }
643
+ /**
644
+ * Build the new version to persist. A first version takes the incoming
645
+ * content verbatim (its dedup `hash` is reused); a subsequent version projects
646
+ * the incoming record's mutable fields into a merge-patch and lets the policy
647
+ * merge them over the CURRENT version (the merge-patch-under-versioning
648
+ * contract), recomputing the content hash from the policy's output. Each
649
+ * version is its own record with its own transaction time (`created` = `now`)
650
+ * and a store-minted `id` = the version stem, so `id === filename stem` holds.
651
+ */
652
+ _buildVersionedRecord(incoming, body, current, policy,
653
+ // Reused only for the first-version branch (its content is taken verbatim); a
654
+ // subsequent version recomputes its hash from the merge-patched content.
655
+ firstVersionHash, versionStem, validAt, now, seq) {
656
+ const mintedId = versionStem;
657
+ if (current === undefined) {
658
+ const envelope = Object.assign(Object.assign({}, incoming.envelope), { id: mintedId, entityId: incoming.envelope.entityId, created: now, updated: now, seq, contentHash: firstVersionHash, temporal: { valid_at: validAt } });
659
+ return succeed({ envelope, body });
660
+ }
661
+ const patch = this._projectMutablePatch(incoming, policy.mutableFields);
662
+ return policy
663
+ .applyUpdate(current, patch)
664
+ .withErrorFormat((msg) => `memory put '${incoming.envelope.entityId}': update failed: ${msg}`)
665
+ .onSuccess((updated) => {
666
+ if (typeof updated.body !== 'string') {
667
+ return fail(`memory put '${incoming.envelope.entityId}': policy returned a non-string body (${typeof updated.body})`);
668
+ }
669
+ const finalBody = updated.body;
670
+ return this._contentHash(updated.envelope.kind, finalBody, updated.envelope.links).onSuccess((finalHash) => {
671
+ const envelope = Object.assign(Object.assign({}, updated.envelope), { id: mintedId, entityId: incoming.envelope.entityId, created: now, updated: now, seq, contentHash: finalHash, temporal: { valid_at: validAt } });
672
+ return succeed({ envelope, body: finalBody });
473
673
  });
474
674
  });
475
675
  }
676
+ /**
677
+ * Set `invalid_at` on a prior current version (invalidate-don't-delete) and
678
+ * rewrite its file + index entry. The content hash is unchanged — `invalid_at`
679
+ * is temporal metadata, not part of `{ kind, body, links }` — so the version's
680
+ * identity is stable.
681
+ */
682
+ _invalidateVersion(scope, version, invalidAt, now) {
683
+ /* c8 ignore start -- defensive: every persisted version carries a string body (only string bodies are written) */
684
+ if (typeof version.body !== 'string') {
685
+ return fail(`memory put: cannot invalidate version '${version.envelope.id}': non-string body`);
686
+ }
687
+ /* c8 ignore stop */
688
+ // `invalid_at` is the WORLD-TRUTH close of the interval (the superseding version's
689
+ // `valid_at`, or the delete instant); `updated` is the transaction-time stamp.
690
+ const invalidated = {
691
+ envelope: Object.assign(Object.assign({}, version.envelope), { updated: now, temporal: Object.assign(Object.assign({}, version.envelope.temporal), { invalid_at: invalidAt }) }),
692
+ body: version.body
693
+ };
694
+ return this._persist(invalidated, scope, version.envelope.id);
695
+ }
696
+ /**
697
+ * Versioned delete: SOFT delete (invalidate-don't-delete). Sets `invalid_at` on
698
+ * the current version, leaving the entity's history intact and the entity with
699
+ * no current version. Returns the invalidated version's {@link MemoryId}. Fails
700
+ * with "no record found" when there is no current version — matching the flat
701
+ * delete's not-found semantics. History is retained deliberately: temporal
702
+ * kinds exist to preserve the audit trail (and the L3 `contradicts` interlock
703
+ * builds on it), so a hard delete would defeat the purpose.
704
+ */
705
+ async _deleteVersioned(entityId, scope) {
706
+ const versions = this._versionsForEntity(scope);
707
+ const currents = versions.filter(isVersionCurrent);
708
+ const current = selectCurrentVersion(versions);
709
+ if (current === undefined) {
710
+ return fail(`memory delete '${entityId}': no record found`);
711
+ }
712
+ // A delete closes the world-truth interval at the delete instant (`now` for both
713
+ // the transaction stamp and the `invalid_at` boundary). Every still-current
714
+ // version is invalidated (self-heals a stuck two-current state — P2-7); the
715
+ // highest-`seq` current's id is returned.
716
+ const now = this._clock();
717
+ return this._invalidateCurrents(scope, currents, now, now).onSuccess(() => succeed(current.envelope.id));
718
+ }
476
719
  /** Evict (physically delete) a single record file by id, patching the index. */
477
720
  _evict(scope, id) {
478
721
  return this._readRecord(scope, id).onSuccess((existing) => {
@@ -512,13 +755,39 @@ export class FileTreeMemoryStore {
512
755
  .filter((entry) => entry.scope === scope && entry.record.envelope.kind === kind && entry.record.envelope.id !== idStem)
513
756
  .map((entry) => entry.record);
514
757
  }
515
- /** Find a record in `scope` whose `contentHash` equals `hash`, if any. */
516
- _findByContentHash(scope, hash) {
758
+ /**
759
+ * Find a record in `scope` whose `contentHash` equals `hash`, if any,
760
+ * optionally excluding a specific id. `excludeId` lets the content-scoped
761
+ * dedup skip the same-id record so it does not shadow the LWW update path.
762
+ */
763
+ _findByContentHash(scope, hash, excludeId) {
517
764
  const match = this._index
518
765
  .entries()
519
- .find((entry) => entry.scope === scope && entry.record.envelope.contentHash === hash);
766
+ .find((entry) => entry.scope === scope &&
767
+ entry.record.envelope.contentHash === hash &&
768
+ entry.record.envelope.id !== excludeId);
520
769
  return match === null || match === void 0 ? void 0 : match.record;
521
770
  }
771
+ /**
772
+ * True when `incoming`'s caller-authored mutable metadata (`tags` / `provenance`)
773
+ * canonically equals `existing`'s. `body` and `links` are covered by the content
774
+ * hash; `embeddingRef` is store-derived (not caller metadata) and is deliberately
775
+ * excluded so a same-content re-put is not treated as changed merely because the
776
+ * store already stamped an embedding. Used to keep an identical re-put a no-op
777
+ * without swallowing a genuine metadata revision.
778
+ *
779
+ * Canonicalization never fails for a validated record (`tags`/`provenance` are
780
+ * always plain JSON); a failure is defaulted to a non-matching sentinel so the
781
+ * write flows to `applyUpdate` (which re-validates) rather than silently
782
+ * no-op-ing on an un-canonicalizable value.
783
+ */
784
+ _isMutableMetadataUnchanged(existing, incoming) {
785
+ const key = (record) => this._hasher
786
+ .canonicalize({ tags: record.envelope.tags, provenance: record.envelope.provenance })
787
+ .orDefault('');
788
+ const existingKey = key(existing);
789
+ return existingKey !== '' && existingKey === key(incoming);
790
+ }
522
791
  _contentHash(kind, body, links) {
523
792
  return this._hasher.computeHash({ kind, body, links });
524
793
  }
@@ -557,15 +826,28 @@ export class FileTreeMemoryStore {
557
826
  });
558
827
  });
559
828
  }
560
- /** Enforce `envelope.id === filename stem` and the codec round-trip on load. */
829
+ /**
830
+ * Enforce `envelope.id === filename stem`, the codec round-trip, AND that the
831
+ * envelope's own `entityId` agrees with the id decoded from the subtree scope.
832
+ * The round-trip only validates (scope, stem) consistency; the codec derives
833
+ * `entityId` from the scope path and never reads the envelope's `entityId`
834
+ * field, so a tampered/corrupt file whose frontmatter declares a foreign
835
+ * `entityId` would otherwise load undetected — and `entityId` is trusted
836
+ * verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
837
+ */
561
838
  _verifyLoaded(scope, file, record) {
562
839
  if (record.envelope.id !== file.baseName) {
563
840
  return fail(`memory file '${file.absolutePath}': envelope id '${record.envelope.id}' does not match filename stem '${file.baseName}'`);
564
841
  }
565
842
  return this._codecFor(record.envelope.kind)
566
- .onSuccess((codec) => codec.verifyRoundTrip(scope, file.baseName))
843
+ .onSuccess((codec) => codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName)))
567
844
  .withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
568
- .onSuccess(() => succeed(record));
845
+ .onSuccess((decodedEntityId) => {
846
+ if (decodedEntityId !== record.envelope.entityId) {
847
+ return fail(`memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`);
848
+ }
849
+ return succeed(record);
850
+ });
569
851
  }
570
852
  /**
571
853
  * Resolve the directory for a scope, returning `undefined` when it does not