@fgv/ts-agent-memory 5.1.0-37 → 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/{6dfc331ddf74dc2db0d4483d0c96ea971f351c83.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 +7 -7
  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 +85 -9
  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
@@ -51,6 +51,190 @@ export interface IIdentityCodec {
51
51
  }
52
52
 
53
53
  /**
54
+ * The `(entityId, version seq)` an {@link ITemporalIdentityCodec.decodeVersion}
55
+ * recovers from a version file address.
56
+ * @public
57
+ */
58
+ export interface ITemporalVersionAddress {
59
+ /** The stable consumer-supplied domain key. */
60
+ readonly entityId: EntityId;
61
+ /** The version's monotonic `seq` (the `v<seq>` component of the file stem). */
62
+ readonly seq: number;
63
+ }
64
+
65
+ /**
66
+ * Additive extension of {@link IIdentityCodec} for versioned (temporal) kinds. A
67
+ * codec whose {@link IIdentityCodec.encode | encode} reports `isVersioned: true`
68
+ * implements this so the store can form and parse per-version filenames without
69
+ * knowing the layout. Non-versioned codecs do NOT implement it (probe with
70
+ * {@link isTemporalIdentityCodec}).
71
+ * @public
72
+ */
73
+ export interface ITemporalIdentityCodec extends IIdentityCodec {
74
+ /**
75
+ * Form the version filename stem for a specific `(entityId, seq)`. The store
76
+ * appends the extension and writes it under the entity subtree returned by
77
+ * {@link IIdentityCodec.encode | encode}.
78
+ */
79
+ encodeVersion(entityId: EntityId, seq: number): Result<string>;
80
+
81
+ /**
82
+ * Parse a `(subtree scope, version stem)` back to its
83
+ * {@link ITemporalVersionAddress}. The subtree scope is authoritative for the
84
+ * `entityId`, disambiguating a stem whose `entityId` itself ends in
85
+ * `-v<digits>`.
86
+ */
87
+ decodeVersion(scope: MemoryScopeKey, stem: string): Result<ITemporalVersionAddress>;
88
+ }
89
+
90
+ /**
91
+ * Narrow an {@link IIdentityCodec} to {@link ITemporalIdentityCodec} by probing
92
+ * for the versioned methods. Used by the store when an `encode` result reports
93
+ * `isVersioned: true`.
94
+ * @public
95
+ */
96
+ export function isTemporalIdentityCodec(codec: IIdentityCodec): codec is ITemporalIdentityCodec {
97
+ const candidate: Partial<ITemporalIdentityCodec> = codec as Partial<ITemporalIdentityCodec>;
98
+ return typeof candidate.encodeVersion === 'function' && typeof candidate.decodeVersion === 'function';
99
+ }
100
+
101
+ /**
102
+ * Identity codec for a versioned (temporal) kind family, resolving OQ-11 to the
103
+ * subtree-per-entity layout: every version of an entity is a distinct file under
104
+ * a per-entity subtree.
105
+ *
106
+ * @remarks
107
+ * - `encode`: scope = `<baseScope>/entities/<entityId>`, idStem = `<entityId>`,
108
+ * `isVersioned = true`. The idStem is the stable entity prefix; the store forms
109
+ * each version's filename via {@link TemporalIdentityCodec.encodeVersion}.
110
+ * - `encodeVersion`: version stem = `<entityId>-v<seq>`.
111
+ * - `decode` / `decodeVersion`: recover `entityId` (and `seq`) from a
112
+ * `(subtree scope, version stem)` pair; the scope is authoritative for the
113
+ * `entityId`.
114
+ * - Escaping: `baseScope` and `entityId` must each match the POSIX portable
115
+ * filename set (they become path segments); `seq` is a non-negative integer.
116
+ * - Layout: `vault/<baseScope>/entities/<entityId>/<entityId>-v<seq>.md`.
117
+ * @public
118
+ */
119
+ export class TemporalIdentityCodec implements ITemporalIdentityCodec {
120
+ /** The fixed subtree segment separating an entity's versions from its scope. */
121
+ public static readonly entitiesSegment: string = 'entities';
122
+ /** The version-stem infix: `<entityId>` + this + `<seq>`. */
123
+ public static readonly versionInfix: string = '-v';
124
+
125
+ /** A non-negative integer string (the version seq). */
126
+ private static readonly _versionSeqRe: RegExp = /^\d+$/;
127
+
128
+ /** The base scope segment this codec's entities live under. */
129
+ public readonly baseScope: string;
130
+
131
+ private constructor(baseScope: string) {
132
+ this.baseScope = baseScope;
133
+ }
134
+
135
+ /**
136
+ * Family-convention factory. Validates that `baseScope` is a single portable
137
+ * filename segment (it becomes the top-level path component).
138
+ */
139
+ public static create(baseScope: string): Result<TemporalIdentityCodec> {
140
+ return assertPortableFilenameStem(baseScope)
141
+ .withErrorFormat((msg) => `temporal codec: baseScope '${baseScope}': ${msg}`)
142
+ .onSuccess(() => succeed(new TemporalIdentityCodec(baseScope)));
143
+ }
144
+
145
+ /** {@inheritDoc IIdentityCodec.encode} */
146
+ public encode(entityId: EntityId): Result<IIdentityCodecResult> {
147
+ return assertPortableFilenameStem(entityId)
148
+ .withErrorFormat((msg) => `temporal codec: entityId '${entityId}': ${msg}`)
149
+ .onSuccess((stem) =>
150
+ succeed({
151
+ scope: `${this.baseScope}/${TemporalIdentityCodec.entitiesSegment}/${stem}` as MemoryScopeKey,
152
+ idStem: stem,
153
+ isVersioned: true
154
+ })
155
+ );
156
+ }
157
+
158
+ /** {@inheritDoc ITemporalIdentityCodec.encodeVersion} */
159
+ public encodeVersion(entityId: EntityId, seq: number): Result<string> {
160
+ return assertPortableFilenameStem(entityId)
161
+ .withErrorFormat((msg) => `temporal codec: entityId '${entityId}': ${msg}`)
162
+ .onSuccess((stem) => {
163
+ if (!Number.isInteger(seq) || seq < 0) {
164
+ return fail(`temporal codec: version seq '${seq}' must be a non-negative integer`);
165
+ }
166
+ return succeed(`${stem}${TemporalIdentityCodec.versionInfix}${seq}`);
167
+ });
168
+ }
169
+
170
+ /** {@inheritDoc IIdentityCodec.decode} */
171
+ public decode(scope: MemoryScopeKey, encodedStem: string): Result<EntityId> {
172
+ return this.decodeVersion(scope, encodedStem).onSuccess((addr) =>
173
+ Convert.entityId.convert(addr.entityId)
174
+ );
175
+ }
176
+
177
+ /** {@inheritDoc ITemporalIdentityCodec.decodeVersion} */
178
+ public decodeVersion(scope: MemoryScopeKey, stem: string): Result<ITemporalVersionAddress> {
179
+ return this._entityIdFromScope(scope).onSuccess((entityId) => {
180
+ const prefix: string = `${entityId}${TemporalIdentityCodec.versionInfix}`;
181
+ if (!stem.startsWith(prefix)) {
182
+ return fail(
183
+ `temporal codec: version stem '${stem}' must begin with '${prefix}' (from scope '${scope}')`
184
+ );
185
+ }
186
+ const seqText: string = stem.slice(prefix.length);
187
+ if (!TemporalIdentityCodec._versionSeqRe.test(seqText)) {
188
+ return fail(`temporal codec: version stem '${stem}' has a non-integer version suffix '${seqText}'`);
189
+ }
190
+ // The regex admits digit strings of unbounded length; `parseInt` would silently
191
+ // lose precision past MAX_SAFE_INTEGER, decoding a corrupt/tampered filename to a
192
+ // plausible-but-wrong `seq` that drives version ordering. Reject rather than corrupt.
193
+ const seq: number = Number.parseInt(seqText, 10);
194
+ if (!Number.isSafeInteger(seq)) {
195
+ return fail(
196
+ `temporal codec: version stem '${stem}' has a version suffix '${seqText}' outside the safe integer range`
197
+ );
198
+ }
199
+ return Convert.entityId.convert(entityId).onSuccess((branded) => succeed({ entityId: branded, seq }));
200
+ });
201
+ }
202
+
203
+ /** {@inheritDoc IIdentityCodec.verifyRoundTrip} */
204
+ public verifyRoundTrip(scope: MemoryScopeKey, stem: string): Result<true> {
205
+ return this.decodeVersion(scope, stem).onSuccess((addr) =>
206
+ this.encode(addr.entityId).onSuccess((encoded) =>
207
+ this.encodeVersion(addr.entityId, addr.seq).onSuccess((reStem) => {
208
+ if (encoded.scope !== scope || reStem !== stem) {
209
+ return fail(
210
+ `temporal codec: round-trip mismatch for scope '${scope}' stem '${stem}' (re-encoded to scope '${encoded.scope}' stem '${reStem}')`
211
+ );
212
+ }
213
+ return succeed(true);
214
+ })
215
+ )
216
+ );
217
+ }
218
+
219
+ /** Validate and extract the `entityId` from a `<baseScope>/entities/<entityId>` scope. */
220
+ private _entityIdFromScope(scope: MemoryScopeKey): Result<string> {
221
+ const segments: string[] = scope.split('/');
222
+ if (
223
+ segments.length !== 3 ||
224
+ segments[0] !== this.baseScope ||
225
+ segments[1] !== TemporalIdentityCodec.entitiesSegment
226
+ ) {
227
+ return fail(
228
+ `temporal codec: scope '${scope}' must be '${this.baseScope}/${TemporalIdentityCodec.entitiesSegment}/<entityId>'`
229
+ );
230
+ }
231
+ const entityId: string = segments[2];
232
+ return assertPortableFilenameStem(entityId)
233
+ .withErrorFormat((msg) => `temporal codec: entityId '${entityId}': ${msg}`)
234
+ .onSuccess(() => succeed(entityId));
235
+ }
236
+ }
237
+
54
238
  /**
55
239
  * Identity codec for the knowledge kind family. A knowledge entity is keyed
56
240
  * by its consumer-supplied `docId`, which is used verbatim as the filename
@@ -7,4 +7,5 @@ export * from './ids';
7
7
  export * from './envelope';
8
8
  export * from './filenameSafety';
9
9
  export * from './identityCodec';
10
+ export * from './temporal';
10
11
  export * from './writePolicy';
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { IMemoryRecord } from './envelope';
7
+
8
+ /**
9
+ * Whether a record participates in the versioned (temporal) layout. A temporal
10
+ * record always carries a {@link ITemporalBlock | temporal} block (the store
11
+ * stamps `valid_at` on every versioned write); an atemporal record never does,
12
+ * so presence of `temporal` is the discriminator. Independent of `id`/`entityId`
13
+ * divergence (MTM is flat yet has `entityId !== id`).
14
+ * @public
15
+ */
16
+ export function isTemporalRecord(record: IMemoryRecord<unknown>): boolean {
17
+ return record.envelope.temporal !== undefined;
18
+ }
19
+
20
+ /**
21
+ * Whether a temporal record is a *current* version — its `temporal.invalid_at`
22
+ * is `null` or absent (the still-valid sentinel). A non-temporal record is never
23
+ * current in this sense (returns `false`).
24
+ * @public
25
+ */
26
+ export function isVersionCurrent(record: IMemoryRecord<unknown>): boolean {
27
+ const temporal: IMemoryRecord<unknown>['envelope']['temporal'] = record.envelope.temporal;
28
+ if (temporal === undefined) {
29
+ return false;
30
+ }
31
+ return temporal.invalid_at === null || temporal.invalid_at === undefined;
32
+ }
33
+
34
+ /**
35
+ * Whether a temporal record's validity interval contains `asOf` (epoch ms):
36
+ * `valid_at <= asOf` and (`invalid_at` is null/absent OR `asOf < invalid_at`).
37
+ * The version's `valid_at` defaults to its `created` when absent; a non-temporal
38
+ * record is never "valid at" a point (returns `false`).
39
+ * @public
40
+ */
41
+ export function isVersionValidAt(record: IMemoryRecord<unknown>, asOf: number): boolean {
42
+ const temporal: IMemoryRecord<unknown>['envelope']['temporal'] = record.envelope.temporal;
43
+ if (temporal === undefined) {
44
+ return false;
45
+ }
46
+ const start: number = temporal.valid_at ?? record.envelope.created;
47
+ if (start > asOf) {
48
+ return false;
49
+ }
50
+ const end: number | null | undefined = temporal.invalid_at;
51
+ if (end === null || end === undefined) {
52
+ return true;
53
+ }
54
+ return asOf < end;
55
+ }
56
+
57
+ /**
58
+ * The version with the highest `seq` among `candidates` (undefined when empty).
59
+ * `seq` is the store's monotonic write counter, so highest `seq` is the newest
60
+ * version. Shared tiebreak for {@link selectCurrentVersion} /
61
+ * {@link selectVersionAsOf}.
62
+ */
63
+ function highestSeq(candidates: ReadonlyArray<IMemoryRecord<unknown>>): IMemoryRecord<unknown> | undefined {
64
+ let best: IMemoryRecord<unknown> | undefined;
65
+ for (const candidate of candidates) {
66
+ if (best === undefined || candidate.envelope.seq > best.envelope.seq) {
67
+ best = candidate;
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+
73
+ /**
74
+ * Select the current version from a set of an entity's versions: the newest
75
+ * (highest `seq`) version whose `invalid_at` is null/absent. `undefined` when the
76
+ * entity has no current version (fully invalidated / soft-deleted, or empty).
77
+ * @public
78
+ */
79
+ export function selectCurrentVersion(
80
+ versions: ReadonlyArray<IMemoryRecord<unknown>>
81
+ ): IMemoryRecord<unknown> | undefined {
82
+ return highestSeq(versions.filter(isVersionCurrent));
83
+ }
84
+
85
+ /**
86
+ * Select the version of an entity valid at `asOf` (epoch ms): the newest
87
+ * (highest `seq`) version whose validity interval contains `asOf`. `undefined`
88
+ * when no version was valid at that instant.
89
+ * @public
90
+ */
91
+ export function selectVersionAsOf(
92
+ versions: ReadonlyArray<IMemoryRecord<unknown>>,
93
+ asOf: number
94
+ ): IMemoryRecord<unknown> | undefined {
95
+ return highestSeq(versions.filter((version) => isVersionValidAt(version, asOf)));
96
+ }
@@ -445,3 +445,130 @@ export class MemoryCapCullPolicy implements IWritePolicy {
445
445
  return succeed({ envelope, body: 'body' in merged ? merged.body : existing.body });
446
446
  }
447
447
  }
448
+
449
+ /**
450
+ * Write policy for a versioned (temporal) kind family, implementing
451
+ * invalidate-don't-delete. Admission always accepts — history is retained, never
452
+ * culled — and updates apply the same RFC-7386 merge patch as
453
+ * {@link KnowledgeLwwPolicy}, restricted to the temporal mutable surface.
454
+ *
455
+ * @remarks
456
+ * The policy does NOT perform the version file writes or set `invalid_at` — that
457
+ * is the store's versioned write branch, driven by the kind's
458
+ * {@link ITemporalIdentityCodec}. The policy's role is limited to admission and
459
+ * the merge that forms the **new version's** content from the **current**
460
+ * version plus the incoming patch (the merge-patch-under-versioning contract).
461
+ *
462
+ * - **Dedup scope.** `'entity'` — an identical re-put of the current content is a
463
+ * no-op (the store compares the incoming content hash against the current
464
+ * version), so identical writes do not spawn redundant versions.
465
+ * - **Mutable surface.** `body` + the envelope metadata a consumer may revise
466
+ * (`tags` / `links` / `provenance` / `embeddingRef`). `temporal` is NOT mutable
467
+ * here — `valid_at` / `invalid_at` are set by the store's versioned branch.
468
+ * @public
469
+ */
470
+ export class TemporalVersionedPolicy implements IWritePolicy {
471
+ /** The temporal mutable surface (mirrors {@link KnowledgeLwwPolicy}). */
472
+ public readonly mutableFields: ReadonlyArray<string> = [
473
+ 'body',
474
+ 'tags',
475
+ 'links',
476
+ 'provenance',
477
+ 'embeddingRef'
478
+ ];
479
+
480
+ /** Versioned kinds dedup per-entity against the current version (see the class remarks). */
481
+ public readonly dedupScope: DedupScope = 'entity';
482
+
483
+ /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */
484
+ private readonly _cloneEditor: JsonEditor;
485
+ /** Applies the RFC-7386 merge patch. */
486
+ private readonly _mergeEditor: JsonEditor;
487
+
488
+ private constructor(cloneEditor: JsonEditor, mergeEditor: JsonEditor) {
489
+ this._cloneEditor = cloneEditor;
490
+ this._mergeEditor = mergeEditor;
491
+ }
492
+
493
+ /**
494
+ * Family-convention factory. Constructs the shared `JsonEditor` instances (one
495
+ * for cloning, one for the RFC-7386 merge), rules disabled — the same merge
496
+ * config as the shipped policies.
497
+ */
498
+ public static create(): Result<TemporalVersionedPolicy> {
499
+ return JsonEditor.create({}, []).onSuccess((cloneEditor) =>
500
+ JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>
501
+ succeed(new TemporalVersionedPolicy(cloneEditor, mergeEditor))
502
+ )
503
+ );
504
+ }
505
+
506
+ /** {@inheritDoc IWritePolicy.admit} */
507
+ public admit(
508
+ __incoming: IMemoryRecord<unknown>,
509
+ __existing: ReadonlyArray<IMemoryRecord<unknown>>
510
+ ): Result<AdmissionDecision> {
511
+ // Invalidate-don't-delete: always accept. Superseded versions are retained
512
+ // (invalidated), never culled.
513
+ return succeed({ decision: 'accept' });
514
+ }
515
+
516
+ /** {@inheritDoc IWritePolicy.applyUpdate} */
517
+ public applyUpdate(
518
+ existing: IMemoryRecord<unknown>,
519
+ patch: Record<string, unknown>
520
+ ): Result<IMemoryRecord<unknown>> {
521
+ // Project the mutable fields into a single record-level view, each sourced
522
+ // from its canonical location. `embeddingRef` is omitted when `undefined`.
523
+ const view: Record<string, unknown> = {
524
+ body: existing.body,
525
+ tags: existing.envelope.tags,
526
+ links: existing.envelope.links,
527
+ provenance: existing.envelope.provenance
528
+ };
529
+ if (existing.envelope.embeddingRef !== undefined) {
530
+ view.embeddingRef = existing.envelope.embeddingRef;
531
+ }
532
+
533
+ // Restrict the incoming patch to the declared mutable fields.
534
+ const scopedPatch: Record<string, unknown> = {};
535
+ for (const field of this.mutableFields) {
536
+ if (field in patch) {
537
+ scopedPatch[field] = patch[field];
538
+ }
539
+ }
540
+
541
+ // Clone the view (no null-deletion), then apply the RFC-7386 merge patch onto
542
+ // the clone so the current version is never mutated in place.
543
+ return this._cloneEditor
544
+ .mergeObjectInPlace({}, view as JsonObject)
545
+ .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))
546
+ .onSuccess((merged) => this._rebuild(existing, merged));
547
+ }
548
+
549
+ /**
550
+ * Reassemble a record from the merged mutable view. `body` / `tags` / `links` /
551
+ * `provenance` are required and may not be deleted by a patch; `embeddingRef`,
552
+ * when dropped by the merge, is restored as `undefined` (absent) — the same
553
+ * hash-stable semantics as {@link KnowledgeLwwPolicy}.
554
+ */
555
+ private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {
556
+ const required: ReadonlyArray<string> = ['body', 'tags', 'links', 'provenance'];
557
+ const missing: ReadonlyArray<string> = required.filter((field) => !(field in merged));
558
+ if (missing.length > 0) {
559
+ return fail(`temporal versioned: merge patch may not delete required field(s): ${missing.join(', ')}`);
560
+ }
561
+
562
+ // The merged values are JSON projections of the already-validated typed
563
+ // record; restore the domain types (structural restorations, not fresh
564
+ // untrusted input — mirrors KnowledgeLwwPolicy._rebuild).
565
+ const envelope: IMemoryEnvelope = {
566
+ ...existing.envelope,
567
+ tags: merged.tags as unknown as ReadonlyArray<Tag>,
568
+ links: merged.links as unknown as ReadonlyArray<IEdge>,
569
+ provenance: merged.provenance as unknown as IProvenance,
570
+ embeddingRef: 'embeddingRef' in merged ? (merged.embeddingRef as string | null) : undefined
571
+ };
572
+ return succeed({ envelope, body: merged.body });
573
+ }
574
+ }
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ /**
7
+ * Antagonist torture test — convert/validate round-trip symmetry (target class
8
+ * 7): every OPTIONAL field the envelope/edge/provenance shapes carry must
9
+ * survive a full on-disk round-trip (serialize → YAML text → parse), not just a
10
+ * single `Converter.convert` pass in memory. This exercises the exact path a
11
+ * corrupted or field-dropping serializer/parser pair would break, mirroring the
12
+ * `aiClientToolConfig`/`annotations` field-drop class named in the brief.
13
+ */
14
+
15
+ import '@fgv/ts-utils-jest';
16
+ import { Converters } from '@fgv/ts-utils';
17
+ import {
18
+ BodyConverterRegistry,
19
+ IBodyConverterRegistry,
20
+ IMemoryEnvelope,
21
+ parseMemoryFile,
22
+ serializeMemoryFile
23
+ } from '../../../index';
24
+
25
+ const kind = 'note' as IMemoryEnvelope['kind'];
26
+
27
+ function registry(): IBodyConverterRegistry {
28
+ const reg = BodyConverterRegistry.create().orThrow();
29
+ reg.register(kind, Converters.string);
30
+ return reg;
31
+ }
32
+
33
+ describe('antagonist — full on-disk round-trip preserves every optional field', () => {
34
+ // Wrong impl this catches: a serializer/parser pair where one side silently
35
+ // drops an optional field (the exact class of bug the brief calls out for
36
+ // `aiClientToolConfig`/`annotations`-shaped converters) — e.g. omitting
37
+ // `temporal.invalid_at: null`, an edge's `valid_at`/`invalid_at`/`provenance`,
38
+ // a `null` `embeddingRef`, or a provenance extension key, because the author
39
+ // forgot to thread it through both the YAML emit AND the envelope Converter.
40
+ test('every optional envelope/edge/provenance field set simultaneously survives a full YAML round-trip', () => {
41
+ const envelope: IMemoryEnvelope = {
42
+ id: 'doc-1' as IMemoryEnvelope['id'],
43
+ entityId: 'doc-1' as IMemoryEnvelope['entityId'],
44
+ kind,
45
+ tags: ['t1', 't2'] as unknown as IMemoryEnvelope['tags'],
46
+ links: [
47
+ {
48
+ type: 'rel' as never,
49
+ target: 'doc-2' as never,
50
+ confidence: 0.42,
51
+ provenance: { source: 'agent', by: 'curator', extra: { nested: true } },
52
+ valid_at: 111,
53
+
54
+ invalid_at: null
55
+ }
56
+ ],
57
+ created: 1000,
58
+ updated: 2000,
59
+ seq: 7,
60
+ contentHash: 'abc123',
61
+ provenance: {
62
+ source: 'host-ingest',
63
+ by: 'erik',
64
+ model: 'gpt-5',
65
+ confidence: 0.87,
66
+ derivedFrom: 'turn-3' as never,
67
+ // Opaque extension keys (per IProvenance's `[key: string]: unknown` arm).
68
+ sentiment: { score: 0.5 },
69
+ epistemic: 'belief'
70
+ },
71
+
72
+ temporal: { valid_at: 500, invalid_at: null },
73
+
74
+ embeddingRef: null
75
+ };
76
+
77
+ const raw = serializeMemoryFile(envelope, 'the body text').orThrow();
78
+ expect(parseMemoryFile(raw, registry())).toSucceedAndSatisfy((record) => {
79
+ // Deep-equal the ENTIRE envelope, not field-by-field — a partial assertion
80
+ // list is exactly how a single dropped field slips through review.
81
+ expect(record.envelope).toEqual(envelope);
82
+ expect(record.body).toBe('the body text');
83
+ });
84
+ });
85
+
86
+ test('an absent temporal/embeddingRef/edge-optionals round-trips to fully absent (no null-vs-undefined drift)', () => {
87
+ const envelope: IMemoryEnvelope = {
88
+ id: 'doc-2' as IMemoryEnvelope['id'],
89
+ entityId: 'doc-2' as IMemoryEnvelope['entityId'],
90
+ kind,
91
+ tags: [],
92
+ links: [{ type: 'rel' as never, target: 'doc-3' as never }],
93
+ created: 0,
94
+ updated: 0,
95
+ seq: 0,
96
+ contentHash: '',
97
+ provenance: { source: 'agent' }
98
+ };
99
+ const raw = serializeMemoryFile(envelope, 'body').orThrow();
100
+ expect(parseMemoryFile(raw, registry())).toSucceedAndSatisfy((record) => {
101
+ expect(record.envelope.temporal).toBeUndefined();
102
+ expect(record.envelope.embeddingRef).toBeUndefined();
103
+ expect(record.envelope.links[0].confidence).toBeUndefined();
104
+ expect(record.envelope.links[0].provenance).toBeUndefined();
105
+ expect(record.envelope.links[0].valid_at).toBeUndefined();
106
+ expect(record.envelope.links[0].invalid_at).toBeUndefined();
107
+ expect(record.envelope).toEqual(envelope);
108
+ });
109
+ });
110
+ });