@modusensus/dsh-mneme 0.3.7 → 0.4.1

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.
@@ -0,0 +1,401 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createSleepScheduler, runSleep } from "../src/sleep.js";
6
+ import { validateDecisions } from "../src/dream.js";
7
+
8
+ function setup(config = {}) {
9
+ const store = createStore(":memory:");
10
+ const service = createService({ store, mirror: null, config: { sleepEnabled: true, ...config } });
11
+ return { store, service };
12
+ }
13
+
14
+ /** Backdate a memory's updated_at (last_accessed_at stays null → the sleep
15
+ * tiering reads updated_at as the reference time). */
16
+ function backdate(store, id, days) {
17
+ const iso = new Date(Date.now() - days * 86400000).toISOString();
18
+ store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(iso, id);
19
+ }
20
+
21
+ /** Dummy embedder/vector index: every text maps to the same vector, so any two
22
+ * same-type memories score cosine 1.0 and become conflict candidates. */
23
+ function fakeSemantic() {
24
+ return {
25
+ embedder: {
26
+ embed: async (texts) => texts.map(() => [1, 0, 0])
27
+ },
28
+ vectorIndex: {
29
+ getEmbedding: () => null,
30
+ saveEmbedding: () => {},
31
+ search: () => []
32
+ }
33
+ };
34
+ }
35
+
36
+ /** Deterministic sleep LLM stub: conflict prompt (user text contains 候选冲突)
37
+ * routes to onConflict, everything else to onPattern. */
38
+ function sleepCtx({ onConflict = () => "[]", onPattern = () => "[]" } = {}) {
39
+ return {
40
+ logger: { warn: () => {}, info: () => {} },
41
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
42
+ llm: {
43
+ async *stream(options) {
44
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
45
+ yield {
46
+ type: "text-delta",
47
+ index: 0,
48
+ text: userText.includes("候选冲突") ? onConflict(userText) : onPattern(userText)
49
+ };
50
+ yield { type: "finish", reason: { kind: "stop" } };
51
+ }
52
+ }
53
+ };
54
+ }
55
+
56
+ // ------------------------------------------------------------ scheduler
57
+
58
+ test("scheduler gates: idle + interval both required", async () => {
59
+ const { service } = setup();
60
+ let t = 1_000_000_000; // big enough that ±8h stays positive
61
+ let runs = 0;
62
+ const sleep = createSleepScheduler({
63
+ service,
64
+ config: { sleepEnabled: true, sleepIdleMinutes: 30, sleepMinIntervalHours: 8 },
65
+ onRun: async () => { runs++; return { ok: true }; },
66
+ now: () => t
67
+ });
68
+ assert.equal(sleep.shouldRun(t + 1), false, "just wrote: not idle yet");
69
+ t += 30 * 60000 + 1000; // idle satisfied
70
+ assert.equal(sleep.shouldRun(t), true, "idle met + no prior run → runnable");
71
+ assert.equal(await sleep.maybeSchedule(), true);
72
+ assert.equal(runs, 1);
73
+ assert.equal(sleep.shouldRun(t), false, "interval gate: just ran");
74
+ assert.equal(await sleep.maybeSchedule(), false);
75
+ t += 8 * 3600000 + 1; // interval satisfied
76
+ assert.equal(sleep.shouldRun(t), true, "interval passed → runnable again");
77
+ });
78
+
79
+ test("scheduler: noteWrite clears a pending idle timer and re-arms against the new window", () => {
80
+ const { service } = setup();
81
+ let t = 1_000_000_000;
82
+ let cleared = 0;
83
+ const timers = [];
84
+ const sleep = createSleepScheduler({
85
+ service,
86
+ config: { sleepEnabled: true, sleepIdleMinutes: 30, sleepMinIntervalHours: 8 },
87
+ onRun: async () => ({ ok: true }),
88
+ now: () => t,
89
+ setTimeoutFn: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
90
+ clearTimeoutFn: () => { cleared++; }
91
+ });
92
+ sleep.noteWrite();
93
+ assert.equal(timers.length, 1, "first write arms an idle timer");
94
+ assert.equal(timers[0].ms, 30 * 60000 + 1000, "armed against the full idle window");
95
+ t += 5 * 60000;
96
+ sleep.noteWrite();
97
+ assert.equal(cleared, 1, "stale timer cleared, not left to fire early");
98
+ assert.equal(timers.length, 2, "fresh timer armed on the write");
99
+ assert.equal(timers[1].ms, 30 * 60000 + 1000, "re-armed against a full idle window from the write");
100
+ });
101
+
102
+ test("scheduler: noteWrite resets the idle clock", () => {
103
+ const { service } = setup();
104
+ let t = 1_000_000_000;
105
+ let runs = 0;
106
+ const sleep = createSleepScheduler({
107
+ service,
108
+ config: { sleepEnabled: true, sleepIdleMinutes: 30, sleepMinIntervalHours: 8 },
109
+ onRun: async () => { runs++; return { ok: true }; },
110
+ now: () => t
111
+ });
112
+ t += 30 * 60000 + 1000;
113
+ sleep.noteWrite(); // a write arrives: idle clock resets
114
+ assert.equal(sleep.shouldRun(t), false, "idle reset by noteWrite");
115
+ });
116
+
117
+ test("scheduler: disabled → never runs", () => {
118
+ const { service } = setup();
119
+ const sleep = createSleepScheduler({
120
+ service,
121
+ config: { sleepEnabled: false, sleepIdleMinutes: 0, sleepMinIntervalHours: 0 },
122
+ onRun: async () => ({ ok: true }),
123
+ now: () => 1_000_000
124
+ });
125
+ assert.equal(sleep.shouldRun(), false);
126
+ });
127
+
128
+ test("scheduler: onRun failure is swallowed, next window still opens", async () => {
129
+ const { service } = setup();
130
+ let t = 1_000_000_000;
131
+ let calls = 0;
132
+ const sleep = createSleepScheduler({
133
+ service,
134
+ config: { sleepEnabled: true, sleepIdleMinutes: 1, sleepMinIntervalHours: 1 },
135
+ onRun: async () => { calls++; if (calls === 1) throw new Error("boom"); return { ok: true }; },
136
+ now: () => t
137
+ });
138
+ t += 60 * 60000;
139
+ assert.equal(await sleep.maybeSchedule(), false, "throwing run reports false");
140
+ assert.equal(calls, 1);
141
+ t += 3600000;
142
+ assert.equal(await sleep.maybeSchedule(), true, "next window still opens");
143
+ assert.equal(calls, 2);
144
+ });
145
+
146
+ // ------------------------------------------------------------ demotion
147
+
148
+ test("phase demotion: 30d → summary + _full_content, 90d → archived", async () => {
149
+ const { store, service } = setup({ sleepArchiveDays: 30, sleepDeepArchiveDays: 90 });
150
+ const long = "X".repeat(200);
151
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "A", content: long });
152
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "B", content: "b content" });
153
+ const { memory: c } = service.saveWithDedupe({ type: "project", title: "C", content: "c content" });
154
+ backdate(store, a.id, 40);
155
+ backdate(store, b.id, 100);
156
+ // c stays fresh
157
+ const ctx = sleepCtx();
158
+ const result = await runSleep(ctx, service, { sleepEnabled: true, sleepArchiveDays: 30, sleepDeepArchiveDays: 90, policyEpoch: 1 }, ctx.logger, fakeSemantic());
159
+ assert.ok(result.phases.demotion.demoted.includes(a.id), "40d unaccessed demoted");
160
+ assert.ok(result.phases.demotion.archived.includes(b.id), "100d unaccessed archived");
161
+ const aNow = store.getById(a.id);
162
+ assert.equal(aNow.content, `${"X".repeat(120)}…`, "content truncated to summary");
163
+ assert.equal(aNow._full_content, long, "full body preserved in _full_content");
164
+ assert.equal(store.getById(b.id).archived, true);
165
+ assert.equal(store.getById(c.id).archived, false);
166
+ assert.equal(store.getById(c.id)._full_content, undefined);
167
+ });
168
+
169
+ test("demoteToSummary: minRefTimeMs skips freshly-accessed memories", () => {
170
+ const store = createStore(":memory:");
171
+ const a = store.save({ type: "project", title: "A", content: "x".repeat(200), importance: 3, tags: [], source: "test" });
172
+ const b = store.save({ type: "project", title: "B", content: "y".repeat(200), importance: 3, tags: [], source: "test" });
173
+ backdate(store, a.id, 40);
174
+ backdate(store, b.id, 40);
175
+ const cutoff = Date.now() - 30 * 86400000;
176
+ // b was touched after the cutoff → must NOT be demoted.
177
+ store.touchAccess(b.id);
178
+ const aAfter = store.demoteToSummary(a.id, "A summary", { minRefTimeMs: cutoff });
179
+ const bAfter = store.demoteToSummary(b.id, "B summary", { minRefTimeMs: cutoff });
180
+ assert.ok(aAfter._full_content, "old memory demoted");
181
+ assert.equal(bAfter._full_content, undefined, "freshly-accessed memory kept full");
182
+ });
183
+
184
+ test("phase demotion: never double-wraps an already-demoted memory", async () => {
185
+ const { store, service } = setup({ sleepArchiveDays: 30, sleepDeepArchiveDays: 90 });
186
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "A", content: "y".repeat(200) });
187
+ backdate(store, a.id, 40);
188
+ const ctx = sleepCtx();
189
+ const first = await runSleep(ctx, service, { sleepEnabled: true, sleepArchiveDays: 30, sleepDeepArchiveDays: 90 }, ctx.logger, fakeSemantic());
190
+ const demoted1 = store.getById(a.id);
191
+ assert.equal(demoted1._full_content, "y".repeat(200));
192
+ const second = await runSleep(ctx, service, { sleepEnabled: true, sleepArchiveDays: 30, sleepDeepArchiveDays: 90 }, ctx.logger, fakeSemantic());
193
+ const demoted2 = store.getById(a.id);
194
+ assert.equal(demoted2.content, demoted1.content, "content unchanged on replay");
195
+ assert.equal(demoted2._full_content, "y".repeat(200), "_full_content not re-wrapped");
196
+ assert.equal(first.phases.demotion.demoted.length, 1);
197
+ assert.equal(second.phases.demotion.demoted.length, 0, "replay demotes nothing");
198
+ });
199
+
200
+ // ------------------------------------------------------------ patterns
201
+
202
+ test("phase patterns: LLM mints pattern memories with evidence tags + sleep audit", async () => {
203
+ const { store, service } = setup({ sleepMaxPatterns: 5, policyEpoch: 1 });
204
+ const { memory: m1 } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
205
+ const { memory: m2 } = service.saveWithDedupe({ type: "project", title: "插件", content: "mneme" });
206
+ const ctx = sleepCtx({
207
+ onPattern: (text) => {
208
+ const ids = [...text.matchAll(/id=([^\s|]+)/g)].map((x) => x[1]);
209
+ assert.ok(ids.includes(m1.id) && ids.includes(m2.id), "pattern prompt lists both memories");
210
+ return JSON.stringify([
211
+ { action: "create", type: "pattern", title: "中文偏好", content: "用户偏好中文内容", importance: 3, evidence: [m1.id] }
212
+ ]);
213
+ }
214
+ });
215
+ const result = await runSleep(ctx, service, { sleepEnabled: true, sleepMaxPatterns: 5, policyEpoch: 1 }, ctx.logger);
216
+ assert.equal(result.phases.patterns.status, "ok");
217
+ assert.equal(result.phases.patterns.applied, 1);
218
+ const patterns = store.list({ type: "pattern" });
219
+ assert.equal(patterns.length, 1);
220
+ assert.equal(patterns[0].title, "中文偏好");
221
+ assert.ok(patterns[0].tags.includes(`ev:${m1.id}`), "evidence ref stored as tag");
222
+ // sleep run is audited with run_type=sleep
223
+ const runs = store.listDreamRuns();
224
+ assert.ok(runs.some((r) => r.run_type === "sleep" && r.status === "ok"), "sleep audit row written");
225
+ });
226
+
227
+ test("phase patterns: invalid LLM output fails the phase but not the run", async () => {
228
+ const { store, service } = setup();
229
+ const { memory: m1 } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
230
+ const ctx = sleepCtx({
231
+ onPattern: () => "[{ \"action\": \"create\", \"content\": \"no title\" }]"
232
+ });
233
+ const result = await runSleep(ctx, service, { sleepEnabled: true, sleepMaxPatterns: 5 }, ctx.logger);
234
+ assert.equal(result.phases.patterns.status, "failed");
235
+ assert.equal(result.status, "failed", "only substantive phase failed → failed");
236
+ assert.equal(store.list({ type: "pattern" }).length, 0, "nothing created on invalid output");
237
+ assert.equal(result.ok, false, "failed run reports ok=false");
238
+ });
239
+
240
+ test("phase patterns: fabricated evidence ids are filtered out", async () => {
241
+ const { store, service } = setup({ sleepMaxPatterns: 5 });
242
+ const { memory: m1 } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
243
+ const ctx = sleepCtx({
244
+ onPattern: () => JSON.stringify([
245
+ { action: "create", type: "pattern", title: "偏好", content: "中文内容偏好", importance: 3, evidence: [m1.id, "made-up-id-123"] }
246
+ ])
247
+ });
248
+ const result = await runSleep(ctx, service, { sleepEnabled: true, sleepMaxPatterns: 5 }, ctx.logger);
249
+ assert.equal(result.phases.patterns.status, "ok");
250
+ const patterns = store.list({ type: "pattern" });
251
+ assert.equal(patterns.length, 1);
252
+ assert.ok(patterns[0].tags.includes(`ev:${m1.id}`), "real evidence kept");
253
+ assert.ok(!patterns[0].tags.some((t) => t === "ev:made-up-id-123"), "fabricated evidence dropped");
254
+ });
255
+
256
+ // ------------------------------------------------------------ conflicts
257
+
258
+ test("phase conflicts (freeze mode): conflicting pairs parked for review", async () => {
259
+ const { store, service } = setup({ conflictFreezeEnabled: true });
260
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日" });
261
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日" });
262
+ const ctx = sleepCtx();
263
+ const result = await runSleep(ctx, service, { sleepEnabled: true, conflictFreezeEnabled: true }, ctx.logger, fakeSemantic());
264
+ assert.equal(result.phases.conflicts.status, "ok");
265
+ assert.equal(result.phases.conflicts.frozen, 1);
266
+ assert.equal(service.countConflictPending(), 1, "conflict parked");
267
+ // no auto-arbitration in freeze mode
268
+ assert.equal(store.getById(l.id).archived, false);
269
+ });
270
+
271
+ test("phase conflicts (LLM): winner kept, loser archived", async () => {
272
+ const { store, service } = setup();
273
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日" });
274
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日" });
275
+ const ctx = sleepCtx({
276
+ onConflict: () => JSON.stringify([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }])
277
+ });
278
+ const result = await runSleep(ctx, service, { sleepEnabled: true }, ctx.logger, fakeSemantic());
279
+ assert.equal(result.phases.conflicts.status, "ok");
280
+ assert.equal(result.phases.conflicts.applied, 1);
281
+ assert.equal(store.getById(l.id).archived, true, "loser archived");
282
+ assert.equal(store.getById(w.id).archived, false, "winner kept");
283
+ });
284
+
285
+ test("phase conflicts: skipped without a semantic embedder", async () => {
286
+ const { service } = setup();
287
+ const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日" });
288
+ const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日" });
289
+ const ctx = sleepCtx();
290
+ const result = await runSleep(ctx, service, { sleepEnabled: true }, ctx.logger, null);
291
+ assert.equal(result.phases.conflicts.status, "skipped");
292
+ });
293
+
294
+ test("phase conflicts: LLM omitting a pair defaults it to keep, phase survives", async () => {
295
+ const { store, service } = setup();
296
+ // 4 same-type memories → 2 deduped conflict pairs. The LLM only adjudicates
297
+ // the first pair; the second pair's ids must be defaulted to keep, not fail
298
+ // the whole phase with "missing from decisions".
299
+ const ids = [];
300
+ for (let i = 0; i < 4; i++) {
301
+ const { memory } = service.saveWithDedupe({ type: "decision", title: `D${i}`, content: `内容 ${i}` });
302
+ ids.push(memory.id);
303
+ }
304
+ const ctx = sleepCtx({
305
+ onConflict: (text) => {
306
+ const [first, second] = [...text.matchAll(/id=([^\s|]+)/g)].map((m) => m[1]);
307
+ assert.ok(first && second, "conflict prompt shows a pair");
308
+ return JSON.stringify([{ action: "conflict", winner: first, loser: second, reason: "矛盾" }]);
309
+ }
310
+ });
311
+ const result = await runSleep(ctx, service, { sleepEnabled: true }, ctx.logger, fakeSemantic());
312
+ assert.equal(result.phases.conflicts.status, "ok", "phase survives partial LLM coverage");
313
+ assert.equal(result.phases.conflicts.applied, 1, "only the adjudicated pair changed");
314
+ });
315
+
316
+ test("phase conflicts: empty LLM response → all pairs kept, phase is a noop not a failure", async () => {
317
+ const { store, service } = setup();
318
+ service.saveWithDedupe({ type: "decision", title: "D1", content: "内容 1" });
319
+ service.saveWithDedupe({ type: "decision", title: "D2", content: "内容 2" });
320
+ const ctx = sleepCtx({ onConflict: () => "[]" });
321
+ const result = await runSleep(ctx, service, { sleepEnabled: true }, ctx.logger, fakeSemantic());
322
+ assert.equal(result.phases.conflicts.status, "noop", "no decisions → nothing changed, not failed");
323
+ assert.equal(result.phases.conflicts.applied, 0);
324
+ });
325
+
326
+ // ------------------------------------------------------------ create validation
327
+
328
+ test("validateDecisions: valid create passes with empty snapshot", () => {
329
+ const { ok, errors } = validateDecisions(
330
+ [{ action: "create", type: "pattern", title: "P", content: "c", importance: 3, evidence: ["x"] }],
331
+ new Map(),
332
+ { maxCreatePerRun: 5 }
333
+ );
334
+ assert.equal(ok, true, errors.join("; "));
335
+ });
336
+
337
+ test("validateDecisions: create rejects missing title/content and over the cap", () => {
338
+ const empty = new Map();
339
+ const { ok: noTitle } = validateDecisions([{ action: "create", title: "", content: "c" }], empty);
340
+ assert.equal(noTitle, false);
341
+ const { ok: noContent } = validateDecisions([{ action: "create", title: "P" }], empty);
342
+ assert.equal(noContent, false);
343
+ const { ok: badType } = validateDecisions([{ action: "create", type: "explode", title: "P", content: "c" }], empty);
344
+ assert.equal(badType, false);
345
+ const cap = validateDecisions(
346
+ Array.from({ length: 6 }, (_, i) => ({ action: "create", title: `P${i}`, content: "c" })),
347
+ empty,
348
+ { maxCreatePerRun: 5 }
349
+ );
350
+ assert.equal(cap.ok, false, "exceeding maxCreatePerRun rejects");
351
+ });
352
+
353
+ test("validateDecisions: create claims no ids, so it can't cover snapshot memories", () => {
354
+ const snap = new Map([["a", { id: "a", type: "preference", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
355
+ const { ok, errors } = validateDecisions([{ action: "create", title: "P", content: "c" }], snap);
356
+ assert.equal(ok, false);
357
+ assert.ok(errors.some((e) => e.includes("missing from decisions")), "create cannot claim snapshot ids");
358
+ });
359
+
360
+ // ------------------------------------------------------------ touch wiring
361
+
362
+ test("searchMemories touches last_accessed_at when sleep enabled", async () => {
363
+ const { store, service } = setup({ sleepEnabled: true });
364
+ const { memory } = service.saveWithDedupe({ type: "project", title: "插件", content: "y" });
365
+ assert.equal(store.getById(memory.id).last_accessed_at, undefined);
366
+ await service.searchMemories("插件");
367
+ assert.ok(store.getById(memory.id).last_accessed_at, "recalled memory touched");
368
+ });
369
+
370
+ test("searchMemories does NOT touch when sleep disabled", async () => {
371
+ const store = createStore(":memory:");
372
+ const service = createService({ store, mirror: null, config: {} });
373
+ const { memory } = service.saveWithDedupe({ type: "project", title: "插件", content: "y" });
374
+ await service.searchMemories("插件");
375
+ assert.equal(store.getById(memory.id).last_accessed_at, undefined, "no touch when sleep off");
376
+ });
377
+
378
+ test("injectCandidates touches injected items when sleep enabled", () => {
379
+ const { store, service } = setup({ sleepEnabled: true });
380
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
381
+ const injected = service.injectCandidates();
382
+ assert.ok(injected.length >= 1);
383
+ assert.ok(store.getById(injected[0].id).last_accessed_at, "injected memory touched");
384
+ });
385
+
386
+ // ------------------------------------------------------------ whole-run
387
+
388
+ test("runSleep with empty store: all phases skip, status noop", async () => {
389
+ const { store, service } = setup();
390
+ const ctx = sleepCtx();
391
+ const result = await runSleep(ctx, service, { sleepEnabled: true }, ctx.logger);
392
+ assert.equal(result.status, "noop");
393
+ assert.equal(result.ok, false);
394
+ assert.equal(result.phases.conflicts.status, "skipped");
395
+ assert.equal(result.phases.demotion.status, "noop");
396
+ assert.equal(result.phases.patterns.status, "skipped");
397
+ const runs = store.listDreamRuns();
398
+ assert.equal(runs.length, 1);
399
+ assert.equal(runs[0].run_type, "sleep");
400
+ assert.equal(runs[0].status, "noop");
401
+ });