@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,256 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { applyMany, bulkAdjustScores } from "../src/bulk.js";
|
|
3
|
+
import type { MemoryItem, GraphState } from "../src/types.js";
|
|
4
|
+
|
|
5
|
+
function buildState(items: MemoryItem[]): GraphState {
|
|
6
|
+
const state: GraphState = { items: new Map(), edges: new Map() };
|
|
7
|
+
for (const i of items) state.items.set(i.id, i);
|
|
8
|
+
return state;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const baseItem = (
|
|
12
|
+
id: string,
|
|
13
|
+
overrides: Partial<MemoryItem> = {},
|
|
14
|
+
): MemoryItem => ({
|
|
15
|
+
id,
|
|
16
|
+
scope: "test",
|
|
17
|
+
kind: "observation",
|
|
18
|
+
content: {},
|
|
19
|
+
author: "user:laz",
|
|
20
|
+
source_kind: "observed",
|
|
21
|
+
authority: 0.5,
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// -- applyMany --
|
|
26
|
+
|
|
27
|
+
describe("applyMany", () => {
|
|
28
|
+
it("updates matching items with a transform", () => {
|
|
29
|
+
const state = buildState([
|
|
30
|
+
baseItem("m1", { authority: 0.5 }),
|
|
31
|
+
baseItem("m2", { authority: 0.6 }),
|
|
32
|
+
]);
|
|
33
|
+
const { state: next, events } = applyMany(
|
|
34
|
+
state,
|
|
35
|
+
{},
|
|
36
|
+
() => ({ authority: 0.9 }),
|
|
37
|
+
"system:eval",
|
|
38
|
+
);
|
|
39
|
+
expect(next.items.get("m1")!.authority).toBe(0.9);
|
|
40
|
+
expect(next.items.get("m2")!.authority).toBe(0.9);
|
|
41
|
+
expect(events).toHaveLength(2);
|
|
42
|
+
expect(events.every((e) => e.type === "memory.updated")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("retracts items when transform returns null", () => {
|
|
46
|
+
const state = buildState([
|
|
47
|
+
baseItem("m1", { authority: 0.1 }),
|
|
48
|
+
baseItem("m2", { authority: 0.8 }),
|
|
49
|
+
]);
|
|
50
|
+
const { state: next, events } = applyMany(
|
|
51
|
+
state,
|
|
52
|
+
{},
|
|
53
|
+
(item) => (item.authority < 0.5 ? null : {}),
|
|
54
|
+
"system:cleanup",
|
|
55
|
+
);
|
|
56
|
+
expect(next.items.has("m1")).toBe(false);
|
|
57
|
+
expect(next.items.has("m2")).toBe(true);
|
|
58
|
+
expect(events).toHaveLength(1);
|
|
59
|
+
expect(events[0].type).toBe("memory.retracted");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("skips items when transform returns empty object", () => {
|
|
63
|
+
const state = buildState([baseItem("m1"), baseItem("m2")]);
|
|
64
|
+
const { state: next, events } = applyMany(
|
|
65
|
+
state,
|
|
66
|
+
{},
|
|
67
|
+
() => ({}),
|
|
68
|
+
"system:noop",
|
|
69
|
+
);
|
|
70
|
+
expect(next).toBe(state); // no changes, same reference
|
|
71
|
+
expect(events).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses item-dependent transform (decay)", () => {
|
|
75
|
+
const state = buildState([
|
|
76
|
+
baseItem("m1", { authority: 0.8 }),
|
|
77
|
+
baseItem("m2", { authority: 0.4 }),
|
|
78
|
+
]);
|
|
79
|
+
const { state: next } = applyMany(
|
|
80
|
+
state,
|
|
81
|
+
{},
|
|
82
|
+
(item) => ({ authority: item.authority * 0.5 }),
|
|
83
|
+
"system:decay",
|
|
84
|
+
);
|
|
85
|
+
expect(next.items.get("m1")!.authority).toBeCloseTo(0.4);
|
|
86
|
+
expect(next.items.get("m2")!.authority).toBeCloseTo(0.2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("conditional: retract low, boost high", () => {
|
|
90
|
+
const state = buildState([
|
|
91
|
+
baseItem("m1", { authority: 0.1 }),
|
|
92
|
+
baseItem("m2", { authority: 0.7 }),
|
|
93
|
+
baseItem("m3", { authority: 0.3 }),
|
|
94
|
+
]);
|
|
95
|
+
const { state: next, events } = applyMany(
|
|
96
|
+
state,
|
|
97
|
+
{},
|
|
98
|
+
(item) => (item.authority < 0.3 ? null : { authority: 1.0 }),
|
|
99
|
+
"system:evaluator",
|
|
100
|
+
);
|
|
101
|
+
expect(next.items.has("m1")).toBe(false); // retracted
|
|
102
|
+
expect(next.items.get("m2")!.authority).toBe(1.0); // boosted
|
|
103
|
+
expect(next.items.get("m3")!.authority).toBe(1.0); // boosted
|
|
104
|
+
expect(events).toHaveLength(3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips items already retracted by a prior transform in the same batch", () => {
|
|
108
|
+
// m1 and m2 both match. Transform retracts m1, then when processing m2
|
|
109
|
+
// it tries to also reference m1 — but m1 is gone. Should not crash.
|
|
110
|
+
const state = buildState([
|
|
111
|
+
baseItem("m1", { authority: 0.1 }),
|
|
112
|
+
baseItem("m2", { authority: 0.1 }),
|
|
113
|
+
]);
|
|
114
|
+
// First pass retracts both; second one should be skipped, not crash
|
|
115
|
+
const { state: next, events } = applyMany(
|
|
116
|
+
state,
|
|
117
|
+
{},
|
|
118
|
+
(item) => null, // retract all
|
|
119
|
+
"system:cleanup",
|
|
120
|
+
);
|
|
121
|
+
expect(next.items.size).toBe(0);
|
|
122
|
+
expect(events).toHaveLength(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("shallow-merges meta without losing existing fields", () => {
|
|
126
|
+
const state = buildState([
|
|
127
|
+
baseItem("m1", { meta: { agent_id: "agent:x", session_id: "s1" } }),
|
|
128
|
+
]);
|
|
129
|
+
const { state: next } = applyMany(
|
|
130
|
+
state,
|
|
131
|
+
{},
|
|
132
|
+
() => ({ meta: { hot: true } }),
|
|
133
|
+
"system:tagger",
|
|
134
|
+
);
|
|
135
|
+
const meta = next.items.get("m1")!.meta!;
|
|
136
|
+
expect(meta.hot).toBe(true);
|
|
137
|
+
expect(meta.agent_id).toBe("agent:x");
|
|
138
|
+
expect(meta.session_id).toBe("s1");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("applies filter before transform", () => {
|
|
142
|
+
const state = buildState([
|
|
143
|
+
baseItem("m1", { scope: "a" }),
|
|
144
|
+
baseItem("m2", { scope: "b" }),
|
|
145
|
+
]);
|
|
146
|
+
const { state: next } = applyMany(
|
|
147
|
+
state,
|
|
148
|
+
{ scope: "a" },
|
|
149
|
+
() => ({ authority: 1.0 }),
|
|
150
|
+
"system:eval",
|
|
151
|
+
);
|
|
152
|
+
expect(next.items.get("m1")!.authority).toBe(1.0);
|
|
153
|
+
expect(next.items.get("m2")!.authority).toBe(0.5); // untouched
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("respects QueryOptions sort + limit", () => {
|
|
157
|
+
const state = buildState([
|
|
158
|
+
baseItem("m1", { authority: 0.3 }),
|
|
159
|
+
baseItem("m2", { authority: 0.9 }),
|
|
160
|
+
baseItem("m3", { authority: 0.6 }),
|
|
161
|
+
]);
|
|
162
|
+
const { state: next, events } = applyMany(
|
|
163
|
+
state,
|
|
164
|
+
{},
|
|
165
|
+
() => ({ meta: { top: true } }),
|
|
166
|
+
"system:tagger",
|
|
167
|
+
undefined,
|
|
168
|
+
{ sort: { field: "authority", order: "desc" }, limit: 2 },
|
|
169
|
+
);
|
|
170
|
+
// only top 2 by authority (m2, m3) should be tagged
|
|
171
|
+
expect(next.items.get("m2")!.meta?.top).toBe(true);
|
|
172
|
+
expect(next.items.get("m3")!.meta?.top).toBe(true);
|
|
173
|
+
expect(next.items.get("m1")!.meta?.top).toBeUndefined();
|
|
174
|
+
expect(events).toHaveLength(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not mutate original state", () => {
|
|
178
|
+
const state = buildState([baseItem("m1", { authority: 0.5 })]);
|
|
179
|
+
applyMany(state, {}, () => ({ authority: 1.0 }), "test");
|
|
180
|
+
expect(state.items.get("m1")!.authority).toBe(0.5);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// -- bulkAdjustScores (now wraps applyMany) --
|
|
185
|
+
|
|
186
|
+
describe("bulkAdjustScores", () => {
|
|
187
|
+
it("adjusts authority on matching items", () => {
|
|
188
|
+
const state = buildState([
|
|
189
|
+
baseItem("m1", { authority: 0.5 }),
|
|
190
|
+
baseItem("m2", { authority: 0.6 }),
|
|
191
|
+
baseItem("m3", { authority: 0.7 }),
|
|
192
|
+
]);
|
|
193
|
+
const { state: next, events } = bulkAdjustScores(
|
|
194
|
+
state,
|
|
195
|
+
{ range: { authority: { min: 0.5 } } },
|
|
196
|
+
{ authority: -0.2 },
|
|
197
|
+
"system:tuner",
|
|
198
|
+
"decay",
|
|
199
|
+
);
|
|
200
|
+
expect(next.items.get("m1")!.authority).toBeCloseTo(0.3);
|
|
201
|
+
expect(next.items.get("m2")!.authority).toBeCloseTo(0.4);
|
|
202
|
+
expect(next.items.get("m3")!.authority).toBeCloseTo(0.5);
|
|
203
|
+
expect(events).toHaveLength(3);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("clamps to 0 (no negative)", () => {
|
|
207
|
+
const state = buildState([baseItem("m1", { authority: 0.1 })]);
|
|
208
|
+
const { state: next } = bulkAdjustScores(
|
|
209
|
+
state,
|
|
210
|
+
{},
|
|
211
|
+
{ authority: -0.5 },
|
|
212
|
+
"system:tuner",
|
|
213
|
+
);
|
|
214
|
+
expect(next.items.get("m1")!.authority).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("clamps to 1 (no overflow)", () => {
|
|
218
|
+
const state = buildState([baseItem("m1", { authority: 0.9 })]);
|
|
219
|
+
const { state: next } = bulkAdjustScores(
|
|
220
|
+
state,
|
|
221
|
+
{},
|
|
222
|
+
{ authority: 0.5 },
|
|
223
|
+
"system:tuner",
|
|
224
|
+
);
|
|
225
|
+
expect(next.items.get("m1")!.authority).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("treats undefined importance as 0 when adding delta", () => {
|
|
229
|
+
const state = buildState([baseItem("m1")]);
|
|
230
|
+
const { state: next } = bulkAdjustScores(
|
|
231
|
+
state,
|
|
232
|
+
{},
|
|
233
|
+
{ importance: 0.7 },
|
|
234
|
+
"system:tuner",
|
|
235
|
+
);
|
|
236
|
+
expect(next.items.get("m1")!.importance).toBe(0.7);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("returns unchanged state and empty events when no matches", () => {
|
|
240
|
+
const state = buildState([baseItem("m1", { scope: "other" })]);
|
|
241
|
+
const { state: next, events } = bulkAdjustScores(
|
|
242
|
+
state,
|
|
243
|
+
{ scope: "nonexistent" },
|
|
244
|
+
{ authority: 0.1 },
|
|
245
|
+
"system:tuner",
|
|
246
|
+
);
|
|
247
|
+
expect(next).toBe(state);
|
|
248
|
+
expect(events).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("does not mutate original state", () => {
|
|
252
|
+
const state = buildState([baseItem("m1", { authority: 0.5 })]);
|
|
253
|
+
bulkAdjustScores(state, {}, { authority: 0.3 }, "system:tuner");
|
|
254
|
+
expect(state.items.get("m1")!.authority).toBe(0.5);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { applyCommand } from "../src/reducer.js";
|
|
3
|
+
import { createGraphState } from "../src/graph.js";
|
|
4
|
+
import {
|
|
5
|
+
getItems,
|
|
6
|
+
getScoredItems,
|
|
7
|
+
extractTimestamp,
|
|
8
|
+
} from "../src/query.js";
|
|
9
|
+
import { exportSlice, importSlice } from "../src/transplant.js";
|
|
10
|
+
import {
|
|
11
|
+
createIntentState,
|
|
12
|
+
applyIntentCommand,
|
|
13
|
+
createIntent,
|
|
14
|
+
InvalidIntentTransitionError,
|
|
15
|
+
} from "../src/intent.js";
|
|
16
|
+
import {
|
|
17
|
+
createTaskState,
|
|
18
|
+
applyTaskCommand,
|
|
19
|
+
createTask,
|
|
20
|
+
InvalidTaskTransitionError,
|
|
21
|
+
} from "../src/task.js";
|
|
22
|
+
import {
|
|
23
|
+
surfaceContradictions,
|
|
24
|
+
} from "../src/retrieval.js";
|
|
25
|
+
import { markContradiction } from "../src/integrity.js";
|
|
26
|
+
import type { MemoryItem, Edge, GraphState, ScoredItem } from "../src/types.js";
|
|
27
|
+
|
|
28
|
+
const makeItem = (
|
|
29
|
+
id: string,
|
|
30
|
+
overrides: Partial<MemoryItem> = {},
|
|
31
|
+
): MemoryItem => ({
|
|
32
|
+
id,
|
|
33
|
+
scope: "test",
|
|
34
|
+
kind: "observation",
|
|
35
|
+
content: {},
|
|
36
|
+
author: "agent:a",
|
|
37
|
+
source_kind: "observed",
|
|
38
|
+
authority: 0.5,
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function stateWith(items: MemoryItem[], edges: Edge[] = []): GraphState {
|
|
43
|
+
const s = createGraphState();
|
|
44
|
+
for (const i of items) s.items.set(i.id, i);
|
|
45
|
+
for (const e of edges) s.edges.set(e.edge_id, e);
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fakeIdAtMs(ms: number): string {
|
|
50
|
+
const hex = ms.toString(16).padStart(12, "0");
|
|
51
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================
|
|
55
|
+
// 1. Export with circular parents
|
|
56
|
+
// ============================================================
|
|
57
|
+
|
|
58
|
+
describe("exportSlice with circular parents", () => {
|
|
59
|
+
it("does not infinite loop on circular parent chain", () => {
|
|
60
|
+
const state = stateWith([
|
|
61
|
+
makeItem("m1", { parents: ["m2"] }),
|
|
62
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
63
|
+
]);
|
|
64
|
+
const slice = exportSlice(
|
|
65
|
+
state,
|
|
66
|
+
createIntentState(),
|
|
67
|
+
createTaskState(),
|
|
68
|
+
{ memory_ids: ["m1"], include_parents: true },
|
|
69
|
+
);
|
|
70
|
+
expect(slice.memories.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not infinite loop on circular children", () => {
|
|
74
|
+
const state = stateWith([
|
|
75
|
+
makeItem("m1", { parents: ["m2"] }),
|
|
76
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
77
|
+
]);
|
|
78
|
+
const slice = exportSlice(
|
|
79
|
+
state,
|
|
80
|
+
createIntentState(),
|
|
81
|
+
createTaskState(),
|
|
82
|
+
{ memory_ids: ["m1"], include_children: true },
|
|
83
|
+
);
|
|
84
|
+
expect(slice.memories.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ============================================================
|
|
89
|
+
// 2. Transplant reIdFor produces valid ids
|
|
90
|
+
// ============================================================
|
|
91
|
+
|
|
92
|
+
describe("transplant reIdFor validity", () => {
|
|
93
|
+
it("re-id'd item has a valid extractable timestamp = original + 1ms", () => {
|
|
94
|
+
const originalMs = Date.now() - 5000; // 5 seconds ago
|
|
95
|
+
const originalId = fakeIdAtMs(originalMs);
|
|
96
|
+
const originalItem = makeItem(originalId, { authority: 0.9 });
|
|
97
|
+
|
|
98
|
+
let targetMem = createGraphState();
|
|
99
|
+
targetMem = applyCommand(targetMem, {
|
|
100
|
+
type: "memory.create",
|
|
101
|
+
item: makeItem(originalId, { authority: 0.1 }), // same id, different content
|
|
102
|
+
}).state;
|
|
103
|
+
|
|
104
|
+
const slice = {
|
|
105
|
+
memories: [originalItem],
|
|
106
|
+
edges: [],
|
|
107
|
+
intents: [],
|
|
108
|
+
tasks: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = importSlice(
|
|
112
|
+
targetMem,
|
|
113
|
+
createIntentState(),
|
|
114
|
+
createTaskState(),
|
|
115
|
+
slice,
|
|
116
|
+
{ shallowCompareExisting: true, reIdOnDifference: true },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const newId = result.report.created.memories[0];
|
|
120
|
+
const newTs = extractTimestamp(newId);
|
|
121
|
+
expect(newTs).toBe(originalMs + 1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ============================================================
|
|
126
|
+
// 3. Intent: update on completed/cancelled
|
|
127
|
+
// ============================================================
|
|
128
|
+
|
|
129
|
+
describe("intent update on terminal states", () => {
|
|
130
|
+
it("allows updating a completed intent (no status guard on update)", () => {
|
|
131
|
+
let state = createIntentState();
|
|
132
|
+
state = applyIntentCommand(state, {
|
|
133
|
+
type: "intent.create",
|
|
134
|
+
intent: createIntent({ id: "i1", label: "test", priority: 0.5, owner: "user:laz" }),
|
|
135
|
+
}).state;
|
|
136
|
+
state = applyIntentCommand(state, {
|
|
137
|
+
type: "intent.complete",
|
|
138
|
+
intent_id: "i1",
|
|
139
|
+
author: "test",
|
|
140
|
+
}).state;
|
|
141
|
+
// update should work — it's a field update, not a status transition
|
|
142
|
+
const { state: next } = applyIntentCommand(state, {
|
|
143
|
+
type: "intent.update",
|
|
144
|
+
intent_id: "i1",
|
|
145
|
+
partial: { description: "added after completion" },
|
|
146
|
+
author: "test",
|
|
147
|
+
});
|
|
148
|
+
expect(next.intents.get("i1")!.description).toBe("added after completion");
|
|
149
|
+
expect(next.intents.get("i1")!.status).toBe("completed");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("allows updating a cancelled intent", () => {
|
|
153
|
+
let state = createIntentState();
|
|
154
|
+
state = applyIntentCommand(state, {
|
|
155
|
+
type: "intent.create",
|
|
156
|
+
intent: createIntent({ id: "i1", label: "test", priority: 0.5, owner: "user:laz" }),
|
|
157
|
+
}).state;
|
|
158
|
+
state = applyIntentCommand(state, {
|
|
159
|
+
type: "intent.cancel",
|
|
160
|
+
intent_id: "i1",
|
|
161
|
+
author: "test",
|
|
162
|
+
}).state;
|
|
163
|
+
const { state: next } = applyIntentCommand(state, {
|
|
164
|
+
type: "intent.update",
|
|
165
|
+
intent_id: "i1",
|
|
166
|
+
partial: { meta: { reason: "post-mortem note" } },
|
|
167
|
+
author: "test",
|
|
168
|
+
});
|
|
169
|
+
expect(next.intents.get("i1")!.meta?.reason).toBe("post-mortem note");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ============================================================
|
|
174
|
+
// 4. Task: fail/cancel on invalid states
|
|
175
|
+
// ============================================================
|
|
176
|
+
|
|
177
|
+
describe("task state machine edge cases", () => {
|
|
178
|
+
it("task.fail on pending throws InvalidTaskTransitionError", () => {
|
|
179
|
+
let state = createTaskState();
|
|
180
|
+
state = applyTaskCommand(state, {
|
|
181
|
+
type: "task.create",
|
|
182
|
+
task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
|
|
183
|
+
}).state;
|
|
184
|
+
expect(() =>
|
|
185
|
+
applyTaskCommand(state, { type: "task.fail", task_id: "t1", error: "oops" }),
|
|
186
|
+
).toThrow(InvalidTaskTransitionError);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("task.fail on cancelled throws InvalidTaskTransitionError", () => {
|
|
190
|
+
let state = createTaskState();
|
|
191
|
+
state = applyTaskCommand(state, {
|
|
192
|
+
type: "task.create",
|
|
193
|
+
task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
|
|
194
|
+
}).state;
|
|
195
|
+
state = applyTaskCommand(state, {
|
|
196
|
+
type: "task.cancel",
|
|
197
|
+
task_id: "t1",
|
|
198
|
+
}).state;
|
|
199
|
+
expect(() =>
|
|
200
|
+
applyTaskCommand(state, { type: "task.fail", task_id: "t1", error: "oops" }),
|
|
201
|
+
).toThrow(InvalidTaskTransitionError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("task.update on cancelled task works (field update, not transition)", () => {
|
|
205
|
+
let state = createTaskState();
|
|
206
|
+
state = applyTaskCommand(state, {
|
|
207
|
+
type: "task.create",
|
|
208
|
+
task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
|
|
209
|
+
}).state;
|
|
210
|
+
state = applyTaskCommand(state, {
|
|
211
|
+
type: "task.cancel",
|
|
212
|
+
task_id: "t1",
|
|
213
|
+
}).state;
|
|
214
|
+
const { state: next } = applyTaskCommand(state, {
|
|
215
|
+
type: "task.update",
|
|
216
|
+
task_id: "t1",
|
|
217
|
+
partial: { meta: { cancelled_reason: "no longer needed" } },
|
|
218
|
+
author: "test",
|
|
219
|
+
});
|
|
220
|
+
expect(next.tasks.get("t1")!.meta?.cancelled_reason).toBe("no longer needed");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ============================================================
|
|
225
|
+
// 5. Reducer: nested undefined in content/meta
|
|
226
|
+
// ============================================================
|
|
227
|
+
|
|
228
|
+
describe("reducer nested undefined handling", () => {
|
|
229
|
+
it("strips top-level undefined in content but preserves nested undefined", () => {
|
|
230
|
+
const state = stateWith([
|
|
231
|
+
makeItem("m1", { content: { a: 1, b: 2 } }),
|
|
232
|
+
]);
|
|
233
|
+
const { state: next } = applyCommand(state, {
|
|
234
|
+
type: "memory.update",
|
|
235
|
+
item_id: "m1",
|
|
236
|
+
partial: { content: { a: undefined, c: 3 } },
|
|
237
|
+
author: "test",
|
|
238
|
+
});
|
|
239
|
+
const content = next.items.get("m1")!.content;
|
|
240
|
+
// top-level undefined stripped — 'a' is NOT overwritten
|
|
241
|
+
expect(content.a).toBe(1);
|
|
242
|
+
expect(content.b).toBe(2);
|
|
243
|
+
expect(content.c).toBe(3);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("strips top-level undefined in meta", () => {
|
|
247
|
+
const state = stateWith([
|
|
248
|
+
makeItem("m1", { meta: { agent_id: "agent:x", session_id: "s1" } }),
|
|
249
|
+
]);
|
|
250
|
+
const { state: next } = applyCommand(state, {
|
|
251
|
+
type: "memory.update",
|
|
252
|
+
item_id: "m1",
|
|
253
|
+
partial: { meta: { agent_id: undefined, tag: "new" } },
|
|
254
|
+
author: "test",
|
|
255
|
+
});
|
|
256
|
+
const meta = next.items.get("m1")!.meta!;
|
|
257
|
+
expect(meta.agent_id).toBe("agent:x"); // not overwritten
|
|
258
|
+
expect(meta.session_id).toBe("s1");
|
|
259
|
+
expect(meta.tag).toBe("new");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ============================================================
|
|
264
|
+
// 6. Decay with future items (clock skew)
|
|
265
|
+
// ============================================================
|
|
266
|
+
|
|
267
|
+
describe("decay with future items", () => {
|
|
268
|
+
it("future item gets multiplier of 1 (no decay boost)", () => {
|
|
269
|
+
const futureMs = Date.now() + 60000; // 1 minute in the future
|
|
270
|
+
const futureId = fakeIdAtMs(futureMs);
|
|
271
|
+
const state = stateWith([makeItem(futureId, { authority: 1.0 })]);
|
|
272
|
+
|
|
273
|
+
const result = getScoredItems(state, {
|
|
274
|
+
authority: 1.0,
|
|
275
|
+
decay: { rate: 0.5, interval: "day", type: "exponential" },
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// should be exactly 1.0, not boosted above 1.0
|
|
279
|
+
expect(result[0].score).toBe(1.0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("future item passes decay filter (not excluded)", () => {
|
|
283
|
+
const futureMs = Date.now() + 60000;
|
|
284
|
+
const futureId = fakeIdAtMs(futureMs);
|
|
285
|
+
const state = stateWith([makeItem(futureId)]);
|
|
286
|
+
|
|
287
|
+
const result = getItems(state, {
|
|
288
|
+
decay: {
|
|
289
|
+
config: { rate: 0.5, interval: "day", type: "exponential" },
|
|
290
|
+
min: 0.5,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
expect(result).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ============================================================
|
|
298
|
+
// 7. surfaceContradictions does not mutate input
|
|
299
|
+
// ============================================================
|
|
300
|
+
|
|
301
|
+
describe("surfaceContradictions immutability", () => {
|
|
302
|
+
it("does not mutate the input scored array", () => {
|
|
303
|
+
const state = stateWith([
|
|
304
|
+
makeItem("m1", { authority: 0.9 }),
|
|
305
|
+
makeItem("m2", { authority: 0.7 }),
|
|
306
|
+
]);
|
|
307
|
+
const { state: marked } = markContradiction(state, "m1", "m2", "system:detector");
|
|
308
|
+
|
|
309
|
+
const original: ScoredItem[] = [
|
|
310
|
+
{ item: marked.items.get("m1")!, score: 0.9 },
|
|
311
|
+
{ item: marked.items.get("m2")!, score: 0.7 },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
// save original state
|
|
315
|
+
const m1Before = { ...original[0] };
|
|
316
|
+
|
|
317
|
+
surfaceContradictions(marked, original);
|
|
318
|
+
|
|
319
|
+
// original entries should NOT have contradicted_by
|
|
320
|
+
expect(original[0].contradicted_by).toBeUndefined();
|
|
321
|
+
expect(original[1].contradicted_by).toBeUndefined();
|
|
322
|
+
expect(original[0].score).toBe(m1Before.score);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ============================================================
|
|
327
|
+
// 8. Export/import with circular parents round-trip
|
|
328
|
+
// ============================================================
|
|
329
|
+
|
|
330
|
+
describe("transplant with circular parents", () => {
|
|
331
|
+
it("exports and imports circular parent chain", () => {
|
|
332
|
+
const state = stateWith([
|
|
333
|
+
makeItem("m1", { parents: ["m2"] }),
|
|
334
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
const slice = exportSlice(
|
|
338
|
+
state,
|
|
339
|
+
createIntentState(),
|
|
340
|
+
createTaskState(),
|
|
341
|
+
{ memory_ids: ["m1"], include_parents: true },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const result = importSlice(
|
|
345
|
+
createGraphState(),
|
|
346
|
+
createIntentState(),
|
|
347
|
+
createTaskState(),
|
|
348
|
+
slice,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
expect(result.memState.items.size).toBe(2);
|
|
352
|
+
expect(result.memState.items.get("m1")!.parents).toEqual(["m2"]);
|
|
353
|
+
expect(result.memState.items.get("m2")!.parents).toEqual(["m1"]);
|
|
354
|
+
});
|
|
355
|
+
});
|