@modusensus/dsh-mneme 0.6.6 → 0.6.8
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/LICENSE +21 -21
- package/README.md +463 -457
- package/cordis.patch.yml +15 -15
- package/lib/api.js +783 -693
- package/lib/client.js +1757 -1656
- package/lib/commands.js +64 -64
- package/lib/config.js +288 -288
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +439 -439
- package/lib/dream/sleep.js +561 -554
- package/lib/dream/tag-extractor.js +156 -156
- package/lib/dream.js +935 -929
- package/lib/embedding.js +154 -154
- package/lib/entities/extractor.js +242 -242
- package/lib/hot-memory.js +53 -53
- package/lib/index.js +361 -361
- package/lib/inject.js +208 -208
- package/lib/local-embedder.js +282 -282
- package/lib/mirror.js +170 -170
- package/lib/parser/tag.js +59 -59
- package/lib/parser/wiki-link.js +38 -38
- package/lib/quality-filter.js +123 -123
- package/lib/reranker.js +218 -218
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/search/tag-boost.js +61 -61
- package/lib/service.js +1726 -1726
- package/lib/settings.js +172 -142
- package/lib/store.js +2238 -2238
- package/lib/summarize.js +236 -236
- package/lib/tools.js +290 -290
- package/lib/vector-index.js +116 -116
- package/package.json +80 -75
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +133 -133
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/e2e-dsh.js +218 -218
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +52 -52
- package/src/api.js +783 -693
- package/src/commands.js +64 -64
- package/src/config.js +288 -288
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +439 -439
- package/src/dream/sleep.js +561 -554
- package/src/dream/tag-extractor.js +156 -156
- package/src/dream.js +935 -929
- package/src/embedding.js +154 -154
- package/src/entities/extractor.js +242 -242
- package/src/hot-memory.js +53 -53
- package/src/index.js +361 -361
- package/src/inject.js +208 -208
- package/src/local-embedder.js +282 -282
- package/src/mirror.js +170 -170
- package/src/parser/tag.js +59 -59
- package/src/parser/wiki-link.js +38 -38
- package/src/quality-filter.js +123 -123
- package/src/reranker.js +218 -218
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/search/tag-boost.js +61 -61
- package/src/service.js +1726 -1726
- package/src/settings.js +172 -142
- package/src/store.js +2238 -2238
- package/src/summarize.js +236 -236
- package/src/tools.js +290 -290
- package/src/vector-index.js +116 -116
- package/test/api.test.js +594 -549
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +35 -35
- package/test/boundary-v0625.test.js +82 -82
- package/test/client.test.js +368 -368
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +69 -69
- package/test/config.test.js +50 -50
- package/test/conflict-freeze.test.js +290 -290
- package/test/directory.test.js +134 -134
- package/test/dream.test.js +903 -901
- package/test/entities.test.js +522 -522
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/hot-memory.test.js +174 -174
- package/test/inject.test.js +103 -103
- package/test/llm-audit.test.js +279 -279
- package/test/local-embedder.test.js +227 -227
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror.test.js +249 -249
- package/test/normalize-decisions.test.js +120 -120
- package/test/peer-blockers.test.js +190 -190
- package/test/policy-epoch.test.js +259 -259
- package/test/provenance.test.js +103 -103
- package/test/quality-filter.test.js +118 -118
- package/test/reasoning-effort.test.js +199 -199
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +315 -315
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reranker.test.js +240 -240
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/service-search.test.js +199 -199
- package/test/service.test.js +435 -435
- package/test/settings.test.js +118 -101
- package/test/sleep.test.js +365 -365
- package/test/store.test.js +436 -436
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +191 -191
- package/test/tag-boost.test.js +125 -125
- package/test/tag.test.js +312 -312
- package/test/tools.test.js +285 -285
- package/test/vector-index.test.js +221 -221
- package/test/wiki-link.test.js +332 -332
package/test/dream.test.js
CHANGED
|
@@ -1,901 +1,903 @@
|
|
|
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
|
-
import { mockCtx } from "./helpers/dream-mock.js";
|
|
7
|
-
|
|
8
|
-
function snapshot(ids, type = "project") {
|
|
9
|
-
return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
test("valid decision list passes", () => {
|
|
13
|
-
const snap = snapshot(["a", "b", "c"]);
|
|
14
|
-
const decisions = [
|
|
15
|
-
{ action: "keep", ids: ["a"], reason: "ok" },
|
|
16
|
-
{ action: "merge", ids: ["b", "c"], title: "bc", content: "merged", importance: 4, keepSource: "b" }
|
|
17
|
-
];
|
|
18
|
-
const { ok, errors } = validateDecisions(decisions, snap);
|
|
19
|
-
assert.equal(ok, true);
|
|
20
|
-
assert.deepEqual(errors, []);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("unknown id rejects whole list", () => {
|
|
24
|
-
const snap = snapshot(["a"]);
|
|
25
|
-
const { ok, errors } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
|
|
26
|
-
assert.equal(ok, false);
|
|
27
|
-
assert.ok(errors.some((e) => e.includes("zzz")));
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("invalid action rejects", () => {
|
|
31
|
-
const snap = snapshot(["a"]);
|
|
32
|
-
const { ok } = validateDecisions([{ action: "explode", ids: ["a"] }], snap);
|
|
33
|
-
assert.equal(ok, false);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("merge keepSource must be in ids", () => {
|
|
37
|
-
const snap = snapshot(["a", "b"]);
|
|
38
|
-
const { ok } = validateDecisions([{ action: "merge", ids: ["a"], keepSource: "b", title: "t", content: "c" }], snap);
|
|
39
|
-
assert.equal(ok, false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("conflict winner and loser must exist and differ", () => {
|
|
43
|
-
const snap = snapshot(["a", "b"]);
|
|
44
|
-
const { ok } = validateDecisions([{ action: "conflict", winner: "a", loser: "a" }], snap);
|
|
45
|
-
assert.equal(ok, false);
|
|
46
|
-
const { ok: ok2 } = validateDecisions([{ action: "conflict", winner: "a", loser: "zzz" }], snap);
|
|
47
|
-
assert.equal(ok2, false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("duplicate primary ids across decisions reject", () => {
|
|
51
|
-
const snap = snapshot(["a", "b"]);
|
|
52
|
-
const { ok } = validateDecisions([
|
|
53
|
-
{ action: "archive", ids: ["a"] },
|
|
54
|
-
{ action: "keep", ids: ["a"] }
|
|
55
|
-
], snap);
|
|
56
|
-
assert.equal(ok, false, "a claimed twice");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("archived or summary entries cannot be decision targets", () => {
|
|
60
|
-
const snap = new Map([["arch", { id: "arch", type: "project", title: "t", content: "c", importance: 3, archived: true, forgotten: false }]]);
|
|
61
|
-
const { ok } = validateDecisions([{ action: "archive", ids: ["arch"] }], snap);
|
|
62
|
-
assert.equal(ok, false);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("empty decision list rejects", () => {
|
|
66
|
-
const { ok, errors } = validateDecisions([], snapshot(["a"]));
|
|
67
|
-
assert.equal(ok, false);
|
|
68
|
-
assert.ok(errors.some((e) => e.includes("non-empty array")));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("empty ids rejects", () => {
|
|
72
|
-
const { ok } = validateDecisions([{ action: "archive", ids: [] }], snapshot(["a"]));
|
|
73
|
-
assert.equal(ok, false);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("merge requires non-empty title and content", () => {
|
|
77
|
-
const snap = snapshot(["a"]);
|
|
78
|
-
const base = { action: "merge", ids: ["a"], keepSource: "a" };
|
|
79
|
-
for (const [title, content] of [["", "x"], ["t", ""], [undefined, "x"], ["t", undefined]]) {
|
|
80
|
-
const { ok } = validateDecisions([{ ...base, title, content }], snap);
|
|
81
|
-
assert.equal(ok, false, `title=${JSON.stringify(title)} content=${JSON.stringify(content)}`);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("summary entries cannot be decision targets", () => {
|
|
86
|
-
const snap = new Map([["s", { id: "s", type: "summary", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
|
|
87
|
-
const { ok } = validateDecisions([{ action: "archive", ids: ["s"] }], snap);
|
|
88
|
-
assert.equal(ok, false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
|
|
92
|
-
const snap = snapshot(["a", "b"]);
|
|
93
|
-
const decisions = [{ action: "keep", ids: ["a"] }];
|
|
94
|
-
const { ok, errors } = validateDecisions(decisions, snap);
|
|
95
|
-
assert.equal(ok, true, errors.join("; "));
|
|
96
|
-
assert.equal(decisions.length, 2, "keep appended for the uncovered snapshot id");
|
|
97
|
-
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("b")), "b auto-kept");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("dreamImplicitKeep=false keeps the strict full-coverage validation", () => {
|
|
101
|
-
const snap = snapshot(["a", "b"]);
|
|
102
|
-
const decisions = [{ action: "keep", ids: ["a"] }];
|
|
103
|
-
const { ok, errors } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
|
|
104
|
-
assert.equal(ok, false);
|
|
105
|
-
assert.ok(errors.some((e) => e.includes("missing from decisions")));
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("duplicate ids within one decision reject", () => {
|
|
109
|
-
const { ok } = validateDecisions([{ action: "keep", ids: ["a", "a"] }], snapshot(["a"]));
|
|
110
|
-
assert.equal(ok, false);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("merge importance out of range rejects", () => {
|
|
114
|
-
const snap = snapshot(["a", "b"]);
|
|
115
|
-
for (const importance of [0, 6, 99, 1.5, "4"]) {
|
|
116
|
-
const { ok, errors } = validateDecisions([
|
|
117
|
-
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance }
|
|
118
|
-
], snap);
|
|
119
|
-
assert.equal(ok, false, `importance=${JSON.stringify(importance)} rejected`);
|
|
120
|
-
assert.ok(errors.some((e) => e.includes("importance")), `importance error present for ${JSON.stringify(importance)}`);
|
|
121
|
-
}
|
|
122
|
-
const { ok } = validateDecisions([
|
|
123
|
-
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance: 5 }
|
|
124
|
-
], snap);
|
|
125
|
-
assert.equal(ok, true, "importance 5 accepted");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("merge across types rejects", () => {
|
|
129
|
-
const snap = new Map([
|
|
130
|
-
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
131
|
-
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
|
|
132
|
-
]);
|
|
133
|
-
const { ok, errors } = validateDecisions([
|
|
134
|
-
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
|
|
135
|
-
], snap);
|
|
136
|
-
assert.equal(ok, false, "cross-type merge rejected");
|
|
137
|
-
assert.ok(errors.some((e) => e.includes("multiple types")), "multi-type error present");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
function dreamSetup() {
|
|
141
|
-
const store = createStore(":memory:");
|
|
142
|
-
const service = createService({ store, mirror: null, config: {} });
|
|
143
|
-
return { store, service };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
test("applyDecisions merges: keepSource updated, others archived", () => {
|
|
147
|
-
const { store, service } = dreamSetup();
|
|
148
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
149
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
150
|
-
const { applied } = applyDecisions([
|
|
151
|
-
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
152
|
-
], service);
|
|
153
|
-
assert.equal(applied, 1);
|
|
154
|
-
const keeper = store.getById(b.id);
|
|
155
|
-
assert.equal(keeper.content, "合并内容");
|
|
156
|
-
assert.equal(keeper.title, "插件总览");
|
|
157
|
-
assert.equal(keeper.importance, 5);
|
|
158
|
-
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("applyDecisions conflict: winner kept, loser archived with provenance", () => {
|
|
162
|
-
const { store, service } = dreamSetup();
|
|
163
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
164
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
165
|
-
applyDecisions([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }], service);
|
|
166
|
-
assert.equal(store.getById(l.id).archived, true);
|
|
167
|
-
const winner = store.getById(w.id);
|
|
168
|
-
assert.ok(winner.content.includes("8月20日"), "winner content intact");
|
|
169
|
-
assert.ok(winner.content.includes("已否决旧信息"), "provenance note appended");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("applyDecisions archive and keep", () => {
|
|
173
|
-
const { store, service } = dreamSetup();
|
|
174
|
-
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
175
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "废弃", content: "过时" });
|
|
176
|
-
applyDecisions([
|
|
177
|
-
{ action: "keep", ids: [k.id] },
|
|
178
|
-
{ action: "archive", ids: [a.id], reason: "过时" }
|
|
179
|
-
], service);
|
|
180
|
-
assert.equal(store.getById(k.id).archived, false);
|
|
181
|
-
assert.equal(store.getById(a.id).archived, true);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("applyDecisions returns count and never throws on unknown id (skip)", () => {
|
|
185
|
-
const { store, service } = dreamSetup();
|
|
186
|
-
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
187
|
-
const { applied } = applyDecisions([{ action: "archive", ids: ["ghost"], reason: "x" }], service);
|
|
188
|
-
assert.equal(applied, 0);
|
|
189
|
-
assert.equal(store.getById(k.id).archived, false);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("applyDecisions catch path: throwing decision is skipped, logged, and later decisions still apply", () => {
|
|
193
|
-
const { store, service } = dreamSetup();
|
|
194
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "会炸", content: "x" });
|
|
195
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "正常", content: "y" });
|
|
196
|
-
const originalSetArchived = service.setArchived;
|
|
197
|
-
let calls = 0;
|
|
198
|
-
service.setArchived = (id, archived) => {
|
|
199
|
-
calls++;
|
|
200
|
-
if (calls === 1) throw new Error("boom");
|
|
201
|
-
return originalSetArchived.call(service, id, archived);
|
|
202
|
-
};
|
|
203
|
-
const warnings = [];
|
|
204
|
-
const logger = { warn: (msg) => warnings.push(msg) };
|
|
205
|
-
const { applied, failures } = applyDecisions([
|
|
206
|
-
{ action: "archive", ids: [a.id], reason: "x" },
|
|
207
|
-
{ action: "archive", ids: [b.id], reason: "y" }
|
|
208
|
-
], service, logger);
|
|
209
|
-
assert.equal(applied, 1, "throwing decision not counted, surviving decision counted");
|
|
210
|
-
assert.equal(failures.length, 1, "thrown decision reported as a failure");
|
|
211
|
-
assert.equal(store.getById(a.id).archived, false, "throwing decision left no partial effect");
|
|
212
|
-
assert.equal(store.getById(b.id).archived, true, "later decision still applied");
|
|
213
|
-
assert.equal(warnings.length, 1, "logger called once");
|
|
214
|
-
assert.match(warnings[0], /failed to apply archive at index 0: boom/);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("applyDecisions conflict with missing loser skips cleanly", () => {
|
|
218
|
-
const { store, service } = dreamSetup();
|
|
219
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
220
|
-
const { applied } = applyDecisions([{ action: "conflict", winner: w.id, loser: "ghost", reason: "x" }], service);
|
|
221
|
-
assert.equal(applied, 0);
|
|
222
|
-
assert.equal(store.getById(w.id).archived, false, "winner untouched");
|
|
223
|
-
assert.ok(!store.getById(w.id).content.includes("已否决"), "no provenance note appended");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test("applyDecisions merge with missing keeper skips cleanly", () => {
|
|
227
|
-
const { store, service } = dreamSetup();
|
|
228
|
-
const { applied } = applyDecisions([
|
|
229
|
-
{ action: "merge", ids: ["ghost"], keepSource: "ghost", title: "t", content: "c" }
|
|
230
|
-
], service);
|
|
231
|
-
assert.equal(applied, 0);
|
|
232
|
-
assert.equal(store.getById("ghost"), undefined);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test("applyDecisions merge without importance falls back to max source importance", () => {
|
|
236
|
-
const { store, service } = dreamSetup();
|
|
237
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
238
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
239
|
-
const { applied } = applyDecisions([
|
|
240
|
-
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", keepSource: b.id }
|
|
241
|
-
], service);
|
|
242
|
-
assert.equal(applied, 1);
|
|
243
|
-
assert.equal(store.getById(b.id).importance, 4, "keeper keeps max of source importances");
|
|
244
|
-
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("maybeSchedule triggers when count exceeds threshold", () => {
|
|
248
|
-
const { store, service } = dreamSetup();
|
|
249
|
-
let runs = 0;
|
|
250
|
-
const dream = createDreamScheduler({
|
|
251
|
-
onRun: async () => { runs++; },
|
|
252
|
-
thresholdCount: 3,
|
|
253
|
-
thresholdChars: 5000,
|
|
254
|
-
delayMs: 0
|
|
255
|
-
});
|
|
256
|
-
for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(100) });
|
|
257
|
-
const pending = dream.maybeSchedule(service);
|
|
258
|
-
assert.equal(pending, true, "scheduled");
|
|
259
|
-
assert.equal(runs, 0, "not run yet (async)");
|
|
260
|
-
store.close();
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test("maybeSchedule does not trigger below threshold", () => {
|
|
264
|
-
const { store, service } = dreamSetup();
|
|
265
|
-
const dream = createDreamScheduler({ onRun: async () => {}, thresholdCount: 10, thresholdChars: 5000, delayMs: 0 });
|
|
266
|
-
service.saveWithDedupe({ type: "project", title: "only", content: "x" });
|
|
267
|
-
assert.equal(dream.maybeSchedule(service), false);
|
|
268
|
-
store.close();
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test("scheduler fires async and resets baseline", async () => {
|
|
272
|
-
const { store, service } = dreamSetup();
|
|
273
|
-
let runs = 0;
|
|
274
|
-
const dream = createDreamScheduler({
|
|
275
|
-
onRun: async () => { runs++; return { ok: true }; },
|
|
276
|
-
thresholdCount: 2, thresholdChars: 5000, delayMs: 5
|
|
277
|
-
});
|
|
278
|
-
for (let i = 0; i < 2; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) });
|
|
279
|
-
dream.maybeSchedule(service);
|
|
280
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
281
|
-
assert.equal(runs, 1, "ran once");
|
|
282
|
-
// still above threshold but baseline reset → no immediate re-trigger
|
|
283
|
-
assert.equal(dream.maybeSchedule(service), false, "baseline prevents loop");
|
|
284
|
-
store.close();
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test("failed run does not refresh baseline: next write re-triggers", async () => {
|
|
288
|
-
const { store, service } = dreamSetup();
|
|
289
|
-
let calls = 0;
|
|
290
|
-
const dream = createDreamScheduler({
|
|
291
|
-
onRun: async () => {
|
|
292
|
-
calls++;
|
|
293
|
-
return { ok: false, error: "llm failed" };
|
|
294
|
-
},
|
|
295
|
-
thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
|
|
296
|
-
logger: { warn: () => {} }
|
|
297
|
-
});
|
|
298
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
|
|
299
|
-
service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
|
|
300
|
-
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
301
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
302
|
-
assert.equal(calls, 1, "run attempted once");
|
|
303
|
-
// baseline NOT refreshed on failure → the same write volume still triggers
|
|
304
|
-
assert.equal(dream.maybeSchedule(service), true, "failed run keeps baseline, re-schedules");
|
|
305
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
306
|
-
assert.equal(calls, 2, "retried after failure");
|
|
307
|
-
store.close();
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("noop run (nothing changed) does not refresh baseline: next write re-triggers", async () => {
|
|
311
|
-
const { store, service } = dreamSetup();
|
|
312
|
-
let calls = 0;
|
|
313
|
-
const dream = createDreamScheduler({
|
|
314
|
-
onRun: async () => { calls++; return { ok: false, status: "noop", applied: 0, summary: false }; },
|
|
315
|
-
thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
|
|
316
|
-
logger: { warn: () => {} }
|
|
317
|
-
});
|
|
318
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
|
|
319
|
-
service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
|
|
320
|
-
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
321
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
322
|
-
assert.equal(calls, 1, "run attempted once");
|
|
323
|
-
// a noop is not a success: the baseline stays put so the accumulated writes
|
|
324
|
-
// are still owed and the next write re-schedules instead of being absorbed
|
|
325
|
-
assert.equal(dream.maybeSchedule(service), true, "noop keeps baseline, re-schedules");
|
|
326
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
327
|
-
assert.equal(calls, 2, "retried after noop");
|
|
328
|
-
store.close();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
test("throwing run does not refresh baseline and is logged", async () => {
|
|
332
|
-
const { store, service } = dreamSetup();
|
|
333
|
-
const warnings = [];
|
|
334
|
-
let calls = 0;
|
|
335
|
-
const dream = createDreamScheduler({
|
|
336
|
-
onRun: async () => {
|
|
337
|
-
calls++;
|
|
338
|
-
throw new Error("boom");
|
|
339
|
-
},
|
|
340
|
-
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
341
|
-
logger: { warn: (msg) => warnings.push(msg) }
|
|
342
|
-
});
|
|
343
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
344
|
-
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
345
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
346
|
-
assert.equal(calls, 1, "run attempted once");
|
|
347
|
-
assert.ok(warnings.some((m) => m.includes("run failed")), "throw logged");
|
|
348
|
-
assert.equal(dream.maybeSchedule(service), true, "throw keeps baseline, re-schedules");
|
|
349
|
-
store.close();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
test("maybeSchedule returns false while a run is in flight", async () => {
|
|
353
|
-
const { store, service } = dreamSetup();
|
|
354
|
-
let release;
|
|
355
|
-
const gate = new Promise((r) => { release = r; });
|
|
356
|
-
let entered = false;
|
|
357
|
-
const dream = createDreamScheduler({
|
|
358
|
-
onRun: async () => { entered = true; await gate; },
|
|
359
|
-
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
360
|
-
logger: { warn: () => {} }
|
|
361
|
-
});
|
|
362
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
363
|
-
assert.equal(dream.maybeSchedule(service), true, "first schedule accepted");
|
|
364
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
365
|
-
assert.equal(entered, true, "run started");
|
|
366
|
-
assert.equal(dream.maybeSchedule(service), false, "no schedule while running");
|
|
367
|
-
release();
|
|
368
|
-
await new Promise((r) => setTimeout(r, 10)); // let the run finish + baseline refresh
|
|
369
|
-
store.close();
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
test("dispose clears pending timer and blocks future scheduling", async () => {
|
|
373
|
-
const { store, service } = dreamSetup();
|
|
374
|
-
let runs = 0;
|
|
375
|
-
const dream = createDreamScheduler({
|
|
376
|
-
onRun: async () => { runs++; },
|
|
377
|
-
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
378
|
-
logger: { warn: () => {} }
|
|
379
|
-
});
|
|
380
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
381
|
-
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
382
|
-
dream.dispose();
|
|
383
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
384
|
-
assert.equal(runs, 0, "pending run cancelled by dispose");
|
|
385
|
-
assert.equal(dream.maybeSchedule(service), false, "disposed scheduler never schedules");
|
|
386
|
-
store.close();
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
test("dispose awaits an in-flight run so the store can close safely", async () => {
|
|
390
|
-
const { store, service } = dreamSetup();
|
|
391
|
-
let release;
|
|
392
|
-
const gate = new Promise((r) => { release = r; });
|
|
393
|
-
let entered = false;
|
|
394
|
-
let finished = false;
|
|
395
|
-
const dream = createDreamScheduler({
|
|
396
|
-
onRun: async () => { entered = true; await gate; finished = true; },
|
|
397
|
-
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
398
|
-
logger: { warn: () => {} }
|
|
399
|
-
});
|
|
400
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
401
|
-
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
402
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
403
|
-
assert.equal(entered, true, "run started");
|
|
404
|
-
|
|
405
|
-
const disposeP = dream.dispose();
|
|
406
|
-
let settled = false;
|
|
407
|
-
disposeP.then(() => { settled = true; });
|
|
408
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
409
|
-
assert.equal(settled, false, "dispose must not resolve while a run is in flight");
|
|
410
|
-
assert.equal(finished, false, "run still pending");
|
|
411
|
-
|
|
412
|
-
release();
|
|
413
|
-
await disposeP;
|
|
414
|
-
assert.equal(finished, true, "run completed before dispose resolved");
|
|
415
|
-
store.close();
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test("runDream stores summary and applies decisions", async () => {
|
|
419
|
-
const { store, service } = dreamSetup();
|
|
420
|
-
// seed 2 memories so snapshot is non-empty and decisions cover them
|
|
421
|
-
const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
422
|
-
const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
423
|
-
let calls = 0;
|
|
424
|
-
const ctx = {
|
|
425
|
-
llm: {
|
|
426
|
-
stream: async function* () {
|
|
427
|
-
calls++;
|
|
428
|
-
if (calls === 1) {
|
|
429
|
-
const text = JSON.stringify([
|
|
430
|
-
{ action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }
|
|
431
|
-
]);
|
|
432
|
-
yield { type: "text-delta", text };
|
|
433
|
-
} else {
|
|
434
|
-
yield { type: "text-delta", text: "记忆库总览摘要文本" };
|
|
435
|
-
}
|
|
436
|
-
yield { type: "finish", reason: { kind: "ok" } };
|
|
437
|
-
}
|
|
438
|
-
},
|
|
439
|
-
logger: { warn: () => {} }
|
|
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, true);
|
|
444
|
-
assert.equal(result.applied, 1, "merge decision applied");
|
|
445
|
-
assert.equal(result.summary, true, "summary stored");
|
|
446
|
-
const keeper = store.getById(a.memory.id);
|
|
447
|
-
assert.equal(keeper.title, "合并标题", "keeper title updated");
|
|
448
|
-
assert.equal(keeper.content, "合并后的内容", "keeper content updated");
|
|
449
|
-
assert.equal(keeper.importance, 4, "keeper importance updated");
|
|
450
|
-
assert.equal(store.getById(b.memory.id).archived, true, "merged source archived");
|
|
451
|
-
const summary = store.all().find((m) => m.type === "summary");
|
|
452
|
-
assert.ok(summary, "summary created");
|
|
453
|
-
assert.equal(summary.title, "记忆库总览");
|
|
454
|
-
assert.equal(summary.content, "记忆库总览摘要文本");
|
|
455
|
-
store.close();
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
test("runDream fails safe on invalid decisions", async () => {
|
|
459
|
-
const { store, service } = dreamSetup();
|
|
460
|
-
const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
461
|
-
let calls = 0;
|
|
462
|
-
const warnings = [];
|
|
463
|
-
const ctx = {
|
|
464
|
-
llm: {
|
|
465
|
-
stream: async function* () {
|
|
466
|
-
calls++;
|
|
467
|
-
yield { type: "text-delta", text: calls === 1 ? "not json at all" : "summary" };
|
|
468
|
-
yield { type: "finish", reason: { kind: "ok" } };
|
|
469
|
-
}
|
|
470
|
-
},
|
|
471
|
-
logger: { warn: (msg) => warnings.push(msg) }
|
|
472
|
-
};
|
|
473
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
474
|
-
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
475
|
-
assert.equal(result.ok, false, "invalid decisions rejected");
|
|
476
|
-
assert.equal(result.summary, false, "no summary flag on failure");
|
|
477
|
-
assert.equal(store.all().filter((m) => m.type === "summary").length, 0, "no summary on failure");
|
|
478
|
-
const lang = store.getById(saved.memory.id);
|
|
479
|
-
assert.ok(lang, "original memory still present");
|
|
480
|
-
assert.equal(lang.archived, false, "original memory not archived");
|
|
481
|
-
assert.equal(lang.content, "中文", "original memory content untouched");
|
|
482
|
-
assert.ok(warnings.length >= 1, "failure was logged");
|
|
483
|
-
store.close();
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
// --- item ①: CAS guard against concurrent edits ----------------------------
|
|
487
|
-
|
|
488
|
-
test("applyDecisions CAS: merge onto a concurrently-edited target is skipped as a conflict, not applied", () => {
|
|
489
|
-
const { store, service } = dreamSetup();
|
|
490
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
491
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
492
|
-
// snapshot captured before the "LLM call"; a concurrent edit lands meanwhile
|
|
493
|
-
const snapshot = new Map([a.id, b.id].map((id) => [id, store.getById(id)]));
|
|
494
|
-
service.update(a.id, { content: "并发编辑" });
|
|
495
|
-
const { applied, conflicts, committed } = applyDecisions([
|
|
496
|
-
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
497
|
-
], service, null, snapshot);
|
|
498
|
-
assert.equal(applied, 0, "decision skipped entirely");
|
|
499
|
-
assert.equal(conflicts.length, 1, "recorded as a CAS conflict");
|
|
500
|
-
assert.equal(committed.length, 0, "nothing committed");
|
|
501
|
-
assert.equal(store.getById(a.id).content, "并发编辑", "concurrent edit preserved");
|
|
502
|
-
assert.equal(store.getById(b.id).archived, false, "source not archived");
|
|
503
|
-
assert.equal(store.getById(b.id).title, "插件2", "keeper untouched");
|
|
504
|
-
store.close();
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
test("applyDecisions CAS: update to a concurrently-edited memory is skipped as a conflict", () => {
|
|
508
|
-
const { store, service } = dreamSetup();
|
|
509
|
-
const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
|
|
510
|
-
const snapshot = new Map([[m.id, store.getById(m.id)]]);
|
|
511
|
-
service.update(m.id, { content: "并发改动" });
|
|
512
|
-
const { applied, conflicts } = applyDecisions(
|
|
513
|
-
[{ action: "update", ids: [m.id], content: "喜欢 Rust" }],
|
|
514
|
-
service, null, snapshot
|
|
515
|
-
);
|
|
516
|
-
assert.equal(applied, 0);
|
|
517
|
-
assert.equal(conflicts.length, 1);
|
|
518
|
-
assert.equal(store.getById(m.id).content, "并发改动", "concurrent edit wins");
|
|
519
|
-
store.close();
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
test("applyDecisions without a snapshot skips the CAS guard (replay path unchanged)", () => {
|
|
523
|
-
const { store, service } = dreamSetup();
|
|
524
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
525
|
-
service.update(a.id, { content: "并发编辑" }); // concurrent edit
|
|
526
|
-
const { applied, conflicts } = applyDecisions([
|
|
527
|
-
{ action: "archive", ids: [a.id], reason: "x" }
|
|
528
|
-
], service);
|
|
529
|
-
assert.equal(applied, 1, "snapshotless replay applies (no CAS guard)");
|
|
530
|
-
assert.equal(conflicts.length, 0);
|
|
531
|
-
assert.equal(store.getById(a.id).archived, true);
|
|
532
|
-
store.close();
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
// --- item ②: per-decision transaction atomicity ----------------------------
|
|
536
|
-
|
|
537
|
-
test("applyDecisions merge is atomic: a throwing archive step rolls back the keeper update too", () => {
|
|
538
|
-
const { store, service } = dreamSetup();
|
|
539
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "旧甲", importance: 3 });
|
|
540
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "旧乙", importance: 4 });
|
|
541
|
-
const originalSetArchived = service.setArchived;
|
|
542
|
-
service.setArchived = (id, archived) => {
|
|
543
|
-
if (id === a.id) throw new Error("archive boom");
|
|
544
|
-
return originalSetArchived.call(service, id, archived);
|
|
545
|
-
};
|
|
546
|
-
const warnings = [];
|
|
547
|
-
const { applied, failures, committed } = applyDecisions([
|
|
548
|
-
{ action: "merge", ids: [a.id, b.id], title: "甲乙", content: "合并", importance: 4, keepSource: b.id }
|
|
549
|
-
], service, { warn: (m) => warnings.push(m) });
|
|
550
|
-
assert.equal(applied, 0, "merge not committed");
|
|
551
|
-
assert.equal(failures.length, 1, "reported as a failure");
|
|
552
|
-
assert.equal(committed.length, 0, "outcome must not claim a merge that rolled back");
|
|
553
|
-
assert.equal(store.getById(b.id).title, "乙", "keeper title untouched by the rolled-back update");
|
|
554
|
-
assert.equal(store.getById(b.id).content, "旧乙", "keeper content untouched");
|
|
555
|
-
assert.equal(store.getById(a.id).archived, false, "source not archived");
|
|
556
|
-
assert.ok(warnings.length >= 1, "failure logged");
|
|
557
|
-
store.close();
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
// --- conflict freeze: manual review instead of auto-adjudication ----------
|
|
561
|
-
|
|
562
|
-
function freezeCtx({ conflicts, includes = [], summaryText = "记忆库总览摘要" }) {
|
|
563
|
-
let calls = 0;
|
|
564
|
-
const warnings = [];
|
|
565
|
-
const ctx = {
|
|
566
|
-
warnings,
|
|
567
|
-
llm: {
|
|
568
|
-
stream: async function* () {
|
|
569
|
-
calls++;
|
|
570
|
-
const list = [...includes, ...conflicts];
|
|
571
|
-
yield { type: "text-delta", text: calls === 1 ? JSON.stringify(list) : summaryText };
|
|
572
|
-
yield { type: "finish", reason: { kind: "ok" } };
|
|
573
|
-
}
|
|
574
|
-
},
|
|
575
|
-
logger: { warn: (m) => warnings.push(m) }
|
|
576
|
-
};
|
|
577
|
-
return ctx;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
test("runDream with conflictFreezeEnabled parks conflicts instead of adjudicating", async () => {
|
|
581
|
-
const { store, service } = dreamSetup();
|
|
582
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
583
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
584
|
-
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新,候选取新" }] });
|
|
585
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
586
|
-
const result = await dream.runDream(ctx, service, {
|
|
587
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
588
|
-
});
|
|
589
|
-
assert.equal(result.ok, true);
|
|
590
|
-
assert.equal(result.applied, 0, "no conflict applied");
|
|
591
|
-
assert.equal(result.frozen, 1, "one conflict frozen");
|
|
592
|
-
// pending row recorded for human review
|
|
593
|
-
const pending = store.listConflictPending();
|
|
594
|
-
assert.equal(pending.length, 1);
|
|
595
|
-
assert.equal(pending[0].reason, "日期更新,候选取新");
|
|
596
|
-
// neither side was auto-adjudicated
|
|
597
|
-
assert.equal(store.getById(l.id).archived, false, "loser NOT archived");
|
|
598
|
-
assert.equal(store.getById(w.id).archived, false, "winner NOT archived");
|
|
599
|
-
assert.ok(!store.getById(w.id).content.includes("已否决旧信息"), "no provenance note appended");
|
|
600
|
-
// audit outcome marks both sides pending
|
|
601
|
-
const run = store.listDreamRuns()[0];
|
|
602
|
-
assert.equal(run.outcome.byId[w.id], "conflict-pending");
|
|
603
|
-
assert.equal(run.outcome.byId[l.id], "conflict-pending");
|
|
604
|
-
assert.equal(run.status, "ok", "summary stored + freeze landed → ok");
|
|
605
|
-
assert.equal(store.listReceipts().length, 0, "no conflict receipt for a frozen (unapplied) conflict");
|
|
606
|
-
store.close();
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
test("runDream freeze keeps auto-adjudication when disabled (default)", async () => {
|
|
610
|
-
const { store, service } = dreamSetup();
|
|
611
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
612
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
613
|
-
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }] });
|
|
614
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
615
|
-
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
616
|
-
assert.equal(result.ok, true);
|
|
617
|
-
assert.equal(result.applied, 1, "conflict auto-adjudicated when freeze is off");
|
|
618
|
-
assert.equal(result.frozen, 0);
|
|
619
|
-
assert.equal(store.getById(l.id).archived, true, "loser archived");
|
|
620
|
-
assert.ok(store.getById(w.id).content.includes("已否决旧信息"), "provenance note appended");
|
|
621
|
-
assert.equal(store.listConflictPending().length, 0, "no pending rows in auto mode");
|
|
622
|
-
store.close();
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
test("runDream freeze applies non-conflict decisions while parking conflicts", async () => {
|
|
626
|
-
const { store, service } = dreamSetup();
|
|
627
|
-
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
628
|
-
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
629
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
630
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
631
|
-
const ctx = freezeCtx({
|
|
632
|
-
includes: [{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }],
|
|
633
|
-
conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新" }]
|
|
634
|
-
});
|
|
635
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
636
|
-
const result = await dream.runDream(ctx, service, {
|
|
637
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
638
|
-
});
|
|
639
|
-
assert.equal(result.ok, true);
|
|
640
|
-
assert.equal(result.applied, 1, "merge applied normally");
|
|
641
|
-
assert.equal(result.frozen, 1, "conflict frozen");
|
|
642
|
-
assert.equal(store.getById(b.id).title, "插件总览", "merge keeper updated");
|
|
643
|
-
assert.equal(store.getById(a.id).archived, true, "merge source archived");
|
|
644
|
-
assert.equal(store.getById(l.id).archived, false, "conflict loser untouched by the merge run");
|
|
645
|
-
const pending = store.listConflictPending();
|
|
646
|
-
assert.equal(pending.length, 1);
|
|
647
|
-
assert.ok(pending[0].reason.includes("日期更新"));
|
|
648
|
-
store.close();
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("runDream freeze respects conflictFreezeMaxPending cap and skips overflow", async () => {
|
|
652
|
-
const { store, service } = dreamSetup();
|
|
653
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
654
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
655
|
-
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
|
|
656
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
657
|
-
const result = await dream.runDream(ctx, service, {
|
|
658
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat",
|
|
659
|
-
conflictFreezeEnabled: true, conflictFreezeMaxPending: 0
|
|
660
|
-
});
|
|
661
|
-
assert.equal(result.frozen, 0, "nothing frozen at capacity");
|
|
662
|
-
assert.equal(store.listConflictPending().length, 0, "no pending rows");
|
|
663
|
-
assert.ok(ctx.warnings.some((m) => m.includes("freeze queue full")), "capacity warning logged");
|
|
664
|
-
store.close();
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
test("runDream freeze store failure never blocks the run (fail-safe)", async () => {
|
|
668
|
-
const { store, service } = dreamSetup();
|
|
669
|
-
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
670
|
-
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
671
|
-
service.saveConflictPending = () => { throw new Error("pending store boom"); };
|
|
672
|
-
service.countConflictPending = () => { throw new Error("count boom"); };
|
|
673
|
-
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
|
|
674
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
675
|
-
const result = await dream.runDream(ctx, service, {
|
|
676
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
677
|
-
});
|
|
678
|
-
assert.equal(result.ok, true, "run completes despite freeze store failure");
|
|
679
|
-
assert.equal(result.frozen, 0, "nothing frozen");
|
|
680
|
-
assert.ok(ctx.warnings.length >= 1, "freeze failure logged");
|
|
681
|
-
assert.equal(store.getById(l.id).archived, false, "no side effects on memories");
|
|
682
|
-
assert.equal(store.getById(w.id).content, "8月20日", "winner untouched");
|
|
683
|
-
store.close();
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
// --- v0.4.4: 大记忆量 autoDream(滑动窗口 + 隐式 keep)回归 -----------------
|
|
687
|
-
|
|
688
|
-
test("autoDream with 650 memories: sliding window truncates snapshot + implicit keep fills uncovered ids", async () => {
|
|
689
|
-
const { store, service } = dreamSetup();
|
|
690
|
-
// seed 650 memories — the size that used to produce 677 "missing from
|
|
691
|
-
// decisions" errors and applied=0 under the strict full-coverage check
|
|
692
|
-
for (let i = 0; i < 650; i++) {
|
|
693
|
-
service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
694
|
-
}
|
|
695
|
-
// mock LLM claims only a small subset (20 archives) and never emits keeps
|
|
696
|
-
// for the rest — implicit keep must auto-fill the uncovered snapshot ids
|
|
697
|
-
const ctx = mockCtx({
|
|
698
|
-
onConsolidation: (listText) => {
|
|
699
|
-
const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
|
|
700
|
-
.map((m) => m[1]);
|
|
701
|
-
return JSON.stringify(ids.slice(0, 20).map((id) => ({ action: "archive", ids: [id], reason: "stale" })));
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
705
|
-
const result = await dream.runDream(ctx, service, {
|
|
706
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
|
|
707
|
-
// mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
|
|
708
|
-
// lowered so this deliberate "tiny explicit claim" scenario still passes
|
|
709
|
-
// (it exercises the window + keep-fill, not the coverage guard)
|
|
710
|
-
dreamMinExplicitCoverage: 0.1
|
|
711
|
-
});
|
|
712
|
-
assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
|
|
713
|
-
assert.ok(result.applied > 0, "archive decisions applied");
|
|
714
|
-
// snapshot capped at the sliding window
|
|
715
|
-
const run = store.listDreamRuns()[0];
|
|
716
|
-
assert.equal(run.input_count, 200, "snapshot truncated to dreamMaxSnapshotSize");
|
|
717
|
-
// decisions cover exactly the snapshot window, with implicit keeps
|
|
718
|
-
assert.equal(result.decisions.length, 200, "decisions count = snapshot count");
|
|
719
|
-
assert.equal(result.decisions.filter((d) => d.action === "archive").length, 20, "claimed subset present");
|
|
720
|
-
assert.equal(result.decisions.filter((d) => d.action === "keep").length, 180, "uncovered ids auto-kept");
|
|
721
|
-
assert.equal(store.getById(result.decisions.find((d) => d.action === "archive").ids[0]).archived, true, "an archive landed");
|
|
722
|
-
store.close();
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
// --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
|
|
726
|
-
|
|
727
|
-
test("validateDecisions rejects a truncated output whose explicit coverage is below the floor", () => {
|
|
728
|
-
const snap = snapshot(["a", "b", "c", "d"]);
|
|
729
|
-
const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
|
|
730
|
-
const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
731
|
-
assert.equal(ok, false, "low explicit coverage rejects the whole list");
|
|
732
|
-
assert.ok(
|
|
733
|
-
errors.some((e) => e.includes("explicit decision coverage 25% < minimum 50%")),
|
|
734
|
-
`coverage error present, got: ${errors.join("; ")}`
|
|
735
|
-
);
|
|
736
|
-
assert.equal(decisions.length, 1, "no keep-fill pushed on rejection (nothing washed white)");
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
|
|
740
|
-
const snap = snapshot(["a", "b", "c", "d"]);
|
|
741
|
-
const decisions = [{ action: "archive", ids: ["a", "b"], reason: "stale" }]; // claims 2/4 = 50%
|
|
742
|
-
const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
743
|
-
assert.equal(ok, true, errors.join("; "));
|
|
744
|
-
assert.equal(decisions.length, 3, "archive + 2 implicit keeps for c/d");
|
|
745
|
-
assert.equal(decisions.filter((d) => d.action === "keep").length, 2);
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test("runDream with dreamImplicitKeep=false rejects a partial mock output (missing from decisions)", async () => {
|
|
749
|
-
const { store, service } = dreamSetup();
|
|
750
|
-
for (let i = 0; i < 3; i++) {
|
|
751
|
-
service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
752
|
-
}
|
|
753
|
-
// mock only claims the first snapshot id, misses the rest — strict mode must
|
|
754
|
-
// reject the whole run instead of auto-keeping the uncovered ids
|
|
755
|
-
const warnings = [];
|
|
756
|
-
const ctx = {
|
|
757
|
-
warnings,
|
|
758
|
-
logger: { warn: (m) => warnings.push(m) },
|
|
759
|
-
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
|
|
760
|
-
llm: {
|
|
761
|
-
async *stream(options) {
|
|
762
|
-
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
763
|
-
if (userText.startsWith("id=")) {
|
|
764
|
-
const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
|
|
765
|
-
yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
|
|
766
|
-
} else {
|
|
767
|
-
yield { type: "text-delta", index: 0, text: "记忆库总览摘要" };
|
|
768
|
-
}
|
|
769
|
-
yield { type: "finish", reason: { kind: "stop" } };
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
};
|
|
773
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
774
|
-
const result = await dream.runDream(ctx, service, {
|
|
775
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat",
|
|
776
|
-
dreamImplicitKeep: false
|
|
777
|
-
});
|
|
778
|
-
assert.equal(result.ok, false, "strict mode rejects the partial output");
|
|
779
|
-
assert.match(result.error, /invalid decisions: \d+ errors/);
|
|
780
|
-
assert.ok(warnings.some((m) => m.includes("missing from decisions")), "warned which ids are missing");
|
|
781
|
-
assert.equal(store.listDreamRuns()[0].status, "failed", "run audited as failed");
|
|
782
|
-
store.close();
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
test("runDream sliding window keeps the newest N memories and excludes the oldest", async () => {
|
|
786
|
-
const { store, service } = dreamSetup();
|
|
787
|
-
// seed 5 memories, backdate updated_at so i=0 is oldest (5h ago), i=4 newest (1h ago)
|
|
788
|
-
const ids = [];
|
|
789
|
-
for (let i = 0; i < 5; i++) {
|
|
790
|
-
const { memory } = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
791
|
-
ids.push(memory.id);
|
|
792
|
-
}
|
|
793
|
-
for (let i = 0; i < 5; i++) {
|
|
794
|
-
const old = new Date(Date.now() - (5 - i) * 3600000).toISOString();
|
|
795
|
-
store.db.prepare("UPDATE memories SET updated_at = ?, created_at = ? WHERE id = ?").run(old, old, ids[i]);
|
|
796
|
-
}
|
|
797
|
-
const ctx = mockCtx({
|
|
798
|
-
onConsolidation: (listText) => {
|
|
799
|
-
const inWindow = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
|
|
800
|
-
.map((m) => m[1]);
|
|
801
|
-
return JSON.stringify(inWindow.map((id) => ({ action: "keep", ids: [id] })));
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
805
|
-
const result = await dream.runDream(ctx, service, {
|
|
806
|
-
dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 3
|
|
807
|
-
});
|
|
808
|
-
const run = store.listDreamRuns()[0];
|
|
809
|
-
assert.equal(run.input_count, 3, "window capped at dreamMaxSnapshotSize");
|
|
810
|
-
const windowIds = run.input.map((m) => m.id).sort();
|
|
811
|
-
const expected = [ids[2], ids[3], ids[4]].sort(); // newest 3 by updated_at
|
|
812
|
-
assert.deepEqual(windowIds, expected, "window contains the newest 3 memories");
|
|
813
|
-
assert.ok(!windowIds.includes(ids[0]), "oldest memory excluded from the window");
|
|
814
|
-
assert.equal(result.decisions.length, 3, "decisions cover exactly the window");
|
|
815
|
-
store.close();
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
// --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
|
|
819
|
-
|
|
820
|
-
test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
|
|
821
|
-
const { store, service } = dreamSetup();
|
|
822
|
-
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
823
|
-
let systemText = "";
|
|
824
|
-
const ctx = {
|
|
825
|
-
logger: { warn: () => {} },
|
|
826
|
-
llm: {
|
|
827
|
-
async *stream(options) {
|
|
828
|
-
systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
|
|
829
|
-
yield { type: "text-delta", text: "[]" };
|
|
830
|
-
yield { type: "finish", reason: { kind: "ok" } };
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
};
|
|
834
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
835
|
-
await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
836
|
-
// 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
|
|
837
|
-
assert.match(systemText, /"action"/, "prompt names the action field");
|
|
838
|
-
assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
|
|
839
|
-
// conflict winner/loser 是单个 id 字符串而非数组
|
|
840
|
-
assert.match(systemText, /"winner"/, "prompt names the winner field");
|
|
841
|
-
assert.match(systemText, /"loser"/, "prompt names the loser field");
|
|
842
|
-
assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
|
|
843
|
-
assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
|
|
844
|
-
// 同一 id 不可被多个决策重复 claim
|
|
845
|
-
assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
|
|
846
|
-
// 决策 JSON 示例块
|
|
847
|
-
assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
|
|
848
|
-
store.close();
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
// --- Bug8: llm_audit_logs trail ----------------------------------------------
|
|
852
|
-
|
|
853
|
-
test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
|
|
854
|
-
const { store, service } = dreamSetup();
|
|
855
|
-
service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
856
|
-
service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
857
|
-
const ctx = mockCtx({
|
|
858
|
-
onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
|
|
859
|
-
});
|
|
860
|
-
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
861
|
-
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
862
|
-
assert.equal(result.ok, true, "run succeeds");
|
|
863
|
-
const rows = store.listLlmAudits();
|
|
864
|
-
assert.equal(rows.length, 2, "consolidation + summary both audited");
|
|
865
|
-
assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
|
|
866
|
-
assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
|
|
867
|
-
const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
|
|
868
|
-
assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
|
|
869
|
-
const summarize = rows.find((r) => r.operation_type === "dream_summarize");
|
|
870
|
-
assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
|
|
871
|
-
for (const row of rows) {
|
|
872
|
-
assert.equal(row.status, "success");
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
assert.equal(row.
|
|
876
|
-
assert.
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
assert.equal(
|
|
897
|
-
|
|
898
|
-
assert.equal(rows
|
|
899
|
-
assert.
|
|
900
|
-
|
|
901
|
-
|
|
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
|
+
import { mockCtx } from "./helpers/dream-mock.js";
|
|
7
|
+
|
|
8
|
+
function snapshot(ids, type = "project") {
|
|
9
|
+
return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("valid decision list passes", () => {
|
|
13
|
+
const snap = snapshot(["a", "b", "c"]);
|
|
14
|
+
const decisions = [
|
|
15
|
+
{ action: "keep", ids: ["a"], reason: "ok" },
|
|
16
|
+
{ action: "merge", ids: ["b", "c"], title: "bc", content: "merged", importance: 4, keepSource: "b" }
|
|
17
|
+
];
|
|
18
|
+
const { ok, errors } = validateDecisions(decisions, snap);
|
|
19
|
+
assert.equal(ok, true);
|
|
20
|
+
assert.deepEqual(errors, []);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("unknown id rejects whole list", () => {
|
|
24
|
+
const snap = snapshot(["a"]);
|
|
25
|
+
const { ok, errors } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
|
|
26
|
+
assert.equal(ok, false);
|
|
27
|
+
assert.ok(errors.some((e) => e.includes("zzz")));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("invalid action rejects", () => {
|
|
31
|
+
const snap = snapshot(["a"]);
|
|
32
|
+
const { ok } = validateDecisions([{ action: "explode", ids: ["a"] }], snap);
|
|
33
|
+
assert.equal(ok, false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("merge keepSource must be in ids", () => {
|
|
37
|
+
const snap = snapshot(["a", "b"]);
|
|
38
|
+
const { ok } = validateDecisions([{ action: "merge", ids: ["a"], keepSource: "b", title: "t", content: "c" }], snap);
|
|
39
|
+
assert.equal(ok, false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("conflict winner and loser must exist and differ", () => {
|
|
43
|
+
const snap = snapshot(["a", "b"]);
|
|
44
|
+
const { ok } = validateDecisions([{ action: "conflict", winner: "a", loser: "a" }], snap);
|
|
45
|
+
assert.equal(ok, false);
|
|
46
|
+
const { ok: ok2 } = validateDecisions([{ action: "conflict", winner: "a", loser: "zzz" }], snap);
|
|
47
|
+
assert.equal(ok2, false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("duplicate primary ids across decisions reject", () => {
|
|
51
|
+
const snap = snapshot(["a", "b"]);
|
|
52
|
+
const { ok } = validateDecisions([
|
|
53
|
+
{ action: "archive", ids: ["a"] },
|
|
54
|
+
{ action: "keep", ids: ["a"] }
|
|
55
|
+
], snap);
|
|
56
|
+
assert.equal(ok, false, "a claimed twice");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("archived or summary entries cannot be decision targets", () => {
|
|
60
|
+
const snap = new Map([["arch", { id: "arch", type: "project", title: "t", content: "c", importance: 3, archived: true, forgotten: false }]]);
|
|
61
|
+
const { ok } = validateDecisions([{ action: "archive", ids: ["arch"] }], snap);
|
|
62
|
+
assert.equal(ok, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty decision list rejects", () => {
|
|
66
|
+
const { ok, errors } = validateDecisions([], snapshot(["a"]));
|
|
67
|
+
assert.equal(ok, false);
|
|
68
|
+
assert.ok(errors.some((e) => e.includes("non-empty array")));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("empty ids rejects", () => {
|
|
72
|
+
const { ok } = validateDecisions([{ action: "archive", ids: [] }], snapshot(["a"]));
|
|
73
|
+
assert.equal(ok, false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("merge requires non-empty title and content", () => {
|
|
77
|
+
const snap = snapshot(["a"]);
|
|
78
|
+
const base = { action: "merge", ids: ["a"], keepSource: "a" };
|
|
79
|
+
for (const [title, content] of [["", "x"], ["t", ""], [undefined, "x"], ["t", undefined]]) {
|
|
80
|
+
const { ok } = validateDecisions([{ ...base, title, content }], snap);
|
|
81
|
+
assert.equal(ok, false, `title=${JSON.stringify(title)} content=${JSON.stringify(content)}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("summary entries cannot be decision targets", () => {
|
|
86
|
+
const snap = new Map([["s", { id: "s", type: "summary", title: "t", content: "c", importance: 3, archived: false, forgotten: false }]]);
|
|
87
|
+
const { ok } = validateDecisions([{ action: "archive", ids: ["s"] }], snap);
|
|
88
|
+
assert.equal(ok, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
|
|
92
|
+
const snap = snapshot(["a", "b"]);
|
|
93
|
+
const decisions = [{ action: "keep", ids: ["a"] }];
|
|
94
|
+
const { ok, errors } = validateDecisions(decisions, snap);
|
|
95
|
+
assert.equal(ok, true, errors.join("; "));
|
|
96
|
+
assert.equal(decisions.length, 2, "keep appended for the uncovered snapshot id");
|
|
97
|
+
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("b")), "b auto-kept");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("dreamImplicitKeep=false keeps the strict full-coverage validation", () => {
|
|
101
|
+
const snap = snapshot(["a", "b"]);
|
|
102
|
+
const decisions = [{ action: "keep", ids: ["a"] }];
|
|
103
|
+
const { ok, errors } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
|
|
104
|
+
assert.equal(ok, false);
|
|
105
|
+
assert.ok(errors.some((e) => e.includes("missing from decisions")));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("duplicate ids within one decision reject", () => {
|
|
109
|
+
const { ok } = validateDecisions([{ action: "keep", ids: ["a", "a"] }], snapshot(["a"]));
|
|
110
|
+
assert.equal(ok, false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("merge importance out of range rejects", () => {
|
|
114
|
+
const snap = snapshot(["a", "b"]);
|
|
115
|
+
for (const importance of [0, 6, 99, 1.5, "4"]) {
|
|
116
|
+
const { ok, errors } = validateDecisions([
|
|
117
|
+
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance }
|
|
118
|
+
], snap);
|
|
119
|
+
assert.equal(ok, false, `importance=${JSON.stringify(importance)} rejected`);
|
|
120
|
+
assert.ok(errors.some((e) => e.includes("importance")), `importance error present for ${JSON.stringify(importance)}`);
|
|
121
|
+
}
|
|
122
|
+
const { ok } = validateDecisions([
|
|
123
|
+
{ action: "merge", ids: ["a", "b"], keepSource: "a", title: "t", content: "c", importance: 5 }
|
|
124
|
+
], snap);
|
|
125
|
+
assert.equal(ok, true, "importance 5 accepted");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("merge across types rejects", () => {
|
|
129
|
+
const snap = new Map([
|
|
130
|
+
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
131
|
+
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
|
|
132
|
+
]);
|
|
133
|
+
const { ok, errors } = validateDecisions([
|
|
134
|
+
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
|
|
135
|
+
], snap);
|
|
136
|
+
assert.equal(ok, false, "cross-type merge rejected");
|
|
137
|
+
assert.ok(errors.some((e) => e.includes("multiple types")), "multi-type error present");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function dreamSetup() {
|
|
141
|
+
const store = createStore(":memory:");
|
|
142
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
143
|
+
return { store, service };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
test("applyDecisions merges: keepSource updated, others archived", () => {
|
|
147
|
+
const { store, service } = dreamSetup();
|
|
148
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
149
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
150
|
+
const { applied } = applyDecisions([
|
|
151
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
152
|
+
], service);
|
|
153
|
+
assert.equal(applied, 1);
|
|
154
|
+
const keeper = store.getById(b.id);
|
|
155
|
+
assert.equal(keeper.content, "合并内容");
|
|
156
|
+
assert.equal(keeper.title, "插件总览");
|
|
157
|
+
assert.equal(keeper.importance, 5);
|
|
158
|
+
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("applyDecisions conflict: winner kept, loser archived with provenance", () => {
|
|
162
|
+
const { store, service } = dreamSetup();
|
|
163
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
164
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
165
|
+
applyDecisions([{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }], service);
|
|
166
|
+
assert.equal(store.getById(l.id).archived, true);
|
|
167
|
+
const winner = store.getById(w.id);
|
|
168
|
+
assert.ok(winner.content.includes("8月20日"), "winner content intact");
|
|
169
|
+
assert.ok(winner.content.includes("已否决旧信息"), "provenance note appended");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("applyDecisions archive and keep", () => {
|
|
173
|
+
const { store, service } = dreamSetup();
|
|
174
|
+
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
175
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "废弃", content: "过时" });
|
|
176
|
+
applyDecisions([
|
|
177
|
+
{ action: "keep", ids: [k.id] },
|
|
178
|
+
{ action: "archive", ids: [a.id], reason: "过时" }
|
|
179
|
+
], service);
|
|
180
|
+
assert.equal(store.getById(k.id).archived, false);
|
|
181
|
+
assert.equal(store.getById(a.id).archived, true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("applyDecisions returns count and never throws on unknown id (skip)", () => {
|
|
185
|
+
const { store, service } = dreamSetup();
|
|
186
|
+
const { memory: k } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
187
|
+
const { applied } = applyDecisions([{ action: "archive", ids: ["ghost"], reason: "x" }], service);
|
|
188
|
+
assert.equal(applied, 0);
|
|
189
|
+
assert.equal(store.getById(k.id).archived, false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("applyDecisions catch path: throwing decision is skipped, logged, and later decisions still apply", () => {
|
|
193
|
+
const { store, service } = dreamSetup();
|
|
194
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "会炸", content: "x" });
|
|
195
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "正常", content: "y" });
|
|
196
|
+
const originalSetArchived = service.setArchived;
|
|
197
|
+
let calls = 0;
|
|
198
|
+
service.setArchived = (id, archived) => {
|
|
199
|
+
calls++;
|
|
200
|
+
if (calls === 1) throw new Error("boom");
|
|
201
|
+
return originalSetArchived.call(service, id, archived);
|
|
202
|
+
};
|
|
203
|
+
const warnings = [];
|
|
204
|
+
const logger = { warn: (msg) => warnings.push(msg) };
|
|
205
|
+
const { applied, failures } = applyDecisions([
|
|
206
|
+
{ action: "archive", ids: [a.id], reason: "x" },
|
|
207
|
+
{ action: "archive", ids: [b.id], reason: "y" }
|
|
208
|
+
], service, logger);
|
|
209
|
+
assert.equal(applied, 1, "throwing decision not counted, surviving decision counted");
|
|
210
|
+
assert.equal(failures.length, 1, "thrown decision reported as a failure");
|
|
211
|
+
assert.equal(store.getById(a.id).archived, false, "throwing decision left no partial effect");
|
|
212
|
+
assert.equal(store.getById(b.id).archived, true, "later decision still applied");
|
|
213
|
+
assert.equal(warnings.length, 1, "logger called once");
|
|
214
|
+
assert.match(warnings[0], /failed to apply archive at index 0: boom/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("applyDecisions conflict with missing loser skips cleanly", () => {
|
|
218
|
+
const { store, service } = dreamSetup();
|
|
219
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
220
|
+
const { applied } = applyDecisions([{ action: "conflict", winner: w.id, loser: "ghost", reason: "x" }], service);
|
|
221
|
+
assert.equal(applied, 0);
|
|
222
|
+
assert.equal(store.getById(w.id).archived, false, "winner untouched");
|
|
223
|
+
assert.ok(!store.getById(w.id).content.includes("已否决"), "no provenance note appended");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("applyDecisions merge with missing keeper skips cleanly", () => {
|
|
227
|
+
const { store, service } = dreamSetup();
|
|
228
|
+
const { applied } = applyDecisions([
|
|
229
|
+
{ action: "merge", ids: ["ghost"], keepSource: "ghost", title: "t", content: "c" }
|
|
230
|
+
], service);
|
|
231
|
+
assert.equal(applied, 0);
|
|
232
|
+
assert.equal(store.getById("ghost"), undefined);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("applyDecisions merge without importance falls back to max source importance", () => {
|
|
236
|
+
const { store, service } = dreamSetup();
|
|
237
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
238
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
239
|
+
const { applied } = applyDecisions([
|
|
240
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", keepSource: b.id }
|
|
241
|
+
], service);
|
|
242
|
+
assert.equal(applied, 1);
|
|
243
|
+
assert.equal(store.getById(b.id).importance, 4, "keeper keeps max of source importances");
|
|
244
|
+
assert.equal(store.getById(a.id).archived, true, "source archived");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("maybeSchedule triggers when count exceeds threshold", () => {
|
|
248
|
+
const { store, service } = dreamSetup();
|
|
249
|
+
let runs = 0;
|
|
250
|
+
const dream = createDreamScheduler({
|
|
251
|
+
onRun: async () => { runs++; },
|
|
252
|
+
thresholdCount: 3,
|
|
253
|
+
thresholdChars: 5000,
|
|
254
|
+
delayMs: 0
|
|
255
|
+
});
|
|
256
|
+
for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(100) });
|
|
257
|
+
const pending = dream.maybeSchedule(service);
|
|
258
|
+
assert.equal(pending, true, "scheduled");
|
|
259
|
+
assert.equal(runs, 0, "not run yet (async)");
|
|
260
|
+
store.close();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("maybeSchedule does not trigger below threshold", () => {
|
|
264
|
+
const { store, service } = dreamSetup();
|
|
265
|
+
const dream = createDreamScheduler({ onRun: async () => {}, thresholdCount: 10, thresholdChars: 5000, delayMs: 0 });
|
|
266
|
+
service.saveWithDedupe({ type: "project", title: "only", content: "x" });
|
|
267
|
+
assert.equal(dream.maybeSchedule(service), false);
|
|
268
|
+
store.close();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("scheduler fires async and resets baseline", async () => {
|
|
272
|
+
const { store, service } = dreamSetup();
|
|
273
|
+
let runs = 0;
|
|
274
|
+
const dream = createDreamScheduler({
|
|
275
|
+
onRun: async () => { runs++; return { ok: true }; },
|
|
276
|
+
thresholdCount: 2, thresholdChars: 5000, delayMs: 5
|
|
277
|
+
});
|
|
278
|
+
for (let i = 0; i < 2; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) });
|
|
279
|
+
dream.maybeSchedule(service);
|
|
280
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
281
|
+
assert.equal(runs, 1, "ran once");
|
|
282
|
+
// still above threshold but baseline reset → no immediate re-trigger
|
|
283
|
+
assert.equal(dream.maybeSchedule(service), false, "baseline prevents loop");
|
|
284
|
+
store.close();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("failed run does not refresh baseline: next write re-triggers", async () => {
|
|
288
|
+
const { store, service } = dreamSetup();
|
|
289
|
+
let calls = 0;
|
|
290
|
+
const dream = createDreamScheduler({
|
|
291
|
+
onRun: async () => {
|
|
292
|
+
calls++;
|
|
293
|
+
return { ok: false, error: "llm failed" };
|
|
294
|
+
},
|
|
295
|
+
thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
|
|
296
|
+
logger: { warn: () => {} }
|
|
297
|
+
});
|
|
298
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
|
|
299
|
+
service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
|
|
300
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
301
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
302
|
+
assert.equal(calls, 1, "run attempted once");
|
|
303
|
+
// baseline NOT refreshed on failure → the same write volume still triggers
|
|
304
|
+
assert.equal(dream.maybeSchedule(service), true, "failed run keeps baseline, re-schedules");
|
|
305
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
306
|
+
assert.equal(calls, 2, "retried after failure");
|
|
307
|
+
store.close();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("noop run (nothing changed) does not refresh baseline: next write re-triggers", async () => {
|
|
311
|
+
const { store, service } = dreamSetup();
|
|
312
|
+
let calls = 0;
|
|
313
|
+
const dream = createDreamScheduler({
|
|
314
|
+
onRun: async () => { calls++; return { ok: false, status: "noop", applied: 0, summary: false }; },
|
|
315
|
+
thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
|
|
316
|
+
logger: { warn: () => {} }
|
|
317
|
+
});
|
|
318
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
|
|
319
|
+
service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
|
|
320
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
321
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
322
|
+
assert.equal(calls, 1, "run attempted once");
|
|
323
|
+
// a noop is not a success: the baseline stays put so the accumulated writes
|
|
324
|
+
// are still owed and the next write re-schedules instead of being absorbed
|
|
325
|
+
assert.equal(dream.maybeSchedule(service), true, "noop keeps baseline, re-schedules");
|
|
326
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
327
|
+
assert.equal(calls, 2, "retried after noop");
|
|
328
|
+
store.close();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("throwing run does not refresh baseline and is logged", async () => {
|
|
332
|
+
const { store, service } = dreamSetup();
|
|
333
|
+
const warnings = [];
|
|
334
|
+
let calls = 0;
|
|
335
|
+
const dream = createDreamScheduler({
|
|
336
|
+
onRun: async () => {
|
|
337
|
+
calls++;
|
|
338
|
+
throw new Error("boom");
|
|
339
|
+
},
|
|
340
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
341
|
+
logger: { warn: (msg) => warnings.push(msg) }
|
|
342
|
+
});
|
|
343
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
344
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
345
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
346
|
+
assert.equal(calls, 1, "run attempted once");
|
|
347
|
+
assert.ok(warnings.some((m) => m.includes("run failed")), "throw logged");
|
|
348
|
+
assert.equal(dream.maybeSchedule(service), true, "throw keeps baseline, re-schedules");
|
|
349
|
+
store.close();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("maybeSchedule returns false while a run is in flight", async () => {
|
|
353
|
+
const { store, service } = dreamSetup();
|
|
354
|
+
let release;
|
|
355
|
+
const gate = new Promise((r) => { release = r; });
|
|
356
|
+
let entered = false;
|
|
357
|
+
const dream = createDreamScheduler({
|
|
358
|
+
onRun: async () => { entered = true; await gate; },
|
|
359
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
360
|
+
logger: { warn: () => {} }
|
|
361
|
+
});
|
|
362
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
363
|
+
assert.equal(dream.maybeSchedule(service), true, "first schedule accepted");
|
|
364
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
365
|
+
assert.equal(entered, true, "run started");
|
|
366
|
+
assert.equal(dream.maybeSchedule(service), false, "no schedule while running");
|
|
367
|
+
release();
|
|
368
|
+
await new Promise((r) => setTimeout(r, 10)); // let the run finish + baseline refresh
|
|
369
|
+
store.close();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("dispose clears pending timer and blocks future scheduling", async () => {
|
|
373
|
+
const { store, service } = dreamSetup();
|
|
374
|
+
let runs = 0;
|
|
375
|
+
const dream = createDreamScheduler({
|
|
376
|
+
onRun: async () => { runs++; },
|
|
377
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 5,
|
|
378
|
+
logger: { warn: () => {} }
|
|
379
|
+
});
|
|
380
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
381
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
382
|
+
dream.dispose();
|
|
383
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
384
|
+
assert.equal(runs, 0, "pending run cancelled by dispose");
|
|
385
|
+
assert.equal(dream.maybeSchedule(service), false, "disposed scheduler never schedules");
|
|
386
|
+
store.close();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("dispose awaits an in-flight run so the store can close safely", async () => {
|
|
390
|
+
const { store, service } = dreamSetup();
|
|
391
|
+
let release;
|
|
392
|
+
const gate = new Promise((r) => { release = r; });
|
|
393
|
+
let entered = false;
|
|
394
|
+
let finished = false;
|
|
395
|
+
const dream = createDreamScheduler({
|
|
396
|
+
onRun: async () => { entered = true; await gate; finished = true; },
|
|
397
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 0,
|
|
398
|
+
logger: { warn: () => {} }
|
|
399
|
+
});
|
|
400
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
401
|
+
assert.equal(dream.maybeSchedule(service), true, "scheduled");
|
|
402
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
403
|
+
assert.equal(entered, true, "run started");
|
|
404
|
+
|
|
405
|
+
const disposeP = dream.dispose();
|
|
406
|
+
let settled = false;
|
|
407
|
+
disposeP.then(() => { settled = true; });
|
|
408
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
409
|
+
assert.equal(settled, false, "dispose must not resolve while a run is in flight");
|
|
410
|
+
assert.equal(finished, false, "run still pending");
|
|
411
|
+
|
|
412
|
+
release();
|
|
413
|
+
await disposeP;
|
|
414
|
+
assert.equal(finished, true, "run completed before dispose resolved");
|
|
415
|
+
store.close();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("runDream stores summary and applies decisions", async () => {
|
|
419
|
+
const { store, service } = dreamSetup();
|
|
420
|
+
// seed 2 memories so snapshot is non-empty and decisions cover them
|
|
421
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
422
|
+
const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
423
|
+
let calls = 0;
|
|
424
|
+
const ctx = {
|
|
425
|
+
llm: {
|
|
426
|
+
stream: async function* () {
|
|
427
|
+
calls++;
|
|
428
|
+
if (calls === 1) {
|
|
429
|
+
const text = JSON.stringify([
|
|
430
|
+
{ action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }
|
|
431
|
+
]);
|
|
432
|
+
yield { type: "text-delta", text };
|
|
433
|
+
} else {
|
|
434
|
+
yield { type: "text-delta", text: "记忆库总览摘要文本" };
|
|
435
|
+
}
|
|
436
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
logger: { warn: () => {} }
|
|
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, true);
|
|
444
|
+
assert.equal(result.applied, 1, "merge decision applied");
|
|
445
|
+
assert.equal(result.summary, true, "summary stored");
|
|
446
|
+
const keeper = store.getById(a.memory.id);
|
|
447
|
+
assert.equal(keeper.title, "合并标题", "keeper title updated");
|
|
448
|
+
assert.equal(keeper.content, "合并后的内容", "keeper content updated");
|
|
449
|
+
assert.equal(keeper.importance, 4, "keeper importance updated");
|
|
450
|
+
assert.equal(store.getById(b.memory.id).archived, true, "merged source archived");
|
|
451
|
+
const summary = store.all().find((m) => m.type === "summary");
|
|
452
|
+
assert.ok(summary, "summary created");
|
|
453
|
+
assert.equal(summary.title, "记忆库总览");
|
|
454
|
+
assert.equal(summary.content, "记忆库总览摘要文本");
|
|
455
|
+
store.close();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("runDream fails safe on invalid decisions", async () => {
|
|
459
|
+
const { store, service } = dreamSetup();
|
|
460
|
+
const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
461
|
+
let calls = 0;
|
|
462
|
+
const warnings = [];
|
|
463
|
+
const ctx = {
|
|
464
|
+
llm: {
|
|
465
|
+
stream: async function* () {
|
|
466
|
+
calls++;
|
|
467
|
+
yield { type: "text-delta", text: calls === 1 ? "not json at all" : "summary" };
|
|
468
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
logger: { warn: (msg) => warnings.push(msg) }
|
|
472
|
+
};
|
|
473
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
474
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
475
|
+
assert.equal(result.ok, false, "invalid decisions rejected");
|
|
476
|
+
assert.equal(result.summary, false, "no summary flag on failure");
|
|
477
|
+
assert.equal(store.all().filter((m) => m.type === "summary").length, 0, "no summary on failure");
|
|
478
|
+
const lang = store.getById(saved.memory.id);
|
|
479
|
+
assert.ok(lang, "original memory still present");
|
|
480
|
+
assert.equal(lang.archived, false, "original memory not archived");
|
|
481
|
+
assert.equal(lang.content, "中文", "original memory content untouched");
|
|
482
|
+
assert.ok(warnings.length >= 1, "failure was logged");
|
|
483
|
+
store.close();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// --- item ①: CAS guard against concurrent edits ----------------------------
|
|
487
|
+
|
|
488
|
+
test("applyDecisions CAS: merge onto a concurrently-edited target is skipped as a conflict, not applied", () => {
|
|
489
|
+
const { store, service } = dreamSetup();
|
|
490
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
491
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
492
|
+
// snapshot captured before the "LLM call"; a concurrent edit lands meanwhile
|
|
493
|
+
const snapshot = new Map([a.id, b.id].map((id) => [id, store.getById(id)]));
|
|
494
|
+
service.update(a.id, { content: "并发编辑" });
|
|
495
|
+
const { applied, conflicts, committed } = applyDecisions([
|
|
496
|
+
{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }
|
|
497
|
+
], service, null, snapshot);
|
|
498
|
+
assert.equal(applied, 0, "decision skipped entirely");
|
|
499
|
+
assert.equal(conflicts.length, 1, "recorded as a CAS conflict");
|
|
500
|
+
assert.equal(committed.length, 0, "nothing committed");
|
|
501
|
+
assert.equal(store.getById(a.id).content, "并发编辑", "concurrent edit preserved");
|
|
502
|
+
assert.equal(store.getById(b.id).archived, false, "source not archived");
|
|
503
|
+
assert.equal(store.getById(b.id).title, "插件2", "keeper untouched");
|
|
504
|
+
store.close();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("applyDecisions CAS: update to a concurrently-edited memory is skipped as a conflict", () => {
|
|
508
|
+
const { store, service } = dreamSetup();
|
|
509
|
+
const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
|
|
510
|
+
const snapshot = new Map([[m.id, store.getById(m.id)]]);
|
|
511
|
+
service.update(m.id, { content: "并发改动" });
|
|
512
|
+
const { applied, conflicts } = applyDecisions(
|
|
513
|
+
[{ action: "update", ids: [m.id], content: "喜欢 Rust" }],
|
|
514
|
+
service, null, snapshot
|
|
515
|
+
);
|
|
516
|
+
assert.equal(applied, 0);
|
|
517
|
+
assert.equal(conflicts.length, 1);
|
|
518
|
+
assert.equal(store.getById(m.id).content, "并发改动", "concurrent edit wins");
|
|
519
|
+
store.close();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("applyDecisions without a snapshot skips the CAS guard (replay path unchanged)", () => {
|
|
523
|
+
const { store, service } = dreamSetup();
|
|
524
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
525
|
+
service.update(a.id, { content: "并发编辑" }); // concurrent edit
|
|
526
|
+
const { applied, conflicts } = applyDecisions([
|
|
527
|
+
{ action: "archive", ids: [a.id], reason: "x" }
|
|
528
|
+
], service);
|
|
529
|
+
assert.equal(applied, 1, "snapshotless replay applies (no CAS guard)");
|
|
530
|
+
assert.equal(conflicts.length, 0);
|
|
531
|
+
assert.equal(store.getById(a.id).archived, true);
|
|
532
|
+
store.close();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// --- item ②: per-decision transaction atomicity ----------------------------
|
|
536
|
+
|
|
537
|
+
test("applyDecisions merge is atomic: a throwing archive step rolls back the keeper update too", () => {
|
|
538
|
+
const { store, service } = dreamSetup();
|
|
539
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "旧甲", importance: 3 });
|
|
540
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "旧乙", importance: 4 });
|
|
541
|
+
const originalSetArchived = service.setArchived;
|
|
542
|
+
service.setArchived = (id, archived) => {
|
|
543
|
+
if (id === a.id) throw new Error("archive boom");
|
|
544
|
+
return originalSetArchived.call(service, id, archived);
|
|
545
|
+
};
|
|
546
|
+
const warnings = [];
|
|
547
|
+
const { applied, failures, committed } = applyDecisions([
|
|
548
|
+
{ action: "merge", ids: [a.id, b.id], title: "甲乙", content: "合并", importance: 4, keepSource: b.id }
|
|
549
|
+
], service, { warn: (m) => warnings.push(m) });
|
|
550
|
+
assert.equal(applied, 0, "merge not committed");
|
|
551
|
+
assert.equal(failures.length, 1, "reported as a failure");
|
|
552
|
+
assert.equal(committed.length, 0, "outcome must not claim a merge that rolled back");
|
|
553
|
+
assert.equal(store.getById(b.id).title, "乙", "keeper title untouched by the rolled-back update");
|
|
554
|
+
assert.equal(store.getById(b.id).content, "旧乙", "keeper content untouched");
|
|
555
|
+
assert.equal(store.getById(a.id).archived, false, "source not archived");
|
|
556
|
+
assert.ok(warnings.length >= 1, "failure logged");
|
|
557
|
+
store.close();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// --- conflict freeze: manual review instead of auto-adjudication ----------
|
|
561
|
+
|
|
562
|
+
function freezeCtx({ conflicts, includes = [], summaryText = "记忆库总览摘要" }) {
|
|
563
|
+
let calls = 0;
|
|
564
|
+
const warnings = [];
|
|
565
|
+
const ctx = {
|
|
566
|
+
warnings,
|
|
567
|
+
llm: {
|
|
568
|
+
stream: async function* () {
|
|
569
|
+
calls++;
|
|
570
|
+
const list = [...includes, ...conflicts];
|
|
571
|
+
yield { type: "text-delta", text: calls === 1 ? JSON.stringify(list) : summaryText };
|
|
572
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
logger: { warn: (m) => warnings.push(m) }
|
|
576
|
+
};
|
|
577
|
+
return ctx;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
test("runDream with conflictFreezeEnabled parks conflicts instead of adjudicating", async () => {
|
|
581
|
+
const { store, service } = dreamSetup();
|
|
582
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
583
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
584
|
+
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新,候选取新" }] });
|
|
585
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
586
|
+
const result = await dream.runDream(ctx, service, {
|
|
587
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
588
|
+
});
|
|
589
|
+
assert.equal(result.ok, true);
|
|
590
|
+
assert.equal(result.applied, 0, "no conflict applied");
|
|
591
|
+
assert.equal(result.frozen, 1, "one conflict frozen");
|
|
592
|
+
// pending row recorded for human review
|
|
593
|
+
const pending = store.listConflictPending();
|
|
594
|
+
assert.equal(pending.length, 1);
|
|
595
|
+
assert.equal(pending[0].reason, "日期更新,候选取新");
|
|
596
|
+
// neither side was auto-adjudicated
|
|
597
|
+
assert.equal(store.getById(l.id).archived, false, "loser NOT archived");
|
|
598
|
+
assert.equal(store.getById(w.id).archived, false, "winner NOT archived");
|
|
599
|
+
assert.ok(!store.getById(w.id).content.includes("已否决旧信息"), "no provenance note appended");
|
|
600
|
+
// audit outcome marks both sides pending
|
|
601
|
+
const run = store.listDreamRuns()[0];
|
|
602
|
+
assert.equal(run.outcome.byId[w.id], "conflict-pending");
|
|
603
|
+
assert.equal(run.outcome.byId[l.id], "conflict-pending");
|
|
604
|
+
assert.equal(run.status, "ok", "summary stored + freeze landed → ok");
|
|
605
|
+
assert.equal(store.listReceipts().length, 0, "no conflict receipt for a frozen (unapplied) conflict");
|
|
606
|
+
store.close();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("runDream freeze keeps auto-adjudication when disabled (default)", async () => {
|
|
610
|
+
const { store, service } = dreamSetup();
|
|
611
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
612
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
613
|
+
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" }] });
|
|
614
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
615
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
616
|
+
assert.equal(result.ok, true);
|
|
617
|
+
assert.equal(result.applied, 1, "conflict auto-adjudicated when freeze is off");
|
|
618
|
+
assert.equal(result.frozen, 0);
|
|
619
|
+
assert.equal(store.getById(l.id).archived, true, "loser archived");
|
|
620
|
+
assert.ok(store.getById(w.id).content.includes("已否决旧信息"), "provenance note appended");
|
|
621
|
+
assert.equal(store.listConflictPending().length, 0, "no pending rows in auto mode");
|
|
622
|
+
store.close();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("runDream freeze applies non-conflict decisions while parking conflicts", async () => {
|
|
626
|
+
const { store, service } = dreamSetup();
|
|
627
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
628
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
629
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
630
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
631
|
+
const ctx = freezeCtx({
|
|
632
|
+
includes: [{ action: "merge", ids: [a.id, b.id], title: "插件总览", content: "合并内容", importance: 5, keepSource: b.id }],
|
|
633
|
+
conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期更新" }]
|
|
634
|
+
});
|
|
635
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
636
|
+
const result = await dream.runDream(ctx, service, {
|
|
637
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
638
|
+
});
|
|
639
|
+
assert.equal(result.ok, true);
|
|
640
|
+
assert.equal(result.applied, 1, "merge applied normally");
|
|
641
|
+
assert.equal(result.frozen, 1, "conflict frozen");
|
|
642
|
+
assert.equal(store.getById(b.id).title, "插件总览", "merge keeper updated");
|
|
643
|
+
assert.equal(store.getById(a.id).archived, true, "merge source archived");
|
|
644
|
+
assert.equal(store.getById(l.id).archived, false, "conflict loser untouched by the merge run");
|
|
645
|
+
const pending = store.listConflictPending();
|
|
646
|
+
assert.equal(pending.length, 1);
|
|
647
|
+
assert.ok(pending[0].reason.includes("日期更新"));
|
|
648
|
+
store.close();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("runDream freeze respects conflictFreezeMaxPending cap and skips overflow", async () => {
|
|
652
|
+
const { store, service } = dreamSetup();
|
|
653
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
654
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
655
|
+
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
|
|
656
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
657
|
+
const result = await dream.runDream(ctx, service, {
|
|
658
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat",
|
|
659
|
+
conflictFreezeEnabled: true, conflictFreezeMaxPending: 0
|
|
660
|
+
});
|
|
661
|
+
assert.equal(result.frozen, 0, "nothing frozen at capacity");
|
|
662
|
+
assert.equal(store.listConflictPending().length, 0, "no pending rows");
|
|
663
|
+
assert.ok(ctx.warnings.some((m) => m.includes("freeze queue full")), "capacity warning logged");
|
|
664
|
+
store.close();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("runDream freeze store failure never blocks the run (fail-safe)", async () => {
|
|
668
|
+
const { store, service } = dreamSetup();
|
|
669
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
670
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
671
|
+
service.saveConflictPending = () => { throw new Error("pending store boom"); };
|
|
672
|
+
service.countConflictPending = () => { throw new Error("count boom"); };
|
|
673
|
+
const ctx = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "x" }] });
|
|
674
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
675
|
+
const result = await dream.runDream(ctx, service, {
|
|
676
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat", conflictFreezeEnabled: true
|
|
677
|
+
});
|
|
678
|
+
assert.equal(result.ok, true, "run completes despite freeze store failure");
|
|
679
|
+
assert.equal(result.frozen, 0, "nothing frozen");
|
|
680
|
+
assert.ok(ctx.warnings.length >= 1, "freeze failure logged");
|
|
681
|
+
assert.equal(store.getById(l.id).archived, false, "no side effects on memories");
|
|
682
|
+
assert.equal(store.getById(w.id).content, "8月20日", "winner untouched");
|
|
683
|
+
store.close();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// --- v0.4.4: 大记忆量 autoDream(滑动窗口 + 隐式 keep)回归 -----------------
|
|
687
|
+
|
|
688
|
+
test("autoDream with 650 memories: sliding window truncates snapshot + implicit keep fills uncovered ids", async () => {
|
|
689
|
+
const { store, service } = dreamSetup();
|
|
690
|
+
// seed 650 memories — the size that used to produce 677 "missing from
|
|
691
|
+
// decisions" errors and applied=0 under the strict full-coverage check
|
|
692
|
+
for (let i = 0; i < 650; i++) {
|
|
693
|
+
service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
694
|
+
}
|
|
695
|
+
// mock LLM claims only a small subset (20 archives) and never emits keeps
|
|
696
|
+
// for the rest — implicit keep must auto-fill the uncovered snapshot ids
|
|
697
|
+
const ctx = mockCtx({
|
|
698
|
+
onConsolidation: (listText) => {
|
|
699
|
+
const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
|
|
700
|
+
.map((m) => m[1]);
|
|
701
|
+
return JSON.stringify(ids.slice(0, 20).map((id) => ({ action: "archive", ids: [id], reason: "stale" })));
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
705
|
+
const result = await dream.runDream(ctx, service, {
|
|
706
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
|
|
707
|
+
// mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
|
|
708
|
+
// lowered so this deliberate "tiny explicit claim" scenario still passes
|
|
709
|
+
// (it exercises the window + keep-fill, not the coverage guard)
|
|
710
|
+
dreamMinExplicitCoverage: 0.1
|
|
711
|
+
});
|
|
712
|
+
assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
|
|
713
|
+
assert.ok(result.applied > 0, "archive decisions applied");
|
|
714
|
+
// snapshot capped at the sliding window
|
|
715
|
+
const run = store.listDreamRuns()[0];
|
|
716
|
+
assert.equal(run.input_count, 200, "snapshot truncated to dreamMaxSnapshotSize");
|
|
717
|
+
// decisions cover exactly the snapshot window, with implicit keeps
|
|
718
|
+
assert.equal(result.decisions.length, 200, "decisions count = snapshot count");
|
|
719
|
+
assert.equal(result.decisions.filter((d) => d.action === "archive").length, 20, "claimed subset present");
|
|
720
|
+
assert.equal(result.decisions.filter((d) => d.action === "keep").length, 180, "uncovered ids auto-kept");
|
|
721
|
+
assert.equal(store.getById(result.decisions.find((d) => d.action === "archive").ids[0]).archived, true, "an archive landed");
|
|
722
|
+
store.close();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
|
|
726
|
+
|
|
727
|
+
test("validateDecisions rejects a truncated output whose explicit coverage is below the floor", () => {
|
|
728
|
+
const snap = snapshot(["a", "b", "c", "d"]);
|
|
729
|
+
const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
|
|
730
|
+
const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
731
|
+
assert.equal(ok, false, "low explicit coverage rejects the whole list");
|
|
732
|
+
assert.ok(
|
|
733
|
+
errors.some((e) => e.includes("explicit decision coverage 25% < minimum 50%")),
|
|
734
|
+
`coverage error present, got: ${errors.join("; ")}`
|
|
735
|
+
);
|
|
736
|
+
assert.equal(decisions.length, 1, "no keep-fill pushed on rejection (nothing washed white)");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
|
|
740
|
+
const snap = snapshot(["a", "b", "c", "d"]);
|
|
741
|
+
const decisions = [{ action: "archive", ids: ["a", "b"], reason: "stale" }]; // claims 2/4 = 50%
|
|
742
|
+
const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
743
|
+
assert.equal(ok, true, errors.join("; "));
|
|
744
|
+
assert.equal(decisions.length, 3, "archive + 2 implicit keeps for c/d");
|
|
745
|
+
assert.equal(decisions.filter((d) => d.action === "keep").length, 2);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("runDream with dreamImplicitKeep=false rejects a partial mock output (missing from decisions)", async () => {
|
|
749
|
+
const { store, service } = dreamSetup();
|
|
750
|
+
for (let i = 0; i < 3; i++) {
|
|
751
|
+
service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
752
|
+
}
|
|
753
|
+
// mock only claims the first snapshot id, misses the rest — strict mode must
|
|
754
|
+
// reject the whole run instead of auto-keeping the uncovered ids
|
|
755
|
+
const warnings = [];
|
|
756
|
+
const ctx = {
|
|
757
|
+
warnings,
|
|
758
|
+
logger: { warn: (m) => warnings.push(m) },
|
|
759
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
|
|
760
|
+
llm: {
|
|
761
|
+
async *stream(options) {
|
|
762
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
763
|
+
if (userText.startsWith("id=")) {
|
|
764
|
+
const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
|
|
765
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
|
|
766
|
+
} else {
|
|
767
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览摘要" };
|
|
768
|
+
}
|
|
769
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
774
|
+
const result = await dream.runDream(ctx, service, {
|
|
775
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat",
|
|
776
|
+
dreamImplicitKeep: false
|
|
777
|
+
});
|
|
778
|
+
assert.equal(result.ok, false, "strict mode rejects the partial output");
|
|
779
|
+
assert.match(result.error, /invalid decisions: \d+ errors/);
|
|
780
|
+
assert.ok(warnings.some((m) => m.includes("missing from decisions")), "warned which ids are missing");
|
|
781
|
+
assert.equal(store.listDreamRuns()[0].status, "failed", "run audited as failed");
|
|
782
|
+
store.close();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("runDream sliding window keeps the newest N memories and excludes the oldest", async () => {
|
|
786
|
+
const { store, service } = dreamSetup();
|
|
787
|
+
// seed 5 memories, backdate updated_at so i=0 is oldest (5h ago), i=4 newest (1h ago)
|
|
788
|
+
const ids = [];
|
|
789
|
+
for (let i = 0; i < 5; i++) {
|
|
790
|
+
const { memory } = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
791
|
+
ids.push(memory.id);
|
|
792
|
+
}
|
|
793
|
+
for (let i = 0; i < 5; i++) {
|
|
794
|
+
const old = new Date(Date.now() - (5 - i) * 3600000).toISOString();
|
|
795
|
+
store.db.prepare("UPDATE memories SET updated_at = ?, created_at = ? WHERE id = ?").run(old, old, ids[i]);
|
|
796
|
+
}
|
|
797
|
+
const ctx = mockCtx({
|
|
798
|
+
onConsolidation: (listText) => {
|
|
799
|
+
const inWindow = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
|
|
800
|
+
.map((m) => m[1]);
|
|
801
|
+
return JSON.stringify(inWindow.map((id) => ({ action: "keep", ids: [id] })));
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
805
|
+
const result = await dream.runDream(ctx, service, {
|
|
806
|
+
dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 3
|
|
807
|
+
});
|
|
808
|
+
const run = store.listDreamRuns()[0];
|
|
809
|
+
assert.equal(run.input_count, 3, "window capped at dreamMaxSnapshotSize");
|
|
810
|
+
const windowIds = run.input.map((m) => m.id).sort();
|
|
811
|
+
const expected = [ids[2], ids[3], ids[4]].sort(); // newest 3 by updated_at
|
|
812
|
+
assert.deepEqual(windowIds, expected, "window contains the newest 3 memories");
|
|
813
|
+
assert.ok(!windowIds.includes(ids[0]), "oldest memory excluded from the window");
|
|
814
|
+
assert.equal(result.decisions.length, 3, "decisions cover exactly the window");
|
|
815
|
+
store.close();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
|
|
819
|
+
|
|
820
|
+
test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
|
|
821
|
+
const { store, service } = dreamSetup();
|
|
822
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
823
|
+
let systemText = "";
|
|
824
|
+
const ctx = {
|
|
825
|
+
logger: { warn: () => {} },
|
|
826
|
+
llm: {
|
|
827
|
+
async *stream(options) {
|
|
828
|
+
systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
|
|
829
|
+
yield { type: "text-delta", text: "[]" };
|
|
830
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
835
|
+
await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
836
|
+
// 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
|
|
837
|
+
assert.match(systemText, /"action"/, "prompt names the action field");
|
|
838
|
+
assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
|
|
839
|
+
// conflict winner/loser 是单个 id 字符串而非数组
|
|
840
|
+
assert.match(systemText, /"winner"/, "prompt names the winner field");
|
|
841
|
+
assert.match(systemText, /"loser"/, "prompt names the loser field");
|
|
842
|
+
assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
|
|
843
|
+
assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
|
|
844
|
+
// 同一 id 不可被多个决策重复 claim
|
|
845
|
+
assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
|
|
846
|
+
// 决策 JSON 示例块
|
|
847
|
+
assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
|
|
848
|
+
store.close();
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// --- Bug8: llm_audit_logs trail ----------------------------------------------
|
|
852
|
+
|
|
853
|
+
test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
|
|
854
|
+
const { store, service } = dreamSetup();
|
|
855
|
+
service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
856
|
+
service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
857
|
+
const ctx = mockCtx({
|
|
858
|
+
onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
|
|
859
|
+
});
|
|
860
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
861
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
862
|
+
assert.equal(result.ok, true, "run succeeds");
|
|
863
|
+
const rows = store.listLlmAudits();
|
|
864
|
+
assert.equal(rows.length, 2, "consolidation + summary both audited");
|
|
865
|
+
assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
|
|
866
|
+
assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
|
|
867
|
+
const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
|
|
868
|
+
assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
|
|
869
|
+
const summarize = rows.find((r) => r.operation_type === "dream_summarize");
|
|
870
|
+
assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
|
|
871
|
+
for (const row of rows) {
|
|
872
|
+
assert.equal(row.status, "success");
|
|
873
|
+
// Issue #25: config route (dreamProvider/dreamModel) wins over the agent
|
|
874
|
+
// default — the audit must reflect the actually-used config route.
|
|
875
|
+
assert.equal(row.model_id, "deepseek:deepseek-chat");
|
|
876
|
+
assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
|
|
877
|
+
assert.equal(row.input_tokens, 0);
|
|
878
|
+
assert.equal(row.output_tokens, 0);
|
|
879
|
+
}
|
|
880
|
+
store.close();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
|
|
884
|
+
const { store, service } = dreamSetup();
|
|
885
|
+
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
886
|
+
const ctx = {
|
|
887
|
+
logger: { warn: () => {} },
|
|
888
|
+
llm: {
|
|
889
|
+
stream: async function* () {
|
|
890
|
+
yield { type: "finish", reason: { kind: "error" } };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
895
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
896
|
+
assert.equal(result.ok, false, "failed run reported");
|
|
897
|
+
const rows = store.listLlmAudits();
|
|
898
|
+
assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
|
|
899
|
+
assert.equal(rows[0].operation_type, "dream_consolidate");
|
|
900
|
+
assert.equal(rows[0].status, "error", "LLM failure status=error");
|
|
901
|
+
assert.ok(rows[0].error_message, "error message recorded");
|
|
902
|
+
store.close();
|
|
903
|
+
});
|