@modusensus/dsh-mneme 0.2.4 → 0.2.6
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 +10 -9
- package/lib/client.js +2 -2
- package/lib/config.js +5 -2
- package/lib/dream/decisions.js +174 -62
- package/lib/dream.js +30 -5
- package/lib/index.js +3 -1
- package/lib/mirror.js +7 -1
- package/lib/service.js +117 -3
- package/lib/store.js +40 -0
- package/lib/tools.js +47 -4
- package/package.json +3 -1
- package/scripts/benchmark-embed.js +201 -0
- package/scripts/benchmark-rerank.js +166 -0
- package/scripts/e2e-dsh.js +216 -0
- package/scripts/stress-dsh.js +255 -0
- package/scripts/sync-lib.js +47 -0
- package/src/config.js +5 -2
- package/src/dream/decisions.js +174 -62
- package/src/dream.js +30 -5
- package/src/index.js +3 -1
- package/src/mirror.js +7 -1
- package/src/service.js +117 -3
- package/src/store.js +40 -0
- package/src/tools.js +47 -4
- package/test/api.test.js +385 -0
- package/test/audit.test.js +290 -0
- package/test/client.test.js +44 -0
- package/test/clustering.test.js +100 -0
- package/test/commands.test.js +69 -0
- package/test/config.test.js +31 -0
- package/test/dream.test.js +526 -0
- package/test/helpers/dream-mock.js +82 -0
- package/test/inject.test.js +82 -0
- package/test/local-embedder.test.js +227 -0
- package/test/mirror.test.js +249 -0
- package/test/reflection.test.js +226 -0
- package/test/reranker.test.js +197 -0
- package/test/semantic.test.js +123 -0
- package/test/service-search.test.js +169 -0
- package/test/service.test.js +198 -0
- package/test/settings.test.js +101 -0
- package/test/store.test.js +293 -0
- package/test/stress.test.js +209 -0
- package/test/summarize.test.js +156 -0
- package/test/tools.test.js +265 -0
- package/test/vector-index.test.js +205 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateDecisions, applyDecisions, createDreamScheduler } from "../src/dream.js";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
|
|
7
|
+
function snapshot(ids, type = "project") {
|
|
8
|
+
return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("valid decision list passes", () => {
|
|
12
|
+
const snap = snapshot(["a", "b", "c"]);
|
|
13
|
+
const decisions = [
|
|
14
|
+
{ action: "keep", ids: ["a"], reason: "ok" },
|
|
15
|
+
{ action: "merge", ids: ["b", "c"], title: "bc", content: "merged", importance: 4, keepSource: "b" }
|
|
16
|
+
];
|
|
17
|
+
const { ok, errors } = validateDecisions(decisions, snap);
|
|
18
|
+
assert.equal(ok, true);
|
|
19
|
+
assert.deepEqual(errors, []);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("unknown id rejects whole list", () => {
|
|
23
|
+
const snap = snapshot(["a"]);
|
|
24
|
+
const { ok, errors } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
|
|
25
|
+
assert.equal(ok, false);
|
|
26
|
+
assert.ok(errors.some((e) => e.includes("zzz")));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("invalid action rejects", () => {
|
|
30
|
+
const snap = snapshot(["a"]);
|
|
31
|
+
const { ok } = validateDecisions([{ action: "explode", ids: ["a"] }], snap);
|
|
32
|
+
assert.equal(ok, false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("merge keepSource must be in ids", () => {
|
|
36
|
+
const snap = snapshot(["a", "b"]);
|
|
37
|
+
const { ok } = validateDecisions([{ action: "merge", ids: ["a"], keepSource: "b", title: "t", content: "c" }], snap);
|
|
38
|
+
assert.equal(ok, false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("conflict winner and loser must exist and differ", () => {
|
|
42
|
+
const snap = snapshot(["a", "b"]);
|
|
43
|
+
const { ok } = validateDecisions([{ action: "conflict", winner: "a", loser: "a" }], snap);
|
|
44
|
+
assert.equal(ok, false);
|
|
45
|
+
const { ok: ok2 } = validateDecisions([{ action: "conflict", winner: "a", loser: "zzz" }], snap);
|
|
46
|
+
assert.equal(ok2, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("duplicate primary ids across decisions reject", () => {
|
|
50
|
+
const snap = snapshot(["a", "b"]);
|
|
51
|
+
const { ok } = validateDecisions([
|
|
52
|
+
{ action: "archive", ids: ["a"] },
|
|
53
|
+
{ action: "keep", ids: ["a"] }
|
|
54
|
+
], snap);
|
|
55
|
+
assert.equal(ok, false, "a claimed twice");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("archived or summary entries cannot be decision targets", () => {
|
|
59
|
+
const snap = new Map([["arch", { id: "arch", type: "project", title: "t", content: "c", importance: 3, archived: true, forgotten: false }]]);
|
|
60
|
+
const { ok } = validateDecisions([{ action: "archive", ids: ["arch"] }], snap);
|
|
61
|
+
assert.equal(ok, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("empty decision list rejects", () => {
|
|
65
|
+
const { ok, errors } = validateDecisions([], snapshot(["a"]));
|
|
66
|
+
assert.equal(ok, false);
|
|
67
|
+
assert.ok(errors.some((e) => e.includes("non-empty array")));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("empty ids rejects", () => {
|
|
71
|
+
const { ok } = validateDecisions([{ action: "archive", ids: [] }], snapshot(["a"]));
|
|
72
|
+
assert.equal(ok, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("merge requires non-empty title and content", () => {
|
|
76
|
+
const snap = snapshot(["a"]);
|
|
77
|
+
const base = { action: "merge", ids: ["a"], keepSource: "a" };
|
|
78
|
+
for (const [title, content] of [["", "x"], ["t", ""], [undefined, "x"], ["t", undefined]]) {
|
|
79
|
+
const { ok } = validateDecisions([{ ...base, title, content }], snap);
|
|
80
|
+
assert.equal(ok, false, `title=${JSON.stringify(title)} content=${JSON.stringify(content)}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("summary entries cannot be decision targets", () => {
|
|
85
|
+
const snap = new Map([["s", { id: "s", type: "summary", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
|
|
86
|
+
const { ok } = validateDecisions([{ action: "archive", ids: ["s"] }], snap);
|
|
87
|
+
assert.equal(ok, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("every snapshot memory must be covered by a decision", () => {
|
|
91
|
+
const snap = snapshot(["a", "b"]);
|
|
92
|
+
const { ok, errors } = validateDecisions([{ action: "keep", ids: ["a"] }], snap);
|
|
93
|
+
assert.equal(ok, false);
|
|
94
|
+
assert.ok(errors.some((e) => e.includes("missing from decisions")));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("duplicate ids within one decision reject", () => {
|
|
98
|
+
const { ok } = validateDecisions([{ action: "keep", ids: ["a", "a"] }], snapshot(["a"]));
|
|
99
|
+
assert.equal(ok, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("merge importance out of range rejects", () => {
|
|
103
|
+
const snap = snapshot(["a", "b"]);
|
|
104
|
+
for (const importance of [0, 6, 99, 1.5, "4"]) {
|
|
105
|
+
const { ok, errors } = validateDecisions([
|
|
106
|
+
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance }
|
|
107
|
+
], snap);
|
|
108
|
+
assert.equal(ok, false, `importance=${JSON.stringify(importance)} rejected`);
|
|
109
|
+
assert.ok(errors.some((e) => e.includes("importance")), `importance error present for ${JSON.stringify(importance)}`);
|
|
110
|
+
}
|
|
111
|
+
const { ok } = validateDecisions([
|
|
112
|
+
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance: 5 }
|
|
113
|
+
], snap);
|
|
114
|
+
assert.equal(ok, true, "importance 5 accepted");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("merge across types rejects", () => {
|
|
118
|
+
const snap = new Map([
|
|
119
|
+
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
120
|
+
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
|
|
121
|
+
]);
|
|
122
|
+
const { ok, errors } = validateDecisions([
|
|
123
|
+
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
|
|
124
|
+
], snap);
|
|
125
|
+
assert.equal(ok, false, "cross-type merge rejected");
|
|
126
|
+
assert.ok(errors.some((e) => e.includes("multiple types")), "multi-type error present");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function dreamSetup() {
|
|
130
|
+
const store = createStore(":memory:");
|
|
131
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
132
|
+
return { store, service };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("applyDecisions merges: keepSource updated, others archived", () => {
|
|
136
|
+
const { store, service } = dreamSetup();
|
|
137
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
138
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
139
|
+
const { applied } = applyDecisions([
|
|
140
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
141
|
+
], service);
|
|
142
|
+
assert.equal(applied, 1);
|
|
143
|
+
const keeper = store.getById(b.id);
|
|
144
|
+
assert.equal(keeper.content, "合并内容");
|
|
145
|
+
assert.equal(keeper.title, "插件总览");
|
|
146
|
+
assert.equal(keeper.importance, 5);
|
|
147
|
+
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("applyDecisions conflict: winner kept, loser archived with provenance", () => {
|
|
151
|
+
const { store, service } = dreamSetup();
|
|
152
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
153
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
154
|
+
applyDecisions([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }], service);
|
|
155
|
+
assert.equal(store.getById(l.id).archived, true);
|
|
156
|
+
const winner = store.getById(w.id);
|
|
157
|
+
assert.ok(winner.content.includes("8月20日"), "winner content intact");
|
|
158
|
+
assert.ok(winner.content.includes("已否决旧信息"), "provenance note appended");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("applyDecisions archive and keep", () => {
|
|
162
|
+
const { store, service } = dreamSetup();
|
|
163
|
+
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
164
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "废弃", content: "过时" });
|
|
165
|
+
applyDecisions([
|
|
166
|
+
{ action: "keep", ids: [k.id] },
|
|
167
|
+
{ action: "archive", ids: [a.id], reason: "过时" }
|
|
168
|
+
], service);
|
|
169
|
+
assert.equal(store.getById(k.id).archived, false);
|
|
170
|
+
assert.equal(store.getById(a.id).archived, true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("applyDecisions returns count and never throws on unknown id (skip)", () => {
|
|
174
|
+
const { store, service } = dreamSetup();
|
|
175
|
+
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
176
|
+
const { applied } = applyDecisions([{ action: "archive", ids: ["ghost"], reason: "x" }], service);
|
|
177
|
+
assert.equal(applied, 0);
|
|
178
|
+
assert.equal(store.getById(k.id).archived, false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("applyDecisions catch path: throwing decision is skipped, logged, and later decisions still apply", () => {
|
|
182
|
+
const { store, service } = dreamSetup();
|
|
183
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "会炸", content: "x" });
|
|
184
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "正常", content: "y" });
|
|
185
|
+
const originalSetArchived = service.setArchived;
|
|
186
|
+
let calls = 0;
|
|
187
|
+
service.setArchived = (id, archived) => {
|
|
188
|
+
calls++;
|
|
189
|
+
if (calls === 1) throw new Error("boom");
|
|
190
|
+
return originalSetArchived.call(service, id, archived);
|
|
191
|
+
};
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const logger = { warn: (msg) => warnings.push(msg) };
|
|
194
|
+
const { applied, failures } = applyDecisions([
|
|
195
|
+
{ action: "archive", ids: [a.id], reason: "x" },
|
|
196
|
+
{ action: "archive", ids: [b.id], reason: "y" }
|
|
197
|
+
], service, logger);
|
|
198
|
+
assert.equal(applied, 1, "throwing decision not counted, surviving decision counted");
|
|
199
|
+
assert.equal(failures.length, 1, "thrown decision reported as a failure");
|
|
200
|
+
assert.equal(store.getById(a.id).archived, false, "throwing decision left no partial effect");
|
|
201
|
+
assert.equal(store.getById(b.id).archived, true, "later decision still applied");
|
|
202
|
+
assert.equal(warnings.length, 1, "logger called once");
|
|
203
|
+
assert.match(warnings[0], /failed to apply archive at index 0: boom/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("applyDecisions conflict with missing loser skips cleanly", () => {
|
|
207
|
+
const { store, service } = dreamSetup();
|
|
208
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
209
|
+
const { applied } = applyDecisions([{ action: "conflict", winner: w.id, loser: "ghost", reason: "x" }], service);
|
|
210
|
+
assert.equal(applied, 0);
|
|
211
|
+
assert.equal(store.getById(w.id).archived, false, "winner untouched");
|
|
212
|
+
assert.ok(!store.getById(w.id).content.includes("已否决"), "no provenance note appended");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("applyDecisions merge with missing keeper skips cleanly", () => {
|
|
216
|
+
const { store, service } = dreamSetup();
|
|
217
|
+
const { applied } = applyDecisions([
|
|
218
|
+
{ action: "merge", ids: ["ghost"], keepSource: "ghost", title: "t", content: "c" }
|
|
219
|
+
], service);
|
|
220
|
+
assert.equal(applied, 0);
|
|
221
|
+
assert.equal(store.getById("ghost"), undefined);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("applyDecisions merge without importance falls back to max source importance", () => {
|
|
225
|
+
const { store, service } = dreamSetup();
|
|
226
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
227
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
228
|
+
const { applied } = applyDecisions([
|
|
229
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", keepSource: b.id }
|
|
230
|
+
], service);
|
|
231
|
+
assert.equal(applied, 1);
|
|
232
|
+
assert.equal(store.getById(b.id).importance, 4, "keeper keeps max of source importances");
|
|
233
|
+
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("maybeSchedule triggers when count exceeds threshold", () => {
|
|
237
|
+
const { store, service } = dreamSetup();
|
|
238
|
+
let runs = 0;
|
|
239
|
+
const dream = createDreamScheduler({
|
|
240
|
+
onRun: async () => { runs++; },
|
|
241
|
+
thresholdCount: 3,
|
|
242
|
+
thresholdChars: 5000,
|
|
243
|
+
delayMs: 0
|
|
244
|
+
});
|
|
245
|
+
for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(100) });
|
|
246
|
+
const pending = dream.maybeSchedule(service);
|
|
247
|
+
assert.equal(pending, true, "scheduled");
|
|
248
|
+
assert.equal(runs, 0, "not run yet (async)");
|
|
249
|
+
store.close();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("maybeSchedule does not trigger below threshold", () => {
|
|
253
|
+
const { store, service } = dreamSetup();
|
|
254
|
+
const dream = createDreamScheduler({ onRun: async () => {}, thresholdCount: 10, thresholdChars: 5000, delayMs: 0 });
|
|
255
|
+
service.saveWithDedupe({ type: "project", title: "only", content: "x" });
|
|
256
|
+
assert.equal(dream.maybeSchedule(service), false);
|
|
257
|
+
store.close();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("scheduler fires async and resets baseline", async () => {
|
|
261
|
+
const { store, service } = dreamSetup();
|
|
262
|
+
let runs = 0;
|
|
263
|
+
const dream = createDreamScheduler({
|
|
264
|
+
onRun: async () => { runs++; return { ok: true }; },
|
|
265
|
+
thresholdCount: 2, thresholdChars: 5000, delayMs: 5
|
|
266
|
+
});
|
|
267
|
+
for (let i = 0; i < 2; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) });
|
|
268
|
+
dream.maybeSchedule(service);
|
|
269
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
270
|
+
assert.equal(runs, 1, "ran once");
|
|
271
|
+
// still above threshold but baseline reset → no immediate re-trigger
|
|
272
|
+
assert.equal(dream.maybeSchedule(service), false, "baseline prevents loop");
|
|
273
|
+
store.close();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("failed run does not refresh baseline: next write re-triggers", async () => {
|
|
277
|
+
const { store, service } = dreamSetup();
|
|
278
|
+
let calls = 0;
|
|
279
|
+
const dream = createDreamScheduler({
|
|
280
|
+
onRun: async () => {
|
|
281
|
+
calls++;
|
|
282
|
+
return { ok: false, error: "llm failed" };
|
|
283
|
+
},
|
|
284
|
+
thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
|
|
285
|
+
logger: { warn: () => {} }
|
|
286
|
+
});
|
|
287
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
|
|
288
|
+
service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
|
|
289
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
290
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
291
|
+
assert.equal(calls, 1, "run attempted once");
|
|
292
|
+
// baseline NOT refreshed on failure → the same write volume still triggers
|
|
293
|
+
assert.equal(dream.maybeSchedule(service), true, "failed run keeps baseline, re-schedules");
|
|
294
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
295
|
+
assert.equal(calls, 2, "retried after failure");
|
|
296
|
+
store.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("throwing run does not refresh baseline and is logged", async () => {
|
|
300
|
+
const { store, service } = dreamSetup();
|
|
301
|
+
const warnings = [];
|
|
302
|
+
let calls = 0;
|
|
303
|
+
const dream = createDreamScheduler({
|
|
304
|
+
onRun: async () => {
|
|
305
|
+
calls++;
|
|
306
|
+
throw new Error("boom");
|
|
307
|
+
},
|
|
308
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
309
|
+
logger: { warn: (msg) => warnings.push(msg) }
|
|
310
|
+
});
|
|
311
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
312
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
313
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
314
|
+
assert.equal(calls, 1, "run attempted once");
|
|
315
|
+
assert.ok(warnings.some((m) => m.includes("run failed")), "throw logged");
|
|
316
|
+
assert.equal(dream.maybeSchedule(service), true, "throw keeps baseline, re-schedules");
|
|
317
|
+
store.close();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("maybeSchedule returns false while a run is in flight", async () => {
|
|
321
|
+
const { store, service } = dreamSetup();
|
|
322
|
+
let release;
|
|
323
|
+
const gate = new Promise((r) => { release = r; });
|
|
324
|
+
let entered = false;
|
|
325
|
+
const dream = createDreamScheduler({
|
|
326
|
+
onRun: async () => { entered = true; await gate; },
|
|
327
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
328
|
+
logger: { warn: () => {} }
|
|
329
|
+
});
|
|
330
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
331
|
+
assert.equal(dream.maybeSchedule(service), true, "first schedule accepted");
|
|
332
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
333
|
+
assert.equal(entered, true, "run started");
|
|
334
|
+
assert.equal(dream.maybeSchedule(service), false, "no schedule while running");
|
|
335
|
+
release();
|
|
336
|
+
await new Promise((r) => setTimeout(r, 10)); // let the run finish + baseline refresh
|
|
337
|
+
store.close();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("dispose clears pending timer and blocks future scheduling", async () => {
|
|
341
|
+
const { store, service } = dreamSetup();
|
|
342
|
+
let runs = 0;
|
|
343
|
+
const dream = createDreamScheduler({
|
|
344
|
+
onRun: async () => { runs++; },
|
|
345
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
346
|
+
logger: { warn: () => {} }
|
|
347
|
+
});
|
|
348
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
349
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
350
|
+
dream.dispose();
|
|
351
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
352
|
+
assert.equal(runs, 0, "pending run cancelled by dispose");
|
|
353
|
+
assert.equal(dream.maybeSchedule(service), false, "disposed scheduler never schedules");
|
|
354
|
+
store.close();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("dispose awaits an in-flight run so the store can close safely", async () => {
|
|
358
|
+
const { store, service } = dreamSetup();
|
|
359
|
+
let release;
|
|
360
|
+
const gate = new Promise((r) => { release = r; });
|
|
361
|
+
let entered = false;
|
|
362
|
+
let finished = false;
|
|
363
|
+
const dream = createDreamScheduler({
|
|
364
|
+
onRun: async () => { entered = true; await gate; finished = true; },
|
|
365
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
366
|
+
logger: { warn: () => {} }
|
|
367
|
+
});
|
|
368
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
369
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
370
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
371
|
+
assert.equal(entered, true, "run started");
|
|
372
|
+
|
|
373
|
+
const disposeP = dream.dispose();
|
|
374
|
+
let settled = false;
|
|
375
|
+
disposeP.then(() => { settled = true; });
|
|
376
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
377
|
+
assert.equal(settled, false, "dispose must not resolve while a run is in flight");
|
|
378
|
+
assert.equal(finished, false, "run still pending");
|
|
379
|
+
|
|
380
|
+
release();
|
|
381
|
+
await disposeP;
|
|
382
|
+
assert.equal(finished, true, "run completed before dispose resolved");
|
|
383
|
+
store.close();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("runDream stores summary and applies decisions", async () => {
|
|
387
|
+
const { store, service } = dreamSetup();
|
|
388
|
+
// seed 2 memories so snapshot is non-empty and decisions cover them
|
|
389
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
390
|
+
const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
391
|
+
let calls = 0;
|
|
392
|
+
const ctx = {
|
|
393
|
+
llm: {
|
|
394
|
+
stream: async function* () {
|
|
395
|
+
calls++;
|
|
396
|
+
if (calls === 1) {
|
|
397
|
+
const text = JSON.stringify([
|
|
398
|
+
{ action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }
|
|
399
|
+
]);
|
|
400
|
+
yield { type: "text-delta", text };
|
|
401
|
+
} else {
|
|
402
|
+
yield { type: "text-delta", text: "记忆库总览摘要文本" };
|
|
403
|
+
}
|
|
404
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
logger: { warn: () => {} }
|
|
408
|
+
};
|
|
409
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
410
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
411
|
+
assert.equal(result.ok, true);
|
|
412
|
+
assert.equal(result.applied, 1, "merge decision applied");
|
|
413
|
+
assert.equal(result.summary, true, "summary stored");
|
|
414
|
+
const keeper = store.getById(a.memory.id);
|
|
415
|
+
assert.equal(keeper.title, "合并标题", "keeper title updated");
|
|
416
|
+
assert.equal(keeper.content, "合并后的内容", "keeper content updated");
|
|
417
|
+
assert.equal(keeper.importance, 4, "keeper importance updated");
|
|
418
|
+
assert.equal(store.getById(b.memory.id).archived, true, "merged source archived");
|
|
419
|
+
const summary = store.all().find((m) => m.type === "summary");
|
|
420
|
+
assert.ok(summary, "summary created");
|
|
421
|
+
assert.equal(summary.title, "记忆库总览");
|
|
422
|
+
assert.equal(summary.content, "记忆库总览摘要文本");
|
|
423
|
+
store.close();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("runDream fails safe on invalid decisions", async () => {
|
|
427
|
+
const { store, service } = dreamSetup();
|
|
428
|
+
const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
429
|
+
let calls = 0;
|
|
430
|
+
const warnings = [];
|
|
431
|
+
const ctx = {
|
|
432
|
+
llm: {
|
|
433
|
+
stream: async function* () {
|
|
434
|
+
calls++;
|
|
435
|
+
yield { type: "text-delta", text: calls === 1 ? "not json at all" : "summary" };
|
|
436
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
logger: { warn: (msg) => warnings.push(msg) }
|
|
440
|
+
};
|
|
441
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
442
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
443
|
+
assert.equal(result.ok, false, "invalid decisions rejected");
|
|
444
|
+
assert.equal(result.summary, false, "no summary flag on failure");
|
|
445
|
+
assert.equal(store.all().filter((m) => m.type === "summary").length, 0, "no summary on failure");
|
|
446
|
+
const lang = store.getById(saved.memory.id);
|
|
447
|
+
assert.ok(lang, "original memory still present");
|
|
448
|
+
assert.equal(lang.archived, false, "original memory not archived");
|
|
449
|
+
assert.equal(lang.content, "中文", "original memory content untouched");
|
|
450
|
+
assert.ok(warnings.length >= 1, "failure was logged");
|
|
451
|
+
store.close();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// --- item ①: CAS guard against concurrent edits ----------------------------
|
|
455
|
+
|
|
456
|
+
test("applyDecisions CAS: merge onto a concurrently-edited target is skipped as a conflict, not applied", () => {
|
|
457
|
+
const { store, service } = dreamSetup();
|
|
458
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
459
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
460
|
+
// snapshot captured before the "LLM call"; a concurrent edit lands meanwhile
|
|
461
|
+
const snapshot = new Map([a.id, b.id].map((id) => [id, store.getById(id)]));
|
|
462
|
+
service.update(a.id, { content: "并发编辑" });
|
|
463
|
+
const { applied, conflicts, committed } = applyDecisions([
|
|
464
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
465
|
+
], service, null, snapshot);
|
|
466
|
+
assert.equal(applied, 0, "decision skipped entirely");
|
|
467
|
+
assert.equal(conflicts.length, 1, "recorded as a CAS conflict");
|
|
468
|
+
assert.equal(committed.length, 0, "nothing committed");
|
|
469
|
+
assert.equal(store.getById(a.id).content, "并发编辑", "concurrent edit preserved");
|
|
470
|
+
assert.equal(store.getById(b.id).archived, false, "source not archived");
|
|
471
|
+
assert.equal(store.getById(b.id).title, "插件2", "keeper untouched");
|
|
472
|
+
store.close();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("applyDecisions CAS: update to a concurrently-edited memory is skipped as a conflict", () => {
|
|
476
|
+
const { store, service } = dreamSetup();
|
|
477
|
+
const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
|
|
478
|
+
const snapshot = new Map([[m.id, store.getById(m.id)]]);
|
|
479
|
+
service.update(m.id, { content: "并发改动" });
|
|
480
|
+
const { applied, conflicts } = applyDecisions(
|
|
481
|
+
[{ action: "update", ids: [m.id], content: "喜欢 Rust" }],
|
|
482
|
+
service, null, snapshot
|
|
483
|
+
);
|
|
484
|
+
assert.equal(applied, 0);
|
|
485
|
+
assert.equal(conflicts.length, 1);
|
|
486
|
+
assert.equal(store.getById(m.id).content, "并发改动", "concurrent edit wins");
|
|
487
|
+
store.close();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("applyDecisions without a snapshot skips the CAS guard (replay path unchanged)", () => {
|
|
491
|
+
const { store, service } = dreamSetup();
|
|
492
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
493
|
+
service.update(a.id, { content: "并发编辑" }); // concurrent edit
|
|
494
|
+
const { applied, conflicts } = applyDecisions([
|
|
495
|
+
{ action: "archive", ids: [a.id], reason: "x" }
|
|
496
|
+
], service);
|
|
497
|
+
assert.equal(applied, 1, "snapshotless replay applies (no CAS guard)");
|
|
498
|
+
assert.equal(conflicts.length, 0);
|
|
499
|
+
assert.equal(store.getById(a.id).archived, true);
|
|
500
|
+
store.close();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// --- item ②: per-decision transaction atomicity ----------------------------
|
|
504
|
+
|
|
505
|
+
test("applyDecisions merge is atomic: a throwing archive step rolls back the keeper update too", () => {
|
|
506
|
+
const { store, service } = dreamSetup();
|
|
507
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "旧甲", importance: 3 });
|
|
508
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "旧乙", importance: 4 });
|
|
509
|
+
const originalSetArchived = service.setArchived;
|
|
510
|
+
service.setArchived = (id, archived) => {
|
|
511
|
+
if (id === a.id) throw new Error("archive boom");
|
|
512
|
+
return originalSetArchived.call(service, id, archived);
|
|
513
|
+
};
|
|
514
|
+
const warnings = [];
|
|
515
|
+
const { applied, failures, committed } = applyDecisions([
|
|
516
|
+
{ action: "merge", ids: [a.id, b.id], title: "甲乙", content: "合并", importance: 4, keepSource: b.id }
|
|
517
|
+
], service, { warn: (m) => warnings.push(m) });
|
|
518
|
+
assert.equal(applied, 0, "merge not committed");
|
|
519
|
+
assert.equal(failures.length, 1, "reported as a failure");
|
|
520
|
+
assert.equal(committed.length, 0, "outcome must not claim a merge that rolled back");
|
|
521
|
+
assert.equal(store.getById(b.id).title, "乙", "keeper title untouched by the rolled-back update");
|
|
522
|
+
assert.equal(store.getById(b.id).content, "旧乙", "keeper content untouched");
|
|
523
|
+
assert.equal(store.getById(a.id).archived, false, "source not archived");
|
|
524
|
+
assert.ok(warnings.length >= 1, "failure logged");
|
|
525
|
+
store.close();
|
|
526
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// test/helpers/dream-mock.js
|
|
2
|
+
// 共享的确定性 LLM 测试桩:consolidation 决策生成器 + 最小 DSH ctx。
|
|
3
|
+
// 被 scripts/stress-dsh.js 与 test/stress.test.js 共用,保证压测与单测
|
|
4
|
+
// 走同一套"LLM 行为",避免两边决策逻辑漂移。
|
|
5
|
+
|
|
6
|
+
export function parseEntries(listText) {
|
|
7
|
+
return [...listText.matchAll(
|
|
8
|
+
/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=(\d+)\s*\|\s*updated=([^\s|]+)\s*\|\s*title=([^|]*)/g
|
|
9
|
+
)].map((m) => ({ id: m[1], type: m[2], importance: Number(m[3]), updated: m[4], title: m[5].trim() }));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 长会话检索的确定性决策:title 含「变体」→ archive,其余 keep。
|
|
14
|
+
*/
|
|
15
|
+
export function sessionDecisions(listText) {
|
|
16
|
+
const entries = parseEntries(listText);
|
|
17
|
+
const decisions = [];
|
|
18
|
+
const claimed = new Set();
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
if (e.title.includes("变体")) {
|
|
21
|
+
decisions.push({ action: "archive", ids: [e.id], reason: "stale variant" });
|
|
22
|
+
claimed.add(e.id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
if (!claimed.has(e.id)) decisions.push({ action: "keep", ids: [e.id] });
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(decisions);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 冲突裁决的确定性决策:title 以「(旧)」结尾 → conflict(胜者为同主题
|
|
33
|
+
* 不带「(旧)」者),无对手 → keep。同一快照必然产出同一决策。
|
|
34
|
+
*/
|
|
35
|
+
export function arbitrationDecisions(listText) {
|
|
36
|
+
const entries = parseEntries(listText);
|
|
37
|
+
const byKey = new Map();
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const key = e.title.replace(/\(旧\)$/, "").trim();
|
|
40
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
41
|
+
byKey.get(key).push(e);
|
|
42
|
+
}
|
|
43
|
+
const decisions = [];
|
|
44
|
+
const claimed = new Set();
|
|
45
|
+
for (const group of byKey.values()) {
|
|
46
|
+
const loser = group.find((e) => e.title.includes("(旧)"));
|
|
47
|
+
const winner = group.find((e) => !e.title.includes("(旧)"));
|
|
48
|
+
if (winner && loser) {
|
|
49
|
+
decisions.push({ action: "conflict", winner: winner.id, loser: loser.id, reason: "新信息覆盖旧信息" });
|
|
50
|
+
claimed.add(winner.id);
|
|
51
|
+
claimed.add(loser.id);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const e of group) {
|
|
55
|
+
if (!claimed.has(e.id)) decisions.push({ action: "keep", ids: [e.id] });
|
|
56
|
+
claimed.add(e.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(decisions);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 最小 DSH ctx:consolidation 用 onConsolidation(listText) 产出决策,
|
|
64
|
+
* summary 返回固定文本。
|
|
65
|
+
*/
|
|
66
|
+
export function mockCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文;关键决策已巩固。" } = {}) {
|
|
67
|
+
return {
|
|
68
|
+
logger: { warn: () => {} },
|
|
69
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
|
|
70
|
+
llm: {
|
|
71
|
+
async *stream(options) {
|
|
72
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
73
|
+
if (userText.startsWith("id=")) {
|
|
74
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
75
|
+
} else {
|
|
76
|
+
yield { type: "text-delta", index: 0, text: summaryText };
|
|
77
|
+
}
|
|
78
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|