@modusensus/dsh-mneme 0.7.15 → 0.7.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +34 -10
- package/README.md +25 -44
- package/lib/client.js +48 -9
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/package.json +1 -1
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/test/api-routes-gaps.test.js +218 -0
- package/test/client.test.js +59 -0
- package/test/dream.test.js +1 -1
- package/test/lib-smoke.test.js +96 -0
- package/test/llm-audit.test.js +34 -4
- package/test/reasoning-effort.test.js +64 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createApi } from "../src/api.js";
|
|
7
|
+
import { createSettings } from "../src/settings.js";
|
|
8
|
+
|
|
9
|
+
// 覆盖 PR#73 删测后遗留的三个存活 API 空白(v0.7.16 回归补测):
|
|
10
|
+
// /api/dsh-mneme/delete 面板删记忆(POST + auth + 存在性 404)
|
|
11
|
+
// /api/dsh-mneme/entities 实体目录(只读、limit 夹取)
|
|
12
|
+
// /api/dsh-mneme/external-api 独立服务配置(GET 发 token / PUT 校验)
|
|
13
|
+
// 这三个路由在 src/api.js 里一直在跑,只是丢了测试。
|
|
14
|
+
|
|
15
|
+
class FakeRes extends EventEmitter {
|
|
16
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
17
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
18
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function req(path, method = "GET", body = null) {
|
|
22
|
+
const r = new EventEmitter();
|
|
23
|
+
r.url = path;
|
|
24
|
+
r.method = method;
|
|
25
|
+
r.headers = {};
|
|
26
|
+
if (body !== null) {
|
|
27
|
+
process.nextTick(() => {
|
|
28
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
29
|
+
r.emit("end");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setup(apiToken = "") {
|
|
36
|
+
const store = createStore(":memory:");
|
|
37
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
38
|
+
const settings = createSettings(store.db);
|
|
39
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
40
|
+
const routes = [];
|
|
41
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
42
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken);
|
|
43
|
+
return { store, service, routes, settings };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handler(routes, path) {
|
|
47
|
+
return routes.find((r) => r.path === path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// /api/dsh-mneme/delete
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
test("delete is auth-gated and rejects non-POST", async () => {
|
|
54
|
+
const { routes } = setup("secret-token");
|
|
55
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
56
|
+
|
|
57
|
+
// 未带 token → 401
|
|
58
|
+
let res = new FakeRes();
|
|
59
|
+
await route.handler(req("/api/dsh-mneme/delete", "POST", { id: "x" }), res);
|
|
60
|
+
assert.equal(res.statusCode, 401);
|
|
61
|
+
assert.equal(JSON.parse(res.body).error, "unauthorized");
|
|
62
|
+
|
|
63
|
+
// 带 token 但 GET → 404
|
|
64
|
+
res = new FakeRes();
|
|
65
|
+
const ok = req("/api/dsh-mneme/delete", "GET");
|
|
66
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
67
|
+
await route.handler(ok, res);
|
|
68
|
+
assert.equal(res.statusCode, 404);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("delete validates id and reports missing/unknown 404", async () => {
|
|
72
|
+
const { routes, service } = setup("secret-token");
|
|
73
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
74
|
+
|
|
75
|
+
// 空 id → 400
|
|
76
|
+
let res = new FakeRes();
|
|
77
|
+
let ok = req("/api/dsh-mneme/delete", "POST", { id: " " });
|
|
78
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
79
|
+
await route.handler(ok, res);
|
|
80
|
+
assert.equal(res.statusCode, 400);
|
|
81
|
+
assert.equal(JSON.parse(res.body).error, "missing-id");
|
|
82
|
+
|
|
83
|
+
// 不存在的 id → 404(store.remove 静默,靠前置 getById 区分)
|
|
84
|
+
res = new FakeRes();
|
|
85
|
+
ok = req("/api/dsh-mneme/delete", "POST", { id: "no-such-memory" });
|
|
86
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
87
|
+
await route.handler(ok, res);
|
|
88
|
+
assert.equal(res.statusCode, 404);
|
|
89
|
+
assert.equal(JSON.parse(res.body).error, "not-found");
|
|
90
|
+
|
|
91
|
+
// 确认没被误删
|
|
92
|
+
assert.equal(service.count(), 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("delete removes an existing memory", async () => {
|
|
96
|
+
const { routes, service } = setup("secret-token");
|
|
97
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
98
|
+
|
|
99
|
+
const { memory } = service.saveWithDedupe({ type: "project", title: "要删的", content: "删掉我" });
|
|
100
|
+
assert.ok(service.getById(memory.id));
|
|
101
|
+
|
|
102
|
+
const res = new FakeRes();
|
|
103
|
+
const ok = req("/api/dsh-mneme/delete", "POST", { id: memory.id });
|
|
104
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
105
|
+
await route.handler(ok, res);
|
|
106
|
+
assert.equal(res.statusCode, 200);
|
|
107
|
+
assert.deepEqual(JSON.parse(res.body), { ok: true });
|
|
108
|
+
assert.equal(service.getById(memory.id), undefined);
|
|
109
|
+
assert.equal(service.count(), 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// /api/dsh-mneme/entities
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
test("entities directory is read-only and returns empty list", async () => {
|
|
116
|
+
const { routes } = setup();
|
|
117
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
118
|
+
const res = new FakeRes();
|
|
119
|
+
await route.handler(req("/api/dsh-mneme/entities"), res);
|
|
120
|
+
assert.equal(res.statusCode, 200);
|
|
121
|
+
assert.deepEqual(JSON.parse(res.body), { entities: [], total: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("entities lists seeded entities without auth", async () => {
|
|
125
|
+
const { routes, service } = setup("secret-token"); // 有 token 但读路由不校验
|
|
126
|
+
service.createEntity({ name: "SQLite", type: "technology" });
|
|
127
|
+
service.createEntity({ name: "记忆", type: "concept" });
|
|
128
|
+
|
|
129
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
130
|
+
const res = new FakeRes();
|
|
131
|
+
await route.handler(req("/api/dsh-mneme/entities"), res);
|
|
132
|
+
assert.equal(res.statusCode, 200);
|
|
133
|
+
const data = JSON.parse(res.body);
|
|
134
|
+
assert.equal(data.total, 2);
|
|
135
|
+
const names = data.entities.map((e) => e.name).sort();
|
|
136
|
+
assert.deepEqual(names, ["SQLite", "记忆"]);
|
|
137
|
+
for (const e of data.entities) {
|
|
138
|
+
assert.ok(e.type, "each entity carries a type");
|
|
139
|
+
assert.ok(Number.isInteger(e.mention_count));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("entities clamps limit to [1, 1000]", async () => {
|
|
144
|
+
const { routes, service } = setup();
|
|
145
|
+
for (let i = 0; i < 5; i++) service.createEntity({ name: `e${i}`, type: "concept" });
|
|
146
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
147
|
+
|
|
148
|
+
// 夹取语义:NaN/0 先被 `|| 500` 兜底成 500(0 不再走 max(1,) 下限),
|
|
149
|
+
// 负数才被夹到 1,超限(>1000)夹到 1000。断言按真实实现收紧。
|
|
150
|
+
for (const [qs, want] of [["limit=abc", 5], ["limit=0", 5], ["limit=-10", 1], ["limit=5000", 5]]) {
|
|
151
|
+
const res = new FakeRes();
|
|
152
|
+
await route.handler(req(`/api/dsh-mneme/entities?${qs}`), res);
|
|
153
|
+
const data = JSON.parse(res.body);
|
|
154
|
+
assert.equal(data.entities.length, want, `${qs} → ${want} (got ${data.entities.length})`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// /api/dsh-mneme/external-api
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
test("external-api GET materializes and persists a token", async () => {
|
|
162
|
+
const { routes, settings } = setup();
|
|
163
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
164
|
+
|
|
165
|
+
const res = new FakeRes();
|
|
166
|
+
await route.handler(req("/api/dsh-mneme/external-api"), res);
|
|
167
|
+
assert.equal(res.statusCode, 200);
|
|
168
|
+
const cfg = JSON.parse(res.body).config;
|
|
169
|
+
assert.ok(typeof cfg.token === "string" && cfg.token.length >= 16, "token is generated");
|
|
170
|
+
|
|
171
|
+
// 持久化后二次 GET 返回同一 token
|
|
172
|
+
const res2 = new FakeRes();
|
|
173
|
+
await route.handler(req("/api/dsh-mneme/external-api"), res2);
|
|
174
|
+
assert.equal(JSON.parse(res2.body).config.token, cfg.token);
|
|
175
|
+
assert.ok(settings.getExternalApi().token, "token persisted to settings kv");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("external-api PUT requires auth and validates port/host", async () => {
|
|
179
|
+
const { routes } = setup("secret-token");
|
|
180
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
181
|
+
|
|
182
|
+
// 未带 token → 401
|
|
183
|
+
let res = new FakeRes();
|
|
184
|
+
await route.handler(req("/api/dsh-mneme/external-api", "PUT", { enabled: true }), res);
|
|
185
|
+
assert.equal(res.statusCode, 401);
|
|
186
|
+
|
|
187
|
+
// 端口越界 → 400
|
|
188
|
+
res = new FakeRes();
|
|
189
|
+
let ok = req("/api/dsh-mneme/external-api", "PUT", { port: 70000 });
|
|
190
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
191
|
+
await route.handler(ok, res);
|
|
192
|
+
assert.equal(res.statusCode, 400);
|
|
193
|
+
assert.equal(JSON.parse(res.body).error, "invalid-port");
|
|
194
|
+
|
|
195
|
+
// host 带协议 → 400
|
|
196
|
+
res = new FakeRes();
|
|
197
|
+
ok = req("/api/dsh-mneme/external-api", "PUT", { host: "https://example.com" });
|
|
198
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
199
|
+
await route.handler(ok, res);
|
|
200
|
+
assert.equal(res.statusCode, 400);
|
|
201
|
+
assert.equal(JSON.parse(res.body).error, "invalid-host");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("external-api PUT applies valid config and echoes it", async () => {
|
|
205
|
+
const { routes } = setup("secret-token");
|
|
206
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
207
|
+
|
|
208
|
+
const res = new FakeRes();
|
|
209
|
+
const ok = req("/api/dsh-mneme/external-api", "PUT", { enabled: true, port: 8790, host: "127.0.0.1" });
|
|
210
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
211
|
+
await route.handler(ok, res);
|
|
212
|
+
assert.equal(res.statusCode, 200);
|
|
213
|
+
const cfg = JSON.parse(res.body).config;
|
|
214
|
+
assert.equal(cfg.enabled, true);
|
|
215
|
+
assert.equal(cfg.port, 8790);
|
|
216
|
+
assert.equal(cfg.host, "127.0.0.1");
|
|
217
|
+
assert.ok(typeof cfg.token === "string");
|
|
218
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -160,6 +160,65 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
160
160
|
);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
164
|
+
// portalled entry keeps tracking the live New-Session class (host and skin
|
|
165
|
+
// rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
|
|
166
|
+
// fills the same width as that button and inherits its native centering,
|
|
167
|
+
// (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
168
|
+
// container needs the z-index because transform creates the context.
|
|
169
|
+
test("entry tracks native class, fills native width, and lifts the toolbar dropdown", () => {
|
|
170
|
+
assert.ok(
|
|
171
|
+
clientSource.includes("new MutationObserver"),
|
|
172
|
+
"the entry must re-sync the copied class via MutationObserver"
|
|
173
|
+
);
|
|
174
|
+
assert.ok(
|
|
175
|
+
clientSource.includes('attributeFilter: ["class"]'),
|
|
176
|
+
"the observer must watch class attribute changes"
|
|
177
|
+
);
|
|
178
|
+
assert.ok(
|
|
179
|
+
/\.mneme-topentry-native\{width:100%;/.test(clientSource),
|
|
180
|
+
"the entry button must fill the same width as the New-Session row"
|
|
181
|
+
);
|
|
182
|
+
assert.equal(
|
|
183
|
+
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
184
|
+
false,
|
|
185
|
+
"the old left-aligned label override must go; native centering applies"
|
|
186
|
+
);
|
|
187
|
+
assert.ok(
|
|
188
|
+
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
189
|
+
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
194
|
+
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
195
|
+
// ships inline like the other stroke icons), not raw ★ text. Only the
|
|
196
|
+
// drawer's edit-mode <option> labels keep the text form — SVG cannot render
|
|
197
|
+
// inside <option>.
|
|
198
|
+
test("importance renders as star glyphs, not raw text stars", () => {
|
|
199
|
+
assert.ok(
|
|
200
|
+
clientSource.includes("STAR_PATH_D"),
|
|
201
|
+
"the Lucide star path data must be inlined"
|
|
202
|
+
);
|
|
203
|
+
assert.ok(
|
|
204
|
+
clientSource.includes("const ImportanceStars"),
|
|
205
|
+
"the star-row component must exist"
|
|
206
|
+
);
|
|
207
|
+
assert.equal(
|
|
208
|
+
(clientSource.match(/"★"\.repeat/g) || []).length,
|
|
209
|
+
1,
|
|
210
|
+
"only the drawer edit <option> labels may keep the ★ text form"
|
|
211
|
+
);
|
|
212
|
+
assert.ok(
|
|
213
|
+
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
214
|
+
"the drawer detail must render the star row"
|
|
215
|
+
);
|
|
216
|
+
assert.ok(
|
|
217
|
+
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
218
|
+
"the card foot must render the star row"
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
163
222
|
// The graph toggle must not read as "share": the primitives share icon is
|
|
164
223
|
// banned and a custom node-graph glyph takes its place.
|
|
165
224
|
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
package/test/dream.test.js
CHANGED
|
@@ -870,7 +870,7 @@ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary",
|
|
|
870
870
|
assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
|
|
871
871
|
for (const row of rows) {
|
|
872
872
|
assert.equal(row.status, "success");
|
|
873
|
-
assert.equal(row.model_id, "
|
|
873
|
+
assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
|
|
874
874
|
assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
|
|
875
875
|
assert.equal(row.input_tokens, 0);
|
|
876
876
|
assert.equal(row.output_tokens, 0);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// lib/ 运行冒烟(v0.7.16 回归补测,重建被 PR#73 删除的 lib-smoke.test.js)。
|
|
8
|
+
// npm 包实际加载的是 lib/(package main → lib/index.js),历史教训 v0.7.8
|
|
9
|
+
// 只改 src 忘了同步 lib 发出去旧产物(issue #65)。这里补两层守护:
|
|
10
|
+
// 1) 静态:src/ 与 lib/ 逐文件字节一致(check-sync.js 的测试版,CI 常驻);
|
|
11
|
+
// 2) 运行时:直接从 lib/ 导入核心模块跑关键链路,证明打包产物可独立加载。
|
|
12
|
+
|
|
13
|
+
const root = fileURLToPath(new URL("..", import.meta.url)); // dsh-mneme/
|
|
14
|
+
const srcDir = join(root, "src");
|
|
15
|
+
const libDir = join(root, "lib");
|
|
16
|
+
|
|
17
|
+
function walk(dir) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
22
|
+
else if (entry.isFile()) out.push(full);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("src/ and lib/ are byte-identical (no publish drift)", () => {
|
|
28
|
+
const srcFiles = walk(srcDir);
|
|
29
|
+
assert.ok(srcFiles.length > 0, "src tree is not empty");
|
|
30
|
+
const drift = [];
|
|
31
|
+
for (const file of srcFiles) {
|
|
32
|
+
const rel = relative(srcDir, file);
|
|
33
|
+
const dest = join(libDir, rel);
|
|
34
|
+
if (!readFileSync(dest, "utf8")) {
|
|
35
|
+
drift.push(`missing lib/${rel}`);
|
|
36
|
+
} else if (!readFileSync(file).equals(readFileSync(dest))) {
|
|
37
|
+
drift.push(`differ lib/${rel}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
assert.deepEqual(drift, [], "src↔lib must stay in sync (npm run sync)");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// lib 运行时冒烟:直接加载 npm 实际分发的产物
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test("lib store+service runs a save/get/count cycle", async () => {
|
|
47
|
+
const { createStore } = await import("../lib/store.js");
|
|
48
|
+
const { createService } = await import("../lib/service.js");
|
|
49
|
+
|
|
50
|
+
const store = createStore(":memory:");
|
|
51
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
52
|
+
const { memory } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
53
|
+
assert.ok(memory.id);
|
|
54
|
+
assert.ok(service.getById(memory.id));
|
|
55
|
+
assert.equal(service.count(), 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("lib summarize parses extraction JSON robustly", async () => {
|
|
59
|
+
const { parseSummaryJson } = await import("../lib/summarize.js");
|
|
60
|
+
|
|
61
|
+
// 前后夹带杂质的原始输出 → 只取数组
|
|
62
|
+
const parsed = parseSummaryJson("```json\n[{\"type\":\"preference\",\"title\":\"x\",\"content\":\"y\",\"importance\":9}]\n```");
|
|
63
|
+
assert.equal(parsed.length, 1);
|
|
64
|
+
assert.equal(parsed[0].importance, 5, "importance clamped to [1,5]");
|
|
65
|
+
|
|
66
|
+
// 非法 JSON / 非数组 → 空数组,不抛
|
|
67
|
+
assert.deepEqual(parseSummaryJson("no array here"), []);
|
|
68
|
+
assert.deepEqual(parseSummaryJson('{"type":"preference"}'), []);
|
|
69
|
+
assert.deepEqual(parseSummaryJson(""), []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("lib summarize constructs with autoSummarize disabled", async () => {
|
|
73
|
+
const { createSummarizer } = await import("../lib/summarize.js");
|
|
74
|
+
const summarizer = createSummarizer({}, {}, { autoSummarize: false });
|
|
75
|
+
assert.equal(typeof summarizer.dispose, "function");
|
|
76
|
+
summarizer.dispose();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("lib inject constructs and renders an empty block", async () => {
|
|
80
|
+
const { createInjector } = await import("../lib/inject.js");
|
|
81
|
+
const registered = [];
|
|
82
|
+
const ctx = {
|
|
83
|
+
systemPrompt: {
|
|
84
|
+
context: (def) => { registered.push(def); return () => {}; }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const service = { injectCandidates: () => [], injectSettings: () => [] };
|
|
88
|
+
const dispose = createInjector(ctx, service, {}, {});
|
|
89
|
+
assert.equal(typeof dispose, "function");
|
|
90
|
+
assert.equal(registered.length, 2, "memory + user-settings contexts registered");
|
|
91
|
+
|
|
92
|
+
const memoryBlock = registered.find((d) => d.name === "memory");
|
|
93
|
+
assert.equal(typeof memoryBlock.text, "function");
|
|
94
|
+
assert.equal(memoryBlock.text({}), ""); // 无候选 → 空块
|
|
95
|
+
dispose();
|
|
96
|
+
});
|
package/test/llm-audit.test.js
CHANGED
|
@@ -50,10 +50,11 @@ test("autoDream writes llm_audit_logs rows for consolidation and summary", async
|
|
|
50
50
|
assert.equal(summarize.trigger_source, "autoDream");
|
|
51
51
|
assert.equal(consolidate.status, "success");
|
|
52
52
|
assert.equal(summarize.status, "success");
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
assert.equal(
|
|
53
|
+
// config-first (Issue #25): dreamSetup sets dreamProvider/dreamModel, so it
|
|
54
|
+
// wins over mockCtx's agentDefaultModel (mock:stress-model) — assert the
|
|
55
|
+
// actually-used config route.
|
|
56
|
+
assert.equal(consolidate.model_id, "deepseek:deepseek-chat");
|
|
57
|
+
assert.equal(summarize.model_id, "deepseek:deepseek-chat");
|
|
57
58
|
assert.ok(Array.isArray(consolidate.related_memory_ids) && consolidate.related_memory_ids.length === 2,
|
|
58
59
|
"consolidation audit records the snapshot ids");
|
|
59
60
|
assert.ok(consolidate.total_tokens >= 0 && summarize.total_tokens >= 0);
|
|
@@ -110,6 +111,35 @@ test("autoDream throwing LLM is audited as status=error", async () => {
|
|
|
110
111
|
store.close();
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
test("autoDream parse failure is audited as status=error, not fake success", async () => {
|
|
115
|
+
const { store, service, config } = dreamSetup();
|
|
116
|
+
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
117
|
+
// Stream completes fine but returns no JSON array — the run fails, and the
|
|
118
|
+
// audit must NOT claim success (the old bug: llm_audit said dream_consolidate
|
|
119
|
+
// success while the run recorded failed).
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const ctx = {
|
|
122
|
+
logger: { warn: (m) => warnings.push(m) },
|
|
123
|
+
llm: {
|
|
124
|
+
async *stream() {
|
|
125
|
+
yield { type: "text-delta", index: 0, text: "抱歉,我无法解析成 JSON。" };
|
|
126
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
131
|
+
const result = await dream.runDream(ctx, service, config);
|
|
132
|
+
assert.equal(result.ok, false);
|
|
133
|
+
assert.equal(result.error, "no json array in llm output");
|
|
134
|
+
const rows = service.listLlmAudits();
|
|
135
|
+
assert.equal(rows.length, 1, "failed consolidation still audited");
|
|
136
|
+
assert.equal(rows[0].operation_type, "dream_consolidate");
|
|
137
|
+
assert.equal(rows[0].status, "error", "stream succeeded but output unusable → audit error");
|
|
138
|
+
assert.match(rows[0].error_message, /no json array in llm output/);
|
|
139
|
+
assert.ok(warnings.some((m) => m.includes("head: 抱歉,我无法解析成")), "raw output head logged for diagnosis");
|
|
140
|
+
store.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
test("autoDream audit is skipped when llmAudit.enabled === false", async () => {
|
|
114
144
|
const { store, service, config } = dreamSetup({ llmAudit: { enabled: false } });
|
|
115
145
|
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
@@ -107,6 +107,70 @@ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async ()
|
|
|
107
107
|
store.close();
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("issue#25: dreamProvider/dreamModel config wins over the agentDefaultModel route", async () => {
|
|
111
|
+
const store = createStore(":memory:");
|
|
112
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
113
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
114
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
115
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
116
|
+
const captured = [];
|
|
117
|
+
const ctx = dreamCtx({
|
|
118
|
+
captured,
|
|
119
|
+
onConsolidation: () => JSON.stringify([
|
|
120
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
|
|
121
|
+
])
|
|
122
|
+
});
|
|
123
|
+
// dreamCtx's agentDefaultModel resolves (mock:mock-model), but the explicit
|
|
124
|
+
// config route must win — otherwise dreamProvider/dreamModel is dead code in
|
|
125
|
+
// a standard DSH install and the dream can never be moved off a thinking model.
|
|
126
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "volcano", dreamModel: "deepseek-v3" });
|
|
127
|
+
assert.equal(result.ok, true);
|
|
128
|
+
assert.ok(captured.length >= 2, "consolidation + summary both hit the LLM");
|
|
129
|
+
for (const options of captured) {
|
|
130
|
+
assert.equal(options.provider, "volcano");
|
|
131
|
+
assert.equal(options.model, "deepseek-v3", "config route wins over agentDefaultModel (mock:mock-model)");
|
|
132
|
+
}
|
|
133
|
+
store.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("issue#9: rejected reasoningEffort retries once without it and still consolidates", async () => {
|
|
137
|
+
const store = createStore(":memory:");
|
|
138
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
139
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
140
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
141
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
142
|
+
const calls = [];
|
|
143
|
+
const ctx = {
|
|
144
|
+
logger: { warn: () => {} },
|
|
145
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
146
|
+
llm: {
|
|
147
|
+
async *stream(options) {
|
|
148
|
+
calls.push(options);
|
|
149
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
150
|
+
if (userText.startsWith("id=")) {
|
|
151
|
+
// First attempt forwards reasoningEffort: the provider rejects it.
|
|
152
|
+
if (options.reasoningEffort) {
|
|
153
|
+
throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
|
|
154
|
+
}
|
|
155
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
156
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
157
|
+
]) };
|
|
158
|
+
} else {
|
|
159
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
|
|
160
|
+
}
|
|
161
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
|
|
166
|
+
assert.equal(result.ok, true, "run survives the effort rejection via the fallback retry");
|
|
167
|
+
assert.ok(result.applied > 0, "consolidation still lands changes");
|
|
168
|
+
assert.equal(calls.length, 3, "consolidation tried (rejected) + retried without effort + summary");
|
|
169
|
+
assert.equal(calls[0].reasoningEffort, "low", "first consolidation attempt forwards the effort");
|
|
170
|
+
assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
|
|
171
|
+
store.close();
|
|
172
|
+
});
|
|
173
|
+
|
|
110
174
|
// ---------------------------------------------------------------- sleep passthrough
|
|
111
175
|
|
|
112
176
|
function sleepSetup() {
|