@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.
- package/README.md +30 -3
- package/lib/api.js +8 -0
- package/lib/config.js +28 -0
- package/lib/dream/decisions.js +79 -1
- package/lib/index.js +21 -0
- package/lib/service.js +103 -31
- package/lib/sleep.js +461 -0
- package/lib/store.js +186 -41
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/config.js +28 -0
- package/src/dream/decisions.js +79 -1
- package/src/index.js +21 -0
- package/src/service.js +103 -31
- package/src/sleep.js +461 -0
- package/src/store.js +186 -41
- package/test/mirror-generation.test.js +24 -21
- package/test/peer-blockers.test.js +148 -0
- package/test/sleep.test.js +401 -0
|
@@ -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
|
+
});
|