@ai2070/memex 0.9.0
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/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +35 -0
- package/API.md +1078 -0
- package/LICENSE +190 -0
- package/README.md +574 -0
- package/package.json +30 -0
- package/src/bulk.ts +128 -0
- package/src/envelope.ts +52 -0
- package/src/errors.ts +27 -0
- package/src/graph.ts +15 -0
- package/src/helpers.ts +51 -0
- package/src/index.ts +142 -0
- package/src/integrity.ts +378 -0
- package/src/intent.ts +311 -0
- package/src/query.ts +357 -0
- package/src/reducer.ts +177 -0
- package/src/replay.ts +32 -0
- package/src/retrieval.ts +306 -0
- package/src/serialization.ts +34 -0
- package/src/stats.ts +62 -0
- package/src/task.ts +373 -0
- package/src/transplant.ts +488 -0
- package/src/types.ts +248 -0
- package/tests/bugfix-and-coverage.test.ts +958 -0
- package/tests/bugfix-holes.test.ts +856 -0
- package/tests/bulk.test.ts +256 -0
- package/tests/edge-cases-v2.test.ts +355 -0
- package/tests/edge-cases.test.ts +661 -0
- package/tests/envelope.test.ts +92 -0
- package/tests/graph.test.ts +41 -0
- package/tests/helpers.test.ts +120 -0
- package/tests/integrity.test.ts +371 -0
- package/tests/intent.test.ts +276 -0
- package/tests/query-advanced.test.ts +252 -0
- package/tests/query.test.ts +623 -0
- package/tests/reducer.test.ts +342 -0
- package/tests/replay.test.ts +145 -0
- package/tests/retrieval.test.ts +691 -0
- package/tests/serialization.test.ts +118 -0
- package/tests/setup.test.ts +7 -0
- package/tests/stats.test.ts +163 -0
- package/tests/task.test.ts +322 -0
- package/tests/transplant.test.ts +385 -0
- package/tests/types.test.ts +231 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createGraphState } from "../src/graph.js";
|
|
3
|
+
import { applyCommand } from "../src/reducer.js";
|
|
4
|
+
import {
|
|
5
|
+
getEdges,
|
|
6
|
+
getItems,
|
|
7
|
+
getScoredItems,
|
|
8
|
+
extractTimestamp,
|
|
9
|
+
getRelatedItems,
|
|
10
|
+
} from "../src/query.js";
|
|
11
|
+
import {
|
|
12
|
+
filterContradictions,
|
|
13
|
+
surfaceContradictions,
|
|
14
|
+
smartRetrieve,
|
|
15
|
+
applyDiversity,
|
|
16
|
+
} from "../src/retrieval.js";
|
|
17
|
+
import {
|
|
18
|
+
markContradiction,
|
|
19
|
+
resolveContradiction,
|
|
20
|
+
getContradictions,
|
|
21
|
+
getItemsByBudget,
|
|
22
|
+
} from "../src/integrity.js";
|
|
23
|
+
import { applyMany, decayImportance } from "../src/bulk.js";
|
|
24
|
+
import {
|
|
25
|
+
createIntentState,
|
|
26
|
+
createIntent,
|
|
27
|
+
applyIntentCommand,
|
|
28
|
+
} from "../src/intent.js";
|
|
29
|
+
import {
|
|
30
|
+
createTaskState,
|
|
31
|
+
createTask,
|
|
32
|
+
applyTaskCommand,
|
|
33
|
+
} from "../src/task.js";
|
|
34
|
+
import { exportSlice, importSlice } from "../src/transplant.js";
|
|
35
|
+
import { replayFromEnvelopes } from "../src/replay.js";
|
|
36
|
+
import type { MemoryItem, Edge, GraphState, ScoredItem } from "../src/types.js";
|
|
37
|
+
import type { Intent } from "../src/intent.js";
|
|
38
|
+
import type { Task } from "../src/task.js";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
let counter = 0;
|
|
45
|
+
|
|
46
|
+
function fakeId(tsMs: number): string {
|
|
47
|
+
counter++;
|
|
48
|
+
const hex = tsMs.toString(16).padStart(12, "0");
|
|
49
|
+
const pad = counter.toString(16).padStart(20, "0");
|
|
50
|
+
return [
|
|
51
|
+
hex.slice(0, 8),
|
|
52
|
+
hex.slice(8, 12),
|
|
53
|
+
"7" + pad.slice(0, 3),
|
|
54
|
+
"8" + pad.slice(3, 6),
|
|
55
|
+
pad.slice(6, 18),
|
|
56
|
+
].join("-");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeItem(
|
|
60
|
+
id: string,
|
|
61
|
+
overrides: Partial<MemoryItem> = {},
|
|
62
|
+
): MemoryItem {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
scope: "test",
|
|
66
|
+
kind: "observation",
|
|
67
|
+
content: { text: `item ${id}` },
|
|
68
|
+
author: "agent:test",
|
|
69
|
+
source_kind: "observed",
|
|
70
|
+
authority: 0.8,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeEdge(
|
|
76
|
+
edgeId: string,
|
|
77
|
+
from: string,
|
|
78
|
+
to: string,
|
|
79
|
+
kind: Edge["kind"] = "SUPPORTS",
|
|
80
|
+
overrides: Partial<Edge> = {},
|
|
81
|
+
): Edge {
|
|
82
|
+
return {
|
|
83
|
+
edge_id: edgeId,
|
|
84
|
+
from,
|
|
85
|
+
to,
|
|
86
|
+
kind,
|
|
87
|
+
author: "agent:test",
|
|
88
|
+
source_kind: "derived_deterministic",
|
|
89
|
+
authority: 1,
|
|
90
|
+
active: true,
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stateWith(
|
|
96
|
+
items: MemoryItem[],
|
|
97
|
+
edges: Edge[] = [],
|
|
98
|
+
): GraphState {
|
|
99
|
+
let state = createGraphState();
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
state = applyCommand(state, { type: "memory.create", item }).state;
|
|
102
|
+
}
|
|
103
|
+
for (const edge of edges) {
|
|
104
|
+
state = applyCommand(state, { type: "edge.create", edge }).state;
|
|
105
|
+
}
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// =========================================================================
|
|
110
|
+
// 1. intent.update / task.update with undefined in partial
|
|
111
|
+
// =========================================================================
|
|
112
|
+
|
|
113
|
+
describe("intent.update strips undefined values", () => {
|
|
114
|
+
it("does not overwrite existing fields with undefined", () => {
|
|
115
|
+
let state = createIntentState();
|
|
116
|
+
const intent = createIntent({
|
|
117
|
+
id: "i1",
|
|
118
|
+
label: "find target",
|
|
119
|
+
description: "locate the target entity",
|
|
120
|
+
priority: 0.9,
|
|
121
|
+
owner: "user:laz",
|
|
122
|
+
});
|
|
123
|
+
state = applyIntentCommand(state, {
|
|
124
|
+
type: "intent.create",
|
|
125
|
+
intent,
|
|
126
|
+
}).state;
|
|
127
|
+
|
|
128
|
+
// update with undefined description — should NOT wipe it
|
|
129
|
+
state = applyIntentCommand(state, {
|
|
130
|
+
type: "intent.update",
|
|
131
|
+
intent_id: "i1",
|
|
132
|
+
partial: { description: undefined, label: "renamed" },
|
|
133
|
+
author: "user:laz",
|
|
134
|
+
}).state;
|
|
135
|
+
|
|
136
|
+
const updated = state.intents.get("i1")!;
|
|
137
|
+
expect(updated.label).toBe("renamed");
|
|
138
|
+
expect(updated.description).toBe("locate the target entity");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not overwrite context with undefined", () => {
|
|
142
|
+
let state = createIntentState();
|
|
143
|
+
const intent = createIntent({
|
|
144
|
+
id: "i2",
|
|
145
|
+
label: "test",
|
|
146
|
+
priority: 0.5,
|
|
147
|
+
owner: "user:laz",
|
|
148
|
+
context: { key: "value" },
|
|
149
|
+
});
|
|
150
|
+
state = applyIntentCommand(state, {
|
|
151
|
+
type: "intent.create",
|
|
152
|
+
intent,
|
|
153
|
+
}).state;
|
|
154
|
+
|
|
155
|
+
state = applyIntentCommand(state, {
|
|
156
|
+
type: "intent.update",
|
|
157
|
+
intent_id: "i2",
|
|
158
|
+
partial: { context: undefined },
|
|
159
|
+
author: "user:laz",
|
|
160
|
+
}).state;
|
|
161
|
+
|
|
162
|
+
const updated = state.intents.get("i2")!;
|
|
163
|
+
expect(updated.context).toEqual({ key: "value" });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("task.update strips undefined values", () => {
|
|
168
|
+
it("does not overwrite existing fields with undefined", () => {
|
|
169
|
+
let state = createTaskState();
|
|
170
|
+
const task = createTask({
|
|
171
|
+
id: "t1",
|
|
172
|
+
intent_id: "i1",
|
|
173
|
+
action: "search",
|
|
174
|
+
label: "search linkedin",
|
|
175
|
+
priority: 0.7,
|
|
176
|
+
context: { query: "test" },
|
|
177
|
+
});
|
|
178
|
+
state = applyTaskCommand(state, { type: "task.create", task }).state;
|
|
179
|
+
|
|
180
|
+
state = applyTaskCommand(state, {
|
|
181
|
+
type: "task.update",
|
|
182
|
+
task_id: "t1",
|
|
183
|
+
partial: { label: undefined, context: undefined, action: "search_v2" },
|
|
184
|
+
author: "agent:test",
|
|
185
|
+
}).state;
|
|
186
|
+
|
|
187
|
+
const updated = state.tasks.get("t1")!;
|
|
188
|
+
expect(updated.action).toBe("search_v2");
|
|
189
|
+
expect(updated.label).toBe("search linkedin");
|
|
190
|
+
expect(updated.context).toEqual({ query: "test" });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// =========================================================================
|
|
195
|
+
// 2. Edge re-id on import conflict
|
|
196
|
+
// =========================================================================
|
|
197
|
+
|
|
198
|
+
describe("importSlice edge re-id on conflict", () => {
|
|
199
|
+
it("re-ids edges when reIdOnDifference is true and edge data differs", () => {
|
|
200
|
+
const memState = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
201
|
+
const intentState = createIntentState();
|
|
202
|
+
const taskState = createTaskState();
|
|
203
|
+
|
|
204
|
+
// add an edge to the existing state
|
|
205
|
+
const existingEdge = makeEdge("e1", "m1", "m2", "SUPPORTS", { weight: 0.5 });
|
|
206
|
+
const stateWithEdge = applyCommand(memState, {
|
|
207
|
+
type: "edge.create",
|
|
208
|
+
edge: existingEdge,
|
|
209
|
+
}).state;
|
|
210
|
+
|
|
211
|
+
// slice with same edge_id but different data
|
|
212
|
+
const slice = {
|
|
213
|
+
memories: [],
|
|
214
|
+
edges: [makeEdge("e1", "m1", "m2", "SUPPORTS", { weight: 0.9 })],
|
|
215
|
+
intents: [],
|
|
216
|
+
tasks: [],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const result = importSlice(stateWithEdge, intentState, taskState, slice, {
|
|
220
|
+
skipExistingIds: true,
|
|
221
|
+
shallowCompareExisting: true,
|
|
222
|
+
reIdOnDifference: true,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// original edge should still exist
|
|
226
|
+
expect(result.memState.edges.has("e1")).toBe(true);
|
|
227
|
+
// a new edge should have been created
|
|
228
|
+
expect(result.report.created.edges.length).toBe(1);
|
|
229
|
+
const newEdgeId = result.report.created.edges[0];
|
|
230
|
+
expect(newEdgeId).not.toBe("e1");
|
|
231
|
+
const newEdge = result.memState.edges.get(newEdgeId)!;
|
|
232
|
+
expect(newEdge.weight).toBe(0.9);
|
|
233
|
+
expect(newEdge.from).toBe("m1");
|
|
234
|
+
expect(newEdge.to).toBe("m2");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("reports conflict when shallowCompare detects difference but reId is false", () => {
|
|
238
|
+
const memState = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
239
|
+
const intentState = createIntentState();
|
|
240
|
+
const taskState = createTaskState();
|
|
241
|
+
|
|
242
|
+
const existingEdge = makeEdge("e1", "m1", "m2", "SUPPORTS", { weight: 0.5 });
|
|
243
|
+
const stateWithEdge = applyCommand(memState, {
|
|
244
|
+
type: "edge.create",
|
|
245
|
+
edge: existingEdge,
|
|
246
|
+
}).state;
|
|
247
|
+
|
|
248
|
+
const slice = {
|
|
249
|
+
memories: [],
|
|
250
|
+
edges: [makeEdge("e1", "m1", "m2", "SUPPORTS", { weight: 0.9 })],
|
|
251
|
+
intents: [],
|
|
252
|
+
tasks: [],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const result = importSlice(stateWithEdge, intentState, taskState, slice, {
|
|
256
|
+
skipExistingIds: true,
|
|
257
|
+
shallowCompareExisting: true,
|
|
258
|
+
reIdOnDifference: false,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.report.conflicts.edges).toEqual(["e1"]);
|
|
262
|
+
expect(result.report.created.edges).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("skips identical edges without conflict", () => {
|
|
266
|
+
const memState = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
267
|
+
const intentState = createIntentState();
|
|
268
|
+
const taskState = createTaskState();
|
|
269
|
+
|
|
270
|
+
const edge = makeEdge("e1", "m1", "m2", "SUPPORTS");
|
|
271
|
+
const stateWithEdge = applyCommand(memState, {
|
|
272
|
+
type: "edge.create",
|
|
273
|
+
edge,
|
|
274
|
+
}).state;
|
|
275
|
+
|
|
276
|
+
const slice = {
|
|
277
|
+
memories: [],
|
|
278
|
+
edges: [{ ...edge }],
|
|
279
|
+
intents: [],
|
|
280
|
+
tasks: [],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const result = importSlice(stateWithEdge, intentState, taskState, slice, {
|
|
284
|
+
skipExistingIds: true,
|
|
285
|
+
shallowCompareExisting: true,
|
|
286
|
+
reIdOnDifference: true,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.report.skipped.edges).toEqual(["e1"]);
|
|
290
|
+
expect(result.report.created.edges).toEqual([]);
|
|
291
|
+
expect(result.report.conflicts.edges).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// =========================================================================
|
|
296
|
+
// 3. filterContradictions with chained contradictions
|
|
297
|
+
// =========================================================================
|
|
298
|
+
|
|
299
|
+
describe("filterContradictions chained contradictions", () => {
|
|
300
|
+
it("item C survives when B is excluded from A↔B but B↔C also exists", () => {
|
|
301
|
+
const state = stateWith(
|
|
302
|
+
[
|
|
303
|
+
makeItem("a", { authority: 0.9 }),
|
|
304
|
+
makeItem("b", { authority: 0.5 }),
|
|
305
|
+
makeItem("c", { authority: 0.7 }),
|
|
306
|
+
],
|
|
307
|
+
[
|
|
308
|
+
makeEdge("e1", "a", "b", "CONTRADICTS"),
|
|
309
|
+
makeEdge("e2", "b", "c", "CONTRADICTS"),
|
|
310
|
+
],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const scored: ScoredItem[] = [
|
|
314
|
+
{ item: state.items.get("a")!, score: 0.9 },
|
|
315
|
+
{ item: state.items.get("c")!, score: 0.7 },
|
|
316
|
+
{ item: state.items.get("b")!, score: 0.5 },
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const result = filterContradictions(state, scored);
|
|
320
|
+
const ids = result.map((s) => s.item.id);
|
|
321
|
+
|
|
322
|
+
expect(ids).toContain("a"); // winner of A↔B
|
|
323
|
+
expect(ids).not.toContain("b"); // loser of A↔B
|
|
324
|
+
expect(ids).toContain("c"); // B already excluded, so B↔C skipped — C survives
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// =========================================================================
|
|
329
|
+
// 4. filterContradictions equal-score tiebreak
|
|
330
|
+
// =========================================================================
|
|
331
|
+
|
|
332
|
+
describe("filterContradictions equal-score tiebreak", () => {
|
|
333
|
+
it("uses lexicographic id comparison for equal scores", () => {
|
|
334
|
+
const state = stateWith(
|
|
335
|
+
[
|
|
336
|
+
makeItem("aaa", { authority: 0.8 }),
|
|
337
|
+
makeItem("zzz", { authority: 0.8 }),
|
|
338
|
+
],
|
|
339
|
+
[makeEdge("e1", "aaa", "zzz", "CONTRADICTS")],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const scored: ScoredItem[] = [
|
|
343
|
+
{ item: state.items.get("aaa")!, score: 0.5 },
|
|
344
|
+
{ item: state.items.get("zzz")!, score: 0.5 },
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const result = filterContradictions(state, scored);
|
|
348
|
+
const ids = result.map((s) => s.item.id);
|
|
349
|
+
|
|
350
|
+
// lexicographically "aaa" < "zzz", so "zzz" is excluded
|
|
351
|
+
expect(ids).toContain("aaa");
|
|
352
|
+
expect(ids).not.toContain("zzz");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("tiebreak is deterministic regardless of input order", () => {
|
|
356
|
+
const state = stateWith(
|
|
357
|
+
[
|
|
358
|
+
makeItem("aaa", { authority: 0.8 }),
|
|
359
|
+
makeItem("zzz", { authority: 0.8 }),
|
|
360
|
+
],
|
|
361
|
+
[makeEdge("e1", "aaa", "zzz", "CONTRADICTS")],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// reverse the scored input order
|
|
365
|
+
const scored: ScoredItem[] = [
|
|
366
|
+
{ item: state.items.get("zzz")!, score: 0.5 },
|
|
367
|
+
{ item: state.items.get("aaa")!, score: 0.5 },
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
const result = filterContradictions(state, scored);
|
|
371
|
+
const ids = result.map((s) => s.item.id);
|
|
372
|
+
|
|
373
|
+
expect(ids).toContain("aaa");
|
|
374
|
+
expect(ids).not.toContain("zzz");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// =========================================================================
|
|
379
|
+
// 5. smartRetrieve with contradictions: "surface"
|
|
380
|
+
// =========================================================================
|
|
381
|
+
|
|
382
|
+
describe("smartRetrieve with contradictions: surface", () => {
|
|
383
|
+
it("keeps both sides and annotates contradicted_by", () => {
|
|
384
|
+
const state = stateWith(
|
|
385
|
+
[
|
|
386
|
+
makeItem("m1", { authority: 0.9 }),
|
|
387
|
+
makeItem("m2", { authority: 0.6 }),
|
|
388
|
+
makeItem("m3", { authority: 0.3 }),
|
|
389
|
+
],
|
|
390
|
+
[makeEdge("e1", "m1", "m2", "CONTRADICTS")],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const result = smartRetrieve(state, {
|
|
394
|
+
budget: 1000,
|
|
395
|
+
costFn: () => 1,
|
|
396
|
+
weights: { authority: 1 },
|
|
397
|
+
contradictions: "surface",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const ids = result.map((s) => s.item.id);
|
|
401
|
+
expect(ids).toContain("m1");
|
|
402
|
+
expect(ids).toContain("m2");
|
|
403
|
+
expect(ids).toContain("m3");
|
|
404
|
+
|
|
405
|
+
const m1Entry = result.find((s) => s.item.id === "m1")!;
|
|
406
|
+
const m2Entry = result.find((s) => s.item.id === "m2")!;
|
|
407
|
+
expect(m1Entry.contradicted_by).toBeDefined();
|
|
408
|
+
expect(m1Entry.contradicted_by!.map((i) => i.id)).toContain("m2");
|
|
409
|
+
expect(m2Entry.contradicted_by).toBeDefined();
|
|
410
|
+
expect(m2Entry.contradicted_by!.map((i) => i.id)).toContain("m1");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("still removes superseded items when surfacing", () => {
|
|
414
|
+
let state = stateWith([
|
|
415
|
+
makeItem("m1", { authority: 0.9 }),
|
|
416
|
+
makeItem("m2", { authority: 0.4 }),
|
|
417
|
+
]);
|
|
418
|
+
// mark contradiction then resolve it
|
|
419
|
+
state = markContradiction(state, "m1", "m2", "agent:test").state;
|
|
420
|
+
state = resolveContradiction(state, "m1", "m2", "agent:test").state;
|
|
421
|
+
|
|
422
|
+
const result = smartRetrieve(state, {
|
|
423
|
+
budget: 1000,
|
|
424
|
+
costFn: () => 1,
|
|
425
|
+
weights: { authority: 1 },
|
|
426
|
+
contradictions: "surface",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const ids = result.map((s) => s.item.id);
|
|
430
|
+
expect(ids).toContain("m1");
|
|
431
|
+
expect(ids).not.toContain("m2"); // superseded
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// =========================================================================
|
|
436
|
+
// 6. surfaceContradictions bidirectional annotation
|
|
437
|
+
// =========================================================================
|
|
438
|
+
|
|
439
|
+
describe("surfaceContradictions", () => {
|
|
440
|
+
it("annotates both sides of each contradiction", () => {
|
|
441
|
+
const state = stateWith(
|
|
442
|
+
[
|
|
443
|
+
makeItem("a", { authority: 0.8 }),
|
|
444
|
+
makeItem("b", { authority: 0.6 }),
|
|
445
|
+
makeItem("c", { authority: 0.4 }),
|
|
446
|
+
],
|
|
447
|
+
[
|
|
448
|
+
makeEdge("e1", "a", "b", "CONTRADICTS"),
|
|
449
|
+
makeEdge("e2", "b", "c", "CONTRADICTS"),
|
|
450
|
+
],
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const scored: ScoredItem[] = [
|
|
454
|
+
{ item: state.items.get("a")!, score: 0.8 },
|
|
455
|
+
{ item: state.items.get("b")!, score: 0.6 },
|
|
456
|
+
{ item: state.items.get("c")!, score: 0.4 },
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
const result = surfaceContradictions(state, scored);
|
|
460
|
+
|
|
461
|
+
const a = result.find((s) => s.item.id === "a")!;
|
|
462
|
+
const b = result.find((s) => s.item.id === "b")!;
|
|
463
|
+
const c = result.find((s) => s.item.id === "c")!;
|
|
464
|
+
|
|
465
|
+
// a contradicts b
|
|
466
|
+
expect(a.contradicted_by!.map((i) => i.id)).toEqual(["b"]);
|
|
467
|
+
// b contradicts a AND c
|
|
468
|
+
expect(b.contradicted_by!.map((i) => i.id).sort()).toEqual(["a", "c"]);
|
|
469
|
+
// c contradicts b
|
|
470
|
+
expect(c.contradicted_by!.map((i) => i.id)).toEqual(["b"]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("does not mutate the input array", () => {
|
|
474
|
+
const state = stateWith(
|
|
475
|
+
[makeItem("a"), makeItem("b")],
|
|
476
|
+
[makeEdge("e1", "a", "b", "CONTRADICTS")],
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const scored: ScoredItem[] = [
|
|
480
|
+
{ item: state.items.get("a")!, score: 0.8 },
|
|
481
|
+
{ item: state.items.get("b")!, score: 0.6 },
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
surfaceContradictions(state, scored);
|
|
485
|
+
|
|
486
|
+
// original entries should not have contradicted_by
|
|
487
|
+
expect(scored[0].contradicted_by).toBeUndefined();
|
|
488
|
+
expect(scored[1].contradicted_by).toBeUndefined();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// =========================================================================
|
|
493
|
+
// 7. getItemsByBudget with zero-cost items
|
|
494
|
+
// =========================================================================
|
|
495
|
+
|
|
496
|
+
describe("getItemsByBudget with zero-cost items", () => {
|
|
497
|
+
it("includes all zero-cost items without exhausting budget", () => {
|
|
498
|
+
const state = stateWith([
|
|
499
|
+
makeItem("m1", { authority: 0.9 }),
|
|
500
|
+
makeItem("m2", { authority: 0.8 }),
|
|
501
|
+
makeItem("m3", { authority: 0.7 }),
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
const result = getItemsByBudget(state, {
|
|
505
|
+
budget: 5,
|
|
506
|
+
costFn: () => 0,
|
|
507
|
+
weights: { authority: 1 },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(result.length).toBe(3);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("mixes zero-cost and positive-cost items correctly", () => {
|
|
514
|
+
const state = stateWith([
|
|
515
|
+
makeItem("m1", { authority: 0.9 }),
|
|
516
|
+
makeItem("m2", { authority: 0.8 }),
|
|
517
|
+
makeItem("m3", { authority: 0.7 }),
|
|
518
|
+
]);
|
|
519
|
+
|
|
520
|
+
const result = getItemsByBudget(state, {
|
|
521
|
+
budget: 2,
|
|
522
|
+
costFn: (item) => (item.id === "m2" ? 0 : 1),
|
|
523
|
+
weights: { authority: 1 },
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const ids = result.map((s) => s.item.id);
|
|
527
|
+
// m1 (score 0.9, cost 1) → remaining 1; m2 (score 0.8, cost 0) → remaining 1; m3 (cost 1) → remaining 0
|
|
528
|
+
expect(ids).toContain("m1");
|
|
529
|
+
expect(ids).toContain("m2");
|
|
530
|
+
expect(ids).toContain("m3");
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// =========================================================================
|
|
535
|
+
// 8. applyMany transform returning empty object (skip path)
|
|
536
|
+
// =========================================================================
|
|
537
|
+
|
|
538
|
+
describe("applyMany transform returning empty object", () => {
|
|
539
|
+
it("skips items when transform returns {}", () => {
|
|
540
|
+
const state = stateWith([
|
|
541
|
+
makeItem("m1", { authority: 0.5 }),
|
|
542
|
+
makeItem("m2", { authority: 0.8 }),
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
const result = applyMany(
|
|
546
|
+
state,
|
|
547
|
+
{}, // match all
|
|
548
|
+
() => ({}), // skip all
|
|
549
|
+
"agent:test",
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// no changes — should return original state
|
|
553
|
+
expect(result.state).toBe(state);
|
|
554
|
+
expect(result.events).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("applies to some and skips others", () => {
|
|
558
|
+
const state = stateWith([
|
|
559
|
+
makeItem("m1", { authority: 0.5 }),
|
|
560
|
+
makeItem("m2", { authority: 0.8 }),
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
const result = applyMany(
|
|
564
|
+
state,
|
|
565
|
+
{},
|
|
566
|
+
(item) => {
|
|
567
|
+
if (item.id === "m1") return { authority: 0.9 };
|
|
568
|
+
return {}; // skip m2
|
|
569
|
+
},
|
|
570
|
+
"agent:test",
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(result.events.length).toBe(1);
|
|
574
|
+
expect(result.state.items.get("m1")!.authority).toBe(0.9);
|
|
575
|
+
expect(result.state.items.get("m2")!.authority).toBe(0.8);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// =========================================================================
|
|
580
|
+
// 9. resolveContradiction with multiple CONTRADICTS edges between same pair
|
|
581
|
+
// =========================================================================
|
|
582
|
+
|
|
583
|
+
describe("resolveContradiction with multiple CONTRADICTS edges", () => {
|
|
584
|
+
it("retracts all CONTRADICTS edges between the pair", () => {
|
|
585
|
+
let state = stateWith([
|
|
586
|
+
makeItem("m1", { authority: 0.9 }),
|
|
587
|
+
makeItem("m2", { authority: 0.5 }),
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
// create two CONTRADICTS edges between same pair (different directions)
|
|
591
|
+
state = applyCommand(state, {
|
|
592
|
+
type: "edge.create",
|
|
593
|
+
edge: makeEdge("c1", "m1", "m2", "CONTRADICTS"),
|
|
594
|
+
}).state;
|
|
595
|
+
state = applyCommand(state, {
|
|
596
|
+
type: "edge.create",
|
|
597
|
+
edge: makeEdge("c2", "m2", "m1", "CONTRADICTS"),
|
|
598
|
+
}).state;
|
|
599
|
+
|
|
600
|
+
expect(getContradictions(state).length).toBe(2);
|
|
601
|
+
|
|
602
|
+
const result = resolveContradiction(state, "m1", "m2", "agent:test");
|
|
603
|
+
state = result.state;
|
|
604
|
+
|
|
605
|
+
// both CONTRADICTS edges should be retracted
|
|
606
|
+
const remainingContradicts = getEdges(state, {
|
|
607
|
+
kind: "CONTRADICTS",
|
|
608
|
+
active_only: true,
|
|
609
|
+
});
|
|
610
|
+
expect(remainingContradicts.length).toBe(0);
|
|
611
|
+
|
|
612
|
+
// SUPERSEDES edge should exist
|
|
613
|
+
const supersedes = getEdges(state, {
|
|
614
|
+
kind: "SUPERSEDES",
|
|
615
|
+
active_only: true,
|
|
616
|
+
});
|
|
617
|
+
expect(supersedes.length).toBe(1);
|
|
618
|
+
expect(supersedes[0].from).toBe("m1");
|
|
619
|
+
expect(supersedes[0].to).toBe("m2");
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// =========================================================================
|
|
624
|
+
// 10. exportSlice with include_related_intents via meta.creation_intent_id
|
|
625
|
+
// =========================================================================
|
|
626
|
+
|
|
627
|
+
describe("exportSlice walks meta.creation_intent_id", () => {
|
|
628
|
+
it("includes intents referenced by memory meta", () => {
|
|
629
|
+
const memState = stateWith([
|
|
630
|
+
makeItem("m1", { meta: { creation_intent_id: "i1" } }),
|
|
631
|
+
]);
|
|
632
|
+
let intentState = createIntentState();
|
|
633
|
+
const intent = createIntent({
|
|
634
|
+
id: "i1",
|
|
635
|
+
label: "test intent",
|
|
636
|
+
priority: 0.5,
|
|
637
|
+
owner: "agent:test",
|
|
638
|
+
});
|
|
639
|
+
intentState = applyIntentCommand(intentState, {
|
|
640
|
+
type: "intent.create",
|
|
641
|
+
intent,
|
|
642
|
+
}).state;
|
|
643
|
+
const taskState = createTaskState();
|
|
644
|
+
|
|
645
|
+
const slice = exportSlice(memState, intentState, taskState, {
|
|
646
|
+
memory_ids: ["m1"],
|
|
647
|
+
include_related_intents: true,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
expect(slice.intents.length).toBe(1);
|
|
651
|
+
expect(slice.intents[0].id).toBe("i1");
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// =========================================================================
|
|
656
|
+
// 11. exportSlice with include_related_tasks via meta.creation_task_id
|
|
657
|
+
// =========================================================================
|
|
658
|
+
|
|
659
|
+
describe("exportSlice walks meta.creation_task_id", () => {
|
|
660
|
+
it("includes tasks referenced by memory meta", () => {
|
|
661
|
+
const memState = stateWith([
|
|
662
|
+
makeItem("m1", { meta: { creation_task_id: "t1" } }),
|
|
663
|
+
]);
|
|
664
|
+
const intentState = createIntentState();
|
|
665
|
+
let taskState = createTaskState();
|
|
666
|
+
const task = createTask({
|
|
667
|
+
id: "t1",
|
|
668
|
+
intent_id: "i1",
|
|
669
|
+
action: "search",
|
|
670
|
+
priority: 0.5,
|
|
671
|
+
});
|
|
672
|
+
taskState = applyTaskCommand(taskState, {
|
|
673
|
+
type: "task.create",
|
|
674
|
+
task,
|
|
675
|
+
}).state;
|
|
676
|
+
|
|
677
|
+
const slice = exportSlice(memState, intentState, taskState, {
|
|
678
|
+
memory_ids: ["m1"],
|
|
679
|
+
include_related_tasks: true,
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
expect(slice.tasks.length).toBe(1);
|
|
683
|
+
expect(slice.tasks[0].id).toBe("t1");
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// =========================================================================
|
|
688
|
+
// 12. getEdges with active_only: false
|
|
689
|
+
// =========================================================================
|
|
690
|
+
|
|
691
|
+
describe("getEdges with active_only: false", () => {
|
|
692
|
+
it("returns inactive edges", () => {
|
|
693
|
+
let state = stateWith(
|
|
694
|
+
[makeItem("m1"), makeItem("m2")],
|
|
695
|
+
[
|
|
696
|
+
makeEdge("e1", "m1", "m2", "SUPPORTS", { active: true }),
|
|
697
|
+
makeEdge("e2", "m1", "m2", "ABOUT", { active: false }),
|
|
698
|
+
],
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const allEdges = getEdges(state, { active_only: false });
|
|
702
|
+
expect(allEdges.length).toBe(2);
|
|
703
|
+
|
|
704
|
+
const activeOnly = getEdges(state, { active_only: true });
|
|
705
|
+
expect(activeOnly.length).toBe(1);
|
|
706
|
+
expect(activeOnly[0].edge_id).toBe("e1");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("defaults to active_only: true", () => {
|
|
710
|
+
let state = stateWith(
|
|
711
|
+
[makeItem("m1"), makeItem("m2")],
|
|
712
|
+
[
|
|
713
|
+
makeEdge("e1", "m1", "m2", "SUPPORTS", { active: true }),
|
|
714
|
+
makeEdge("e2", "m1", "m2", "ABOUT", { active: false }),
|
|
715
|
+
],
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
const defaultEdges = getEdges(state);
|
|
719
|
+
expect(defaultEdges.length).toBe(1);
|
|
720
|
+
expect(defaultEdges[0].edge_id).toBe("e1");
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// =========================================================================
|
|
725
|
+
// 13. decayImportance with all items at importance: 0
|
|
726
|
+
// =========================================================================
|
|
727
|
+
|
|
728
|
+
describe("decayImportance with zero importance", () => {
|
|
729
|
+
it("is a no-op when all items have importance 0", () => {
|
|
730
|
+
// use fake ids with old timestamps so they match the cutoff
|
|
731
|
+
const oldId1 = fakeId(1000);
|
|
732
|
+
const oldId2 = fakeId(1001);
|
|
733
|
+
|
|
734
|
+
const state = stateWith([
|
|
735
|
+
makeItem(oldId1, { importance: 0 }),
|
|
736
|
+
makeItem(oldId2, { importance: 0 }),
|
|
737
|
+
]);
|
|
738
|
+
|
|
739
|
+
const result = decayImportance(state, 1, 0.5, "agent:test");
|
|
740
|
+
|
|
741
|
+
// no changes — should return original state
|
|
742
|
+
expect(result.state).toBe(state);
|
|
743
|
+
expect(result.events).toEqual([]);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// =========================================================================
|
|
748
|
+
// 14. replayFromEnvelopes with duplicate/out-of-order timestamps
|
|
749
|
+
// =========================================================================
|
|
750
|
+
|
|
751
|
+
describe("replayFromEnvelopes ordering", () => {
|
|
752
|
+
it("sorts by timestamp before replaying", () => {
|
|
753
|
+
const id1 = fakeId(1000);
|
|
754
|
+
const id2 = fakeId(2000);
|
|
755
|
+
|
|
756
|
+
const item1 = makeItem(id1, { authority: 0.5 });
|
|
757
|
+
const item2 = makeItem(id2, { authority: 0.9 });
|
|
758
|
+
|
|
759
|
+
// envelopes in reverse chronological order
|
|
760
|
+
const envelopes = [
|
|
761
|
+
{
|
|
762
|
+
id: "env2",
|
|
763
|
+
namespace: "memory" as const,
|
|
764
|
+
type: "memory.create",
|
|
765
|
+
ts: "2026-01-01T00:00:02.000Z",
|
|
766
|
+
payload: { type: "memory.create" as const, item: item2 },
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
id: "env1",
|
|
770
|
+
namespace: "memory" as const,
|
|
771
|
+
type: "memory.create",
|
|
772
|
+
ts: "2026-01-01T00:00:01.000Z",
|
|
773
|
+
payload: { type: "memory.create" as const, item: item1 },
|
|
774
|
+
},
|
|
775
|
+
];
|
|
776
|
+
|
|
777
|
+
const result = replayFromEnvelopes(envelopes);
|
|
778
|
+
expect(result.state.items.size).toBe(2);
|
|
779
|
+
expect(result.state.items.has(id1)).toBe(true);
|
|
780
|
+
expect(result.state.items.has(id2)).toBe(true);
|
|
781
|
+
// events should be in chronological order
|
|
782
|
+
expect(result.events[0].item!.id).toBe(id1);
|
|
783
|
+
expect(result.events[1].item!.id).toBe(id2);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("handles envelopes with identical timestamps", () => {
|
|
787
|
+
const id1 = fakeId(3000);
|
|
788
|
+
const id2 = fakeId(3001);
|
|
789
|
+
|
|
790
|
+
const item1 = makeItem(id1);
|
|
791
|
+
const item2 = makeItem(id2);
|
|
792
|
+
|
|
793
|
+
const envelopes = [
|
|
794
|
+
{
|
|
795
|
+
id: "env1",
|
|
796
|
+
namespace: "memory" as const,
|
|
797
|
+
type: "memory.create",
|
|
798
|
+
ts: "2026-01-01T00:00:01.000Z",
|
|
799
|
+
payload: { type: "memory.create" as const, item: item1 },
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
id: "env2",
|
|
803
|
+
namespace: "memory" as const,
|
|
804
|
+
type: "memory.create",
|
|
805
|
+
ts: "2026-01-01T00:00:01.000Z",
|
|
806
|
+
payload: { type: "memory.create" as const, item: item2 },
|
|
807
|
+
},
|
|
808
|
+
];
|
|
809
|
+
|
|
810
|
+
const result = replayFromEnvelopes(envelopes);
|
|
811
|
+
expect(result.state.items.size).toBe(2);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// =========================================================================
|
|
816
|
+
// 15. getRelatedItems with inactive edges
|
|
817
|
+
// =========================================================================
|
|
818
|
+
|
|
819
|
+
describe("getRelatedItems with inactive edges", () => {
|
|
820
|
+
it("excludes items connected only via inactive edges", () => {
|
|
821
|
+
const state = stateWith(
|
|
822
|
+
[makeItem("m1"), makeItem("m2"), makeItem("m3")],
|
|
823
|
+
[
|
|
824
|
+
makeEdge("e1", "m1", "m2", "SUPPORTS", { active: true }),
|
|
825
|
+
makeEdge("e2", "m1", "m3", "SUPPORTS", { active: false }),
|
|
826
|
+
],
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const related = getRelatedItems(state, "m1");
|
|
830
|
+
const ids = related.map((i) => i.id);
|
|
831
|
+
|
|
832
|
+
expect(ids).toContain("m2");
|
|
833
|
+
expect(ids).not.toContain("m3"); // inactive edge
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("returns empty when all edges are inactive", () => {
|
|
837
|
+
const state = stateWith(
|
|
838
|
+
[makeItem("m1"), makeItem("m2")],
|
|
839
|
+
[makeEdge("e1", "m1", "m2", "SUPPORTS", { active: false })],
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const related = getRelatedItems(state, "m1");
|
|
843
|
+
expect(related).toEqual([]);
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// =========================================================================
|
|
848
|
+
// Bonus: applyDiversity with empty input
|
|
849
|
+
// =========================================================================
|
|
850
|
+
|
|
851
|
+
describe("applyDiversity edge cases", () => {
|
|
852
|
+
it("handles empty scored array", () => {
|
|
853
|
+
const result = applyDiversity([], { author_penalty: 0.1 });
|
|
854
|
+
expect(result).toEqual([]);
|
|
855
|
+
});
|
|
856
|
+
});
|