@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.
- package/.rush/temp/{f6a88bfdd66517ccb98c2c7ae1be6e6fe9e15d38.tar.log → b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log} +116 -2
- package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +4 -4
- package/.rush/temp/operation/build/all.log +4 -4
- package/.rush/temp/operation/build/log-chunks.jsonl +4 -4
- package/.rush/temp/operation/build/state.json +1 -1
- package/.rush/temp/shrinkwrap-deps.json +222 -221
- package/config/jest.config.json +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/packlets/ingest/cycleGuard.js +111 -0
- package/dist/packlets/ingest/cycleGuard.js.map +1 -0
- package/dist/packlets/ingest/hostStages.js +6 -0
- package/dist/packlets/ingest/hostStages.js.map +1 -0
- package/dist/packlets/ingest/index.js +9 -0
- package/dist/packlets/ingest/index.js.map +1 -0
- package/dist/packlets/ingest/model.js +6 -0
- package/dist/packlets/ingest/model.js.map +1 -0
- package/dist/packlets/ingest/orchestrator.js +438 -0
- package/dist/packlets/ingest/orchestrator.js.map +1 -0
- package/dist/packlets/retrieve/index.js +1 -0
- package/dist/packlets/retrieve/index.js.map +1 -1
- package/dist/packlets/retrieve/temporalRetrievers.js +172 -0
- package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -0
- package/dist/packlets/store/fileTreeMemoryStore.js +323 -41
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/tools/index.js +6 -0
- package/dist/packlets/tools/index.js.map +1 -0
- package/dist/packlets/tools/memoryTools.js +337 -0
- package/dist/packlets/tools/memoryTools.js.map +1 -0
- package/dist/packlets/types/identityCodec.js +115 -0
- package/dist/packlets/types/identityCodec.js.map +1 -1
- package/dist/packlets/types/index.js +1 -0
- package/dist/packlets/types/index.js.map +1 -1
- package/dist/packlets/types/temporal.js +85 -0
- package/dist/packlets/types/temporal.js.map +1 -0
- package/dist/packlets/types/writePolicy.js +96 -0
- package/dist/packlets/types/writePolicy.js.map +1 -1
- package/dist/test/unit/converters/antagonistRoundTrip.test.js +95 -0
- package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +273 -0
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
- package/dist/test/unit/ingest/cycleGuard.test.js +54 -0
- package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -0
- package/dist/test/unit/ingest/orchestrator.test.js +913 -0
- package/dist/test/unit/ingest/orchestrator.test.js.map +1 -0
- package/dist/test/unit/retrieve/temporalRetrievers.test.js +182 -0
- package/dist/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
- package/dist/test/unit/store/antagonistTemporalBoundary.test.js +120 -0
- package/dist/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
- package/dist/test/unit/store/fileTreeMemoryStore.test.js +74 -7
- package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/dist/test/unit/store/temporalStore.test.js +398 -0
- package/dist/test/unit/store/temporalStore.test.js.map +1 -0
- package/dist/test/unit/tools/memoryTools.test.js +572 -0
- package/dist/test/unit/tools/memoryTools.test.js.map +1 -0
- package/dist/test/unit/types/temporalCodec.test.js +203 -0
- package/dist/test/unit/types/temporalCodec.test.js.map +1 -0
- package/dist/test/unit/types/temporalPolicy.test.js +62 -0
- package/dist/test/unit/types/temporalPolicy.test.js.map +1 -0
- package/dist/ts-agent-memory.d.ts +1042 -10
- package/dist/tsdoc-metadata.json +1 -1
- package/etc/ts-agent-memory.api.md +284 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/packlets/ingest/cycleGuard.d.ts +41 -0
- package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -0
- package/lib/packlets/ingest/cycleGuard.js +115 -0
- package/lib/packlets/ingest/cycleGuard.js.map +1 -0
- package/lib/packlets/ingest/hostStages.d.ts +88 -0
- package/lib/packlets/ingest/hostStages.d.ts.map +1 -0
- package/lib/packlets/ingest/hostStages.js +7 -0
- package/lib/packlets/ingest/hostStages.js.map +1 -0
- package/lib/packlets/ingest/index.d.ts +5 -0
- package/lib/packlets/ingest/index.d.ts.map +1 -0
- package/lib/packlets/ingest/index.js +25 -0
- package/lib/packlets/ingest/index.js.map +1 -0
- package/lib/packlets/ingest/model.d.ts +177 -0
- package/lib/packlets/ingest/model.d.ts.map +1 -0
- package/lib/packlets/ingest/model.js +7 -0
- package/lib/packlets/ingest/model.js.map +1 -0
- package/lib/packlets/ingest/orchestrator.d.ts +206 -0
- package/lib/packlets/ingest/orchestrator.d.ts.map +1 -0
- package/lib/packlets/ingest/orchestrator.js +442 -0
- package/lib/packlets/ingest/orchestrator.js.map +1 -0
- package/lib/packlets/retrieve/index.d.ts +1 -0
- package/lib/packlets/retrieve/index.d.ts.map +1 -1
- package/lib/packlets/retrieve/index.js +1 -0
- package/lib/packlets/retrieve/index.js.map +1 -1
- package/lib/packlets/retrieve/temporalRetrievers.d.ts +78 -0
- package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -0
- package/lib/packlets/retrieve/temporalRetrievers.js +178 -0
- package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -0
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +118 -9
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +322 -40
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/tools/index.d.ts +2 -0
- package/lib/packlets/tools/index.d.ts.map +1 -0
- package/lib/packlets/tools/index.js +22 -0
- package/lib/packlets/tools/index.js.map +1 -0
- package/lib/packlets/tools/memoryTools.d.ts +139 -0
- package/lib/packlets/tools/memoryTools.d.ts.map +1 -0
- package/lib/packlets/tools/memoryTools.js +341 -0
- package/lib/packlets/tools/memoryTools.js.map +1 -0
- package/lib/packlets/types/identityCodec.d.ts +86 -0
- package/lib/packlets/types/identityCodec.d.ts.map +1 -1
- package/lib/packlets/types/identityCodec.js +118 -1
- package/lib/packlets/types/identityCodec.js.map +1 -1
- package/lib/packlets/types/index.d.ts +1 -0
- package/lib/packlets/types/index.d.ts.map +1 -1
- package/lib/packlets/types/index.js +1 -0
- package/lib/packlets/types/index.js.map +1 -1
- package/lib/packlets/types/temporal.d.ts +40 -0
- package/lib/packlets/types/temporal.d.ts.map +1 -0
- package/lib/packlets/types/temporal.js +92 -0
- package/lib/packlets/types/temporal.js.map +1 -0
- package/lib/packlets/types/writePolicy.d.ts +49 -0
- package/lib/packlets/types/writePolicy.d.ts.map +1 -1
- package/lib/packlets/types/writePolicy.js +98 -1
- package/lib/packlets/types/writePolicy.js.map +1 -1
- package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts +10 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts.map +1 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.js +97 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts +9 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +275 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
- package/lib/test/unit/ingest/cycleGuard.test.d.ts +2 -0
- package/lib/test/unit/ingest/cycleGuard.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/cycleGuard.test.js +56 -0
- package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.d.ts +2 -0
- package/lib/test/unit/ingest/orchestrator.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.js +915 -0
- package/lib/test/unit/ingest/orchestrator.test.js.map +1 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts +2 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts.map +1 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.js +184 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts +9 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts.map +1 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.js +122 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.js +74 -7
- package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/lib/test/unit/store/temporalStore.test.d.ts +2 -0
- package/lib/test/unit/store/temporalStore.test.d.ts.map +1 -0
- package/lib/test/unit/store/temporalStore.test.js +400 -0
- package/lib/test/unit/store/temporalStore.test.js.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.d.ts +2 -0
- package/lib/test/unit/tools/memoryTools.test.d.ts.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.js +574 -0
- package/lib/test/unit/tools/memoryTools.test.js.map +1 -0
- package/lib/test/unit/types/temporalCodec.test.d.ts +2 -0
- package/lib/test/unit/types/temporalCodec.test.d.ts.map +1 -0
- package/lib/test/unit/types/temporalCodec.test.js +205 -0
- package/lib/test/unit/types/temporalCodec.test.js.map +1 -0
- package/lib/test/unit/types/temporalPolicy.test.d.ts +2 -0
- package/lib/test/unit/types/temporalPolicy.test.d.ts.map +1 -0
- package/lib/test/unit/types/temporalPolicy.test.js +64 -0
- package/lib/test/unit/types/temporalPolicy.test.js.map +1 -0
- package/package.json +19 -19
- package/rush-logs/ts-agent-memory.build.cache.log +1 -1
- package/rush-logs/ts-agent-memory.build.log +4 -4
- package/src/index.ts +2 -0
- package/src/packlets/ingest/cycleGuard.ts +142 -0
- package/src/packlets/ingest/hostStages.ts +111 -0
- package/src/packlets/ingest/index.ts +9 -0
- package/src/packlets/ingest/model.ts +184 -0
- package/src/packlets/ingest/orchestrator.ts +797 -0
- package/src/packlets/retrieve/index.ts +1 -0
- package/src/packlets/retrieve/temporalRetrievers.ts +210 -0
- package/src/packlets/store/fileTreeMemoryStore.ts +460 -66
- package/src/packlets/tools/index.ts +6 -0
- package/src/packlets/tools/memoryTools.ts +579 -0
- package/src/packlets/types/identityCodec.ts +184 -0
- package/src/packlets/types/index.ts +1 -0
- package/src/packlets/types/temporal.ts +96 -0
- package/src/packlets/types/writePolicy.ts +127 -0
- package/src/test/unit/converters/antagonistRoundTrip.test.ts +110 -0
- package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +362 -0
- package/src/test/unit/ingest/cycleGuard.test.ts +68 -0
- package/src/test/unit/ingest/orchestrator.test.ts +1158 -0
- package/src/test/unit/retrieve/temporalRetrievers.test.ts +226 -0
- package/src/test/unit/store/antagonistTemporalBoundary.test.ts +158 -0
- package/src/test/unit/store/fileTreeMemoryStore.test.ts +98 -7
- package/src/test/unit/store/temporalStore.test.ts +469 -0
- package/src/test/unit/tools/memoryTools.test.ts +771 -0
- package/src/test/unit/types/temporalCodec.test.ts +259 -0
- package/src/test/unit/types/temporalPolicy.test.ts +96 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +122 -46
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +11984 -6314
- 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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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
|
-
.
|
|
282
|
+
.thenOnSuccess((codec) => codec.encode(envelope.entityId).thenOnSuccess((addr) => {
|
|
236
283
|
if (addr.isVersioned) {
|
|
237
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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.
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
/**
|
|
516
|
-
|
|
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 &&
|
|
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
|
-
/**
|
|
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(() =>
|
|
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
|