@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.
Files changed (46) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/.github/workflows/release.yml +35 -0
  3. package/API.md +1078 -0
  4. package/LICENSE +190 -0
  5. package/README.md +574 -0
  6. package/package.json +30 -0
  7. package/src/bulk.ts +128 -0
  8. package/src/envelope.ts +52 -0
  9. package/src/errors.ts +27 -0
  10. package/src/graph.ts +15 -0
  11. package/src/helpers.ts +51 -0
  12. package/src/index.ts +142 -0
  13. package/src/integrity.ts +378 -0
  14. package/src/intent.ts +311 -0
  15. package/src/query.ts +357 -0
  16. package/src/reducer.ts +177 -0
  17. package/src/replay.ts +32 -0
  18. package/src/retrieval.ts +306 -0
  19. package/src/serialization.ts +34 -0
  20. package/src/stats.ts +62 -0
  21. package/src/task.ts +373 -0
  22. package/src/transplant.ts +488 -0
  23. package/src/types.ts +248 -0
  24. package/tests/bugfix-and-coverage.test.ts +958 -0
  25. package/tests/bugfix-holes.test.ts +856 -0
  26. package/tests/bulk.test.ts +256 -0
  27. package/tests/edge-cases-v2.test.ts +355 -0
  28. package/tests/edge-cases.test.ts +661 -0
  29. package/tests/envelope.test.ts +92 -0
  30. package/tests/graph.test.ts +41 -0
  31. package/tests/helpers.test.ts +120 -0
  32. package/tests/integrity.test.ts +371 -0
  33. package/tests/intent.test.ts +276 -0
  34. package/tests/query-advanced.test.ts +252 -0
  35. package/tests/query.test.ts +623 -0
  36. package/tests/reducer.test.ts +342 -0
  37. package/tests/replay.test.ts +145 -0
  38. package/tests/retrieval.test.ts +691 -0
  39. package/tests/serialization.test.ts +118 -0
  40. package/tests/setup.test.ts +7 -0
  41. package/tests/stats.test.ts +163 -0
  42. package/tests/task.test.ts +322 -0
  43. package/tests/transplant.test.ts +385 -0
  44. package/tests/types.test.ts +231 -0
  45. package/tsconfig.json +18 -0
  46. 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
+ });