@modusensus/dsh-mneme 0.8.2 → 0.8.4

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.
Files changed (193) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +718 -633
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +502 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +495 -316
  7. package/lib/api.js +1382 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4475 -4362
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +533 -500
  12. package/lib/dream/clustering.js +118 -118
  13. package/lib/dream/decisions.js +658 -658
  14. package/lib/dream/narratives.js +44 -0
  15. package/lib/dream/sleep.js +700 -700
  16. package/lib/dream.js +1319 -1216
  17. package/lib/embedding.js +177 -177
  18. package/lib/entities/extractor.js +249 -249
  19. package/lib/heat.js +142 -136
  20. package/lib/hot-memory.js +46 -46
  21. package/lib/index.js +515 -513
  22. package/lib/inject.js +299 -253
  23. package/lib/lang.js +202 -179
  24. package/lib/local-embedder.js +320 -308
  25. package/lib/mirror.js +198 -198
  26. package/lib/quality-filter.js +126 -126
  27. package/lib/recall-stats.js +103 -0
  28. package/lib/reranker.js +269 -260
  29. package/lib/runtime/adopt.js +176 -176
  30. package/lib/runtime/closure.js +172 -172
  31. package/lib/runtime/download.js +247 -247
  32. package/lib/runtime/layout.js +235 -235
  33. package/lib/runtime/loader.js +249 -249
  34. package/lib/runtime/model-fetch.js +361 -0
  35. package/lib/runtime/provision.js +237 -237
  36. package/lib/runtime/tarball.js +130 -130
  37. package/lib/runtime/verify.js +191 -191
  38. package/lib/scope.js +118 -118
  39. package/lib/search/adaptive.js +22 -22
  40. package/lib/search/bm25.js +96 -96
  41. package/lib/service.js +2171 -2071
  42. package/lib/settings.js +430 -419
  43. package/lib/store.js +2448 -2367
  44. package/lib/summarize.js +575 -449
  45. package/lib/tools.js +574 -574
  46. package/lib/vector-index.js +110 -110
  47. package/lib/version-check.js +122 -122
  48. package/package.json +109 -108
  49. package/runtime-manifest.json +596 -596
  50. package/scripts/benchmark-embed.js +201 -201
  51. package/scripts/benchmark-recall.js +199 -199
  52. package/scripts/benchmark-rerank.js +166 -166
  53. package/scripts/build-runtime-manifest.mjs +217 -217
  54. package/scripts/check-sync.js +42 -42
  55. package/scripts/e2e-dsh.js +220 -220
  56. package/scripts/mneme-runtime.mjs +277 -277
  57. package/scripts/stress-dsh.js +255 -255
  58. package/scripts/sync-lib.js +47 -47
  59. package/scripts/sync-test-badge.mjs +61 -61
  60. package/src/api-standalone.js +495 -316
  61. package/src/api.js +1382 -1316
  62. package/src/bootstrap.js +211 -0
  63. package/src/commands.js +95 -95
  64. package/src/config.js +533 -500
  65. package/src/dream/clustering.js +118 -118
  66. package/src/dream/decisions.js +658 -658
  67. package/src/dream/narratives.js +44 -0
  68. package/src/dream/sleep.js +700 -700
  69. package/src/dream.js +1319 -1216
  70. package/src/embedding.js +177 -177
  71. package/src/entities/extractor.js +249 -249
  72. package/src/heat.js +142 -136
  73. package/src/hot-memory.js +46 -46
  74. package/src/index.js +515 -513
  75. package/src/inject.js +299 -253
  76. package/src/lang.js +202 -179
  77. package/src/local-embedder.js +320 -308
  78. package/src/mirror.js +198 -198
  79. package/src/quality-filter.js +126 -126
  80. package/src/recall-stats.js +103 -0
  81. package/src/reranker.js +269 -260
  82. package/src/runtime/adopt.js +176 -176
  83. package/src/runtime/closure.js +172 -172
  84. package/src/runtime/download.js +247 -247
  85. package/src/runtime/layout.js +235 -235
  86. package/src/runtime/loader.js +249 -249
  87. package/src/runtime/model-fetch.js +361 -0
  88. package/src/runtime/provision.js +237 -237
  89. package/src/runtime/tarball.js +130 -130
  90. package/src/runtime/verify.js +191 -191
  91. package/src/scope.js +118 -118
  92. package/src/search/adaptive.js +22 -22
  93. package/src/search/bm25.js +96 -96
  94. package/src/service.js +2171 -2071
  95. package/src/settings.js +430 -419
  96. package/src/store.js +2448 -2367
  97. package/src/summarize.js +575 -449
  98. package/src/tools.js +574 -574
  99. package/src/vector-index.js +110 -110
  100. package/src/version-check.js +122 -122
  101. package/test/api-routes-gaps.test.js +218 -218
  102. package/test/api.test.js +1578 -1481
  103. package/test/audit.test.js +448 -448
  104. package/test/benchmark.test.js +94 -94
  105. package/test/bootstrap.test.js +220 -0
  106. package/test/client.test.js +799 -736
  107. package/test/clustering.test.js +100 -100
  108. package/test/commands.test.js +106 -106
  109. package/test/config.test.js +66 -66
  110. package/test/conflict-freeze.test.js +300 -300
  111. package/test/dream-archive-guardrail.test.js +116 -116
  112. package/test/dream-candidate-hybrid.test.js +281 -281
  113. package/test/dream-coverage-degrade.test.js +86 -86
  114. package/test/dream.test.js +1257 -1254
  115. package/test/embedding-ready.test.js +196 -196
  116. package/test/entities.test.js +547 -547
  117. package/test/entity-recall.test.js +179 -0
  118. package/test/epistemic.test.js +298 -298
  119. package/test/fnew-0112.test.js +311 -311
  120. package/test/fnew-03.test.js +422 -422
  121. package/test/graph-api.test.js +175 -175
  122. package/test/heat.test.js +175 -148
  123. package/test/helpers/dream-mock.js +82 -82
  124. package/test/helpers/peer-worker.mjs +25 -25
  125. package/test/helpers/runtime-source.js +60 -60
  126. package/test/helpers/tar-builder.js +80 -80
  127. package/test/hot-memory.test.js +145 -145
  128. package/test/inject-content-cap.test.js +89 -0
  129. package/test/inject-first-round-bm25.test.js +89 -89
  130. package/test/inject-heat.test.js +61 -0
  131. package/test/inject-rotation.test.js +161 -0
  132. package/test/inject.test.js +249 -249
  133. package/test/lang.test.js +87 -87
  134. package/test/lib-smoke.test.js +96 -96
  135. package/test/llm-audit.test.js +309 -309
  136. package/test/llm-message-source.test.js +177 -177
  137. package/test/local-embedder.test.js +249 -229
  138. package/test/mcp-stdio.test.js +385 -0
  139. package/test/mirror-dirty.test.js +424 -424
  140. package/test/mirror-edit-digest.test.js +187 -187
  141. package/test/mirror-generation.test.js +499 -499
  142. package/test/mirror.test.js +249 -249
  143. package/test/narrative-bars.test.js +162 -0
  144. package/test/peer-blockers.test.js +193 -193
  145. package/test/policy-epoch.test.js +259 -259
  146. package/test/quality-filter.test.js +186 -186
  147. package/test/reasoning-effort.test.js +746 -746
  148. package/test/recall-evals.test.js +235 -235
  149. package/test/recall-layer.test.js +326 -326
  150. package/test/recall-stats.test.js +117 -0
  151. package/test/receipt-chain.test.js +451 -451
  152. package/test/reflection.test.js +226 -226
  153. package/test/reindex-backfill.test.js +62 -62
  154. package/test/reranker-single-logit.test.js +73 -73
  155. package/test/reranker.test.js +197 -197
  156. package/test/resident-status.test.js +83 -0
  157. package/test/runtime-adopt.test.js +206 -206
  158. package/test/runtime-cli.test.js +89 -89
  159. package/test/runtime-closure.test.js +193 -193
  160. package/test/runtime-download.test.js +302 -302
  161. package/test/runtime-layout.test.js +163 -163
  162. package/test/runtime-loader.test.js +237 -237
  163. package/test/runtime-manifest.test.js +158 -158
  164. package/test/runtime-model-fetch.test.js +348 -0
  165. package/test/runtime-provision.test.js +101 -101
  166. package/test/runtime-tarball.test.js +81 -81
  167. package/test/runtime-verify.test.js +214 -214
  168. package/test/scope-candidates.test.js +239 -239
  169. package/test/scope-explicit.test.js +452 -452
  170. package/test/scope-retrieval.test.js +197 -197
  171. package/test/scope-storage.test.js +255 -255
  172. package/test/scope-strict.test.js +231 -231
  173. package/test/search-fusion.test.js +90 -90
  174. package/test/semantic.test.js +124 -124
  175. package/test/service-search.test.js +174 -174
  176. package/test/service.test.js +405 -405
  177. package/test/settings.test.js +261 -261
  178. package/test/skip-invalid-restore.test.js +262 -262
  179. package/test/sleep-action-set.test.js +422 -422
  180. package/test/sleep-heat.test.js +126 -125
  181. package/test/sleep-scheduler-rearm.test.js +89 -89
  182. package/test/sleep.test.js +373 -373
  183. package/test/standalone-api.test.js +544 -359
  184. package/test/store-vector-cache.test.js +98 -98
  185. package/test/store.test.js +383 -383
  186. package/test/stress.test.js +209 -209
  187. package/test/summarize.test.js +884 -575
  188. package/test/tools-memory-get-schema.test.js +65 -65
  189. package/test/tools.test.js +341 -341
  190. package/test/updated-at-semantics.test.js +113 -113
  191. package/test/vector-index.test.js +221 -221
  192. package/test/version-check.test.js +152 -152
  193. package/test/webServer-optional.test.js +82 -82
package/test/api.test.js CHANGED
@@ -1,1481 +1,1578 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { EventEmitter } from "node:events";
4
- import { readFileSync } from "node:fs";
5
- import { createStore } from "../src/store.js";
6
- import { createService } from "../src/service.js";
7
- import { createApi } from "../src/api.js";
8
- import { createSettings } from "../src/settings.js";
9
- import { createVectorIndex } from "../src/vector-index.js";
10
- import { Config } from "../src/config.js";
11
- import { parseHumanEdits } from "../src/mirror.js";
12
-
13
- // 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
14
- const FLAGS_CFG = Config({});
15
-
16
- class FakeRes extends EventEmitter {
17
- constructor() { super(); this.statusCode = 200; this.body = ""; }
18
- writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
- end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
- }
21
-
22
- function req(path, method = "GET", body = null) {
23
- const r = new EventEmitter();
24
- r.url = path;
25
- r.method = method;
26
- r.headers = {};
27
- if (body !== null) {
28
- process.nextTick(() => {
29
- r.emit("data", Buffer.from(JSON.stringify(body)));
30
- r.emit("end");
31
- });
32
- }
33
- return r;
34
- }
35
-
36
- function setup(embedder, apiToken = "", config = null, semantic = undefined) {
37
- const store = createStore(":memory:");
38
- const service = createService({ store, mirror: null, config: {} });
39
- const settings = createSettings(store.db);
40
- const commands = {
41
- add: (def) => settings.addCommand(def),
42
- remove: (id) => settings.removeCommand(id),
43
- list: () => settings.listCommands()
44
- };
45
- const routes = [];
46
- const ctx = {
47
- webServer: {
48
- register(route) {
49
- routes.push(route);
50
- return () => {};
51
- }
52
- }
53
- };
54
- const api = createApi(ctx, service, settings, commands, embedder, semantic, apiToken, config);
55
- return { store, service, routes, api, settings, apiToken };
56
- }
57
-
58
- function findHandler(routes, path) {
59
- const route = routes.find((r) => r.path === path || (r.kind === "prefix" && path.startsWith(r.path)));
60
- return route;
61
- }
62
-
63
- test("registers list, search, and get prefix routes", () => {
64
- const { routes } = setup();
65
- const paths = routes.map((r) => r.path);
66
- assert.ok(paths.includes("/api/dsh-mneme/list"));
67
- assert.ok(paths.includes("/api/dsh-mneme/search"));
68
- assert.ok(paths.includes("/api/dsh-mneme"));
69
- });
70
-
71
- test("GET /api/dsh-mneme/list returns memories as JSON", async () => {
72
- const { routes, service } = setup();
73
- service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
74
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
75
- const res = new FakeRes();
76
- await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
77
- assert.equal(res.statusCode, 200);
78
- const data = JSON.parse(res.body);
79
- assert.equal(data.items.length, 1);
80
- assert.equal(data.items[0].title, "语言");
81
- });
82
-
83
- test("GET /api/dsh-mneme/search?q= returns matches", async () => {
84
- const { routes, service } = setup();
85
- service.saveWithDedupe({ type: "project", title: "记忆插件", content: "SQLite 中文搜索" });
86
- const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
87
- const res = new FakeRes();
88
- await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
89
- assert.equal(res.statusCode, 200);
90
- const data = JSON.parse(res.body);
91
- assert.equal(data.items.length, 1);
92
- });
93
-
94
- test("unknown route under prefix returns 404 json", async () => {
95
- const { routes } = setup();
96
- const route = findHandler(routes, "/api/dsh-mneme");
97
- const res = new FakeRes();
98
- await route.handler(req("/api/dsh-mneme/nope"), res);
99
- assert.equal(res.statusCode, 404);
100
- });
101
-
102
- test("list total excludes forgotten entries", async () => {
103
- const { routes, service } = setup();
104
- service.saveWithDedupe({ type: "preference", title: "正常", content: "可见" });
105
- const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" });
106
- service.saveWithDedupe({ type: "project", title: "项目", content: "其他类型" });
107
- service.setForget(forgotten.memory.id, true);
108
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
109
- const res = new FakeRes();
110
- await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
111
- assert.equal(res.statusCode, 200);
112
- const data = JSON.parse(res.body);
113
- assert.equal(data.items.length, 1);
114
- assert.equal(data.total, 1, "total matches visible items, forgotten excluded");
115
- });
116
-
117
- test("list honors limit/offset", async () => {
118
- const { routes, service } = setup();
119
- service.saveWithDedupe({ type: "preference", title: "a", content: "1" });
120
- service.saveWithDedupe({ type: "preference", title: "b", content: "2" });
121
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
122
- const res = new FakeRes();
123
- await route.handler(req("/api/dsh-mneme/list?limit=1&offset=0"), res);
124
- const data = JSON.parse(res.body);
125
- assert.equal(data.items.length, 1);
126
- assert.equal(data.total, 2);
127
- });
128
-
129
- test("responses carry application/json content-type", async () => {
130
- const { routes } = setup();
131
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
132
- const res = new FakeRes();
133
- await route.handler(req("/api/dsh-mneme/list"), res);
134
- assert.match(res.headers["Content-Type"], /application\/json/);
135
- });
136
-
137
- test("handler errors return 500 json instead of leaking to host", async () => {
138
- const routes = [];
139
- const ctx = {
140
- webServer: {
141
- register(route) {
142
- routes.push(route);
143
- return () => {};
144
- }
145
- }
146
- };
147
- const service = {
148
- list() { throw new Error("boom"); },
149
- count() { throw new Error("boom"); },
150
- search() { throw new Error("boom"); },
151
- toApiList() { return []; }
152
- };
153
- createApi(ctx, service, {}, { add: () => { throw new Error("x"); }, remove: () => false, list: () => [] });
154
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
155
- const res = new FakeRes();
156
- await route.handler(req("/api/dsh-mneme/list"), res);
157
- assert.equal(res.statusCode, 500);
158
- assert.deepEqual(JSON.parse(res.body), { error: "internal" });
159
- });
160
-
161
- test("GET /api/dsh-mneme/profile returns stored profile", async () => {
162
- const { routes, settings } = setup();
163
- settings.setProfile("我是前端");
164
- const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
165
- const res = new FakeRes();
166
- await route.handler(req("/api/dsh-mneme/profile"), res);
167
- assert.equal(res.statusCode, 200);
168
- assert.equal(JSON.parse(res.body).profile, "我是前端");
169
- });
170
-
171
- test("PUT /api/dsh-mneme/profile saves profile", async () => {
172
- const { routes, settings } = setup();
173
- const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
174
- const res = new FakeRes();
175
- await route.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "新画像" }), res);
176
- assert.equal(res.statusCode, 200);
177
- assert.equal(JSON.parse(res.body).profile, "新画像");
178
- assert.equal(settings.getProfile(), "新画像");
179
- });
180
-
181
- test("GET /api/dsh-mneme/rules returns stored rules", async () => {
182
- const { routes, settings } = setup();
183
- settings.setRules(["规则1", "规则2"]);
184
- const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
185
- const res = new FakeRes();
186
- await route.handler(req("/api/dsh-mneme/rules"), res);
187
- assert.equal(res.statusCode, 200);
188
- assert.deepEqual(JSON.parse(res.body).rules, ["规则1", "规则2"]);
189
- });
190
-
191
- test("PUT /api/dsh-mneme/rules saves rules", async () => {
192
- const { routes, settings } = setup();
193
- const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
194
- const res = new FakeRes();
195
- await route.handler(req("/api/dsh-mneme/rules", "PUT", { rules: ["a", "b"] }), res);
196
- assert.equal(res.statusCode, 200);
197
- assert.deepEqual(settings.getRules(), ["a", "b"]);
198
- });
199
-
200
- // --- panel mode (light/standard) ---
201
-
202
- test("GET /api/dsh-mneme/mode defaults to standard", async () => {
203
- const { routes } = setup();
204
- const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
205
- const res = new FakeRes();
206
- await route.handler(req("/api/dsh-mneme/mode"), res);
207
- assert.equal(res.statusCode, 200);
208
- assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
209
- });
210
-
211
- test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
212
- const { routes, settings } = setup();
213
- const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
214
-
215
- const put = new FakeRes();
216
- await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
217
- assert.equal(put.statusCode, 200);
218
- assert.deepEqual(JSON.parse(put.body), { mode: "light" });
219
- assert.equal(settings.getPanelMode(), "light");
220
-
221
- const back = new FakeRes();
222
- await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
223
- assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
224
-
225
- const bad = new FakeRes();
226
- await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
227
- assert.equal(bad.statusCode, 400);
228
- assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
229
- });
230
-
231
- test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
232
- const { routes } = setup(undefined, "secret-token");
233
- const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
234
- const res = new FakeRes();
235
- await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
236
- assert.equal(res.statusCode, 401);
237
- const ok = new FakeRes();
238
- const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
239
- authed.headers = { authorization: "Bearer secret-token" };
240
- await route.handler(authed, ok);
241
- assert.equal(ok.statusCode, 200);
242
- });
243
-
244
- test("GET /api/dsh-mneme/commands lists commands", async () => {
245
- const { routes, settings } = setup();
246
- settings.addCommand({ name: "agenda", instruction: "x" });
247
- const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
248
- const res = new FakeRes();
249
- await route.handler(req("/api/dsh-mneme/commands"), res);
250
- assert.equal(res.statusCode, 200);
251
- assert.equal(JSON.parse(res.body).commands.length, 1);
252
- });
253
-
254
- test("POST /api/dsh-mneme/commands adds a command; DELETE removes", async () => {
255
- const { routes } = setup();
256
- const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
257
- const res = new FakeRes();
258
- await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "fmt", description: "d", instruction: "格式化" }), res);
259
- assert.equal(res.statusCode, 200);
260
- const { command } = JSON.parse(res.body);
261
- assert.equal(command.name, "fmt");
262
- const del = new FakeRes();
263
- await route.handler(req(`/api/dsh-mneme/commands?id=${command.id}`, "DELETE"), del);
264
- assert.equal(JSON.parse(del.body).removed, true);
265
- });
266
-
267
- test("POST /api/dsh-mneme/commands rejects invalid name with 400", async () => {
268
- const { routes } = setup();
269
- const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
270
- const res = new FakeRes();
271
- await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "Bad Name", instruction: "x" }), res);
272
- assert.equal(res.statusCode, 400);
273
- });
274
-
275
- // --- vector search routes ---
276
-
277
- function setupWithEmbedder(embedder) {
278
- const base = setup(embedder);
279
- base.settings.setVectorConfig({
280
- enabled: true,
281
- baseUrl: "https://api.example.com/v1",
282
- apiKey: "sk-test",
283
- model: "text-embedding-v3"
284
- });
285
- return base;
286
- }
287
-
288
- test("vector-config defaults and round-trips through PUT/GET", async () => {
289
- const { routes, settings } = setup();
290
- const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
291
-
292
- const get1 = new FakeRes();
293
- await route.handler(req("/api/dsh-mneme/vector-config"), get1);
294
- assert.equal(JSON.parse(get1.body).config.enabled, false);
295
-
296
- const put = new FakeRes();
297
- await route.handler(req("/api/dsh-mneme/vector-config", "PUT", { enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-x", model: "text-embedding-3-small" }), put);
298
- assert.equal(JSON.parse(put.body).config.model, "text-embedding-3-small");
299
- assert.equal(settings.getVectorConfig().enabled, true);
300
- });
301
-
302
- test("search mode=vector merges vector results when embedder returns a vector", async () => {
303
- const embedder = {
304
- embed: async () => [1, 0, 0],
305
- reindexMissing: async () => ({ indexed: 0, skipped: 0 })
306
- };
307
- const { routes, store, service } = setupWithEmbedder(embedder);
308
- const v = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
309
- store.setEmbedding(v.memory.id, [1, 0, 0]);
310
- service.saveWithDedupe({ type: "preference", title: "狗", content: "喜欢狗" });
311
-
312
- const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
313
- const res = new FakeRes();
314
- await route.handler(req("/api/dsh-mneme/search?q=%E7%8C%AB&mode=vector"), res);
315
- const data = JSON.parse(res.body);
316
- assert.equal(data.mode, "vector");
317
- assert.equal(data.items.length, 1, "keyword hit + vector fill merged");
318
- assert.equal(data.items[0].title, "猫");
319
- });
320
-
321
- test("search falls back to keyword when embedder disabled or unavailable", async () => {
322
- // embedder that resolves null (disabled provider)
323
- const embedder = { embed: async () => null, reindexMissing: async () => ({ indexed: 0, skipped: 0 }) };
324
- const { routes, service } = setupWithEmbedder(embedder);
325
- service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
326
- const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
327
- const res = new FakeRes();
328
- await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
329
- const data = JSON.parse(res.body);
330
- assert.equal(data.mode, "keyword");
331
- assert.equal(data.items.length, 1);
332
- });
333
-
334
- test("vector-reindex calls embedder and returns counts", async () => {
335
- const embedder = { reindexMissing: async () => ({ indexed: 2, skipped: 1 }) };
336
- const { routes } = setupWithEmbedder(embedder);
337
- const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
338
- const res = new FakeRes();
339
- await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
340
- const data = JSON.parse(res.body);
341
- assert.equal(data.indexed, 2);
342
- assert.equal(data.skipped, 1);
343
- });
344
-
345
- test("vector-config masks apiKey; empty/masked key on PUT keeps existing", async () => {
346
- const { routes, settings } = setup();
347
- const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
348
-
349
- // PUT stores the real key but responds masked
350
- const put = new FakeRes();
351
- await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
352
- enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-abcdefghijklmnop", model: "m1"
353
- }), put);
354
- const putBody = JSON.parse(put.body);
355
- assert.equal(putBody.config.apiKey, "sk-***mnop", "PUT response masked");
356
- assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "storage keeps the real key");
357
-
358
- // GET returns masked key, other fields intact
359
- const get = new FakeRes();
360
- await route.handler(req("/api/dsh-mneme/vector-config"), get);
361
- const getBody = JSON.parse(get.body);
362
- assert.equal(getBody.config.apiKey, "sk-***mnop");
363
- assert.equal(getBody.config.baseUrl, "https://api.openai.com/v1");
364
- assert.equal(getBody.config.enabled, true);
365
-
366
- // PUT with empty apiKey keeps the previous key
367
- const put2 = new FakeRes();
368
- await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
369
- enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "", model: "m2"
370
- }), put2);
371
- assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "empty key keeps existing");
372
- assert.equal(JSON.parse(put2.body).config.model, "m2");
373
-
374
- // PUT with a masked apiKey (client round-trip) also keeps the previous key
375
- const put3 = new FakeRes();
376
- await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
377
- enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-***mnop", model: "m3"
378
- }), put3);
379
- assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "masked key keeps existing");
380
- });
381
-
382
- test("apiToken protects write/secret endpoints while read endpoints stay open", async () => {
383
- const { routes } = setup(undefined, "secret-token");
384
- const list = routes.find((r) => r.path === "/api/dsh-mneme/list");
385
- const profile = routes.find((r) => r.path === "/api/dsh-mneme/profile");
386
- const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
387
- const reindex = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
388
-
389
- // read-only endpoint stays open without a token
390
- const resList = new FakeRes();
391
- await list.handler(req("/api/dsh-mneme/list"), resList);
392
- assert.equal(resList.statusCode, 200, "list stays open");
393
-
394
- // secret endpoint without token → 401
395
- const resVec = new FakeRes();
396
- await vec.handler(req("/api/dsh-mneme/vector-config"), resVec);
397
- assert.equal(resVec.statusCode, 401, "vector-config GET requires token");
398
-
399
- // write endpoint without token → 401
400
- const resProfile = new FakeRes();
401
- await profile.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "x" }), resProfile);
402
- assert.equal(resProfile.statusCode, 401, "profile PUT requires token");
403
-
404
- // reindex without token → 401
405
- const resReindex = new FakeRes();
406
- await reindex.handler(req("/api/dsh-mneme/vector-reindex"), resReindex);
407
- assert.equal(resReindex.statusCode, 401, "vector-reindex requires token");
408
-
409
- // with a Bearer token everything is allowed
410
- const authReq = (path, method = "GET", body = null) => {
411
- const r = req(path, method, body);
412
- r.headers = { authorization: "Bearer secret-token" };
413
- return r;
414
- };
415
- const resVecOk = new FakeRes();
416
- await vec.handler(authReq("/api/dsh-mneme/vector-config"), resVecOk);
417
- assert.equal(resVecOk.statusCode, 200, "vector-config GET with token");
418
- const resProfileOk = new FakeRes();
419
- await profile.handler(authReq("/api/dsh-mneme/profile", "PUT", { profile: "hi" }), resProfileOk);
420
- assert.equal(resProfileOk.statusCode, 200, "profile PUT with token");
421
-
422
- // wrong token → 401
423
- const bad = req("/api/dsh-mneme/vector-config");
424
- bad.headers = { authorization: "Bearer wrong" };
425
- const resBad = new FakeRes();
426
- await vec.handler(bad, resBad);
427
- assert.equal(resBad.statusCode, 401, "wrong token rejected");
428
- });
429
-
430
- test("no apiToken configured keeps all endpoints open", async () => {
431
- const { routes } = setup();
432
- const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
433
- const res = new FakeRes();
434
- await vec.handler(req("/api/dsh-mneme/vector-config"), res);
435
- assert.equal(res.statusCode, 200, "open when apiToken is unset");
436
- });
437
-
438
- // --- #118: /semantic exposes embedder readiness for the status card ----------
439
-
440
- test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () => {
441
- const fetchSem = async (embedder) => {
442
- const { routes } = setup(embedder);
443
- const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
444
- const res = new FakeRes();
445
- await sem.handler(req("/api/dsh-mneme/semantic"), res);
446
- assert.equal(res.statusCode, 200);
447
- return JSON.parse(res.body);
448
- };
449
- // no embedder → everything null
450
- assert.equal((await fetchSem(undefined)).embedProvider, null);
451
- assert.equal((await fetchSem(undefined)).ready, null, "no embedder → ready null");
452
-
453
- // Ollama-style embedder mid-init (ready:false) → ready false
454
- class OllamaEmbedder { constructor() { this.ready = false; } }
455
- const mid = await fetchSem(new OllamaEmbedder());
456
- assert.equal(mid.embedProvider, "OllamaEmbedder");
457
- assert.equal(mid.ready, false, "not-yet-ready embedder → ready false");
458
-
459
- // ready after init
460
- const ready = new OllamaEmbedder(); ready.ready = true;
461
- assert.equal((await fetchSem(ready)).ready, true);
462
-
463
- // legacy OpenAI embedder has no `ready` prop → treated as ready
464
- assert.equal((await fetchSem({ embed() {} })).ready, true, "no ready prop → assumed ready");
465
-
466
- // legacy embedder carries an explicit display name (constructor.name of a
467
- // literal is "Object", which the status card must not render verbatim)
468
- const named = await fetchSem({ name: "OpenAI", embed() {} });
469
- assert.equal(named.embedProvider, "OpenAI", "explicit name beats constructor.name");
470
- assert.equal(named.ready, true);
471
- });
472
-
473
- // --- #135: /semantic must tell "not configured" apart from "degraded" ---------
474
-
475
- test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async () => {
476
- const fetchSem = async (embedder, semantic) => {
477
- const { routes } = setup(embedder, "", null, semantic);
478
- const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
479
- const res = new FakeRes();
480
- await sem.handler(req("/api/dsh-mneme/semantic"), res);
481
- assert.equal(res.statusCode, 200);
482
- return JSON.parse(res.body);
483
- };
484
- // Stats double: `total` is the memory count, `embedded` the indexed count.
485
- const index = (embeddedCount, totalCount) => ({
486
- vectorIndex: { getStats: () => ({ embeddedCount, totalCount }) }
487
- });
488
- const openai = (ready, configured) => ({
489
- name: "OpenAI",
490
- get ready() { return ready; },
491
- get configured() { return configured; },
492
- embed() {}
493
- });
494
-
495
- // No embedder at all → everything reports absence, not a false green.
496
- const none = await fetchSem(undefined, index(0, 0));
497
- assert.equal(none.configured, null);
498
- assert.equal(none.reason, "no-embedder");
499
- assert.equal(none.degraded, false);
500
- assert.deepEqual(none.coverage, { embedded: 0, total: 0, ratio: null },
501
- "empty store → ratio null (0 would read as 'nothing embedded')");
502
-
503
- // Configured embedder still initializing: unreachable ≠ unconfigured.
504
- assert.equal((await fetchSem(openai(false, true), index(0, 346))).reason, "initializing");
505
-
506
- // Never configured: reported as such even though the index has memories.
507
- const unconfigured = await fetchSem(openai(false, false), index(0, 346));
508
- assert.equal(unconfigured.ready, false);
509
- assert.equal(unconfigured.configured, false);
510
- assert.equal(unconfigured.reason, "not-configured");
511
- assert.equal(unconfigured.degraded, false, "unconfigured was never meant to index");
512
- assert.equal(unconfigured.coverage.ratio, 0);
513
-
514
- // Configured, memories present, zero vectors — the state this machine sat in
515
- // for weeks while the card showed a healthy provider.
516
- const degraded = await fetchSem(openai(true, true), index(0, 346));
517
- assert.equal(degraded.reason, null, "a complete config has no abnormal reason");
518
- assert.equal(degraded.degraded, true);
519
- assert.equal(degraded.coverage.ratio, 0);
520
-
521
- // Healthy index.
522
- const ok = await fetchSem(openai(true, true), index(173, 346));
523
- assert.equal(ok.degraded, false);
524
- assert.equal(ok.coverage.ratio, 0.5);
525
-
526
- // Adapters without a `configured` field (third-party/test doubles) keep the
527
- // old contract: assumed configured, `ready` decides.
528
- const legacy = await fetchSem({ embed() {} }, index(5, 10));
529
- assert.equal(legacy.configured, true);
530
- assert.equal(legacy.reason, null);
531
- assert.equal(legacy.ready, true);
532
-
533
- // No stats provider (no vector index) must not throw — coverage falls back
534
- // to zeros and an empty store is never "degraded".
535
- const noIndex = await fetchSem(openai(true, true), null);
536
- assert.deepEqual(noIndex.coverage, { embedded: 0, total: 0, ratio: null });
537
- assert.equal(noIndex.degraded, false);
538
- });
539
-
540
- // --- Bug8: llm-audit API (pagination + stats) --------------------------------
541
-
542
- test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
543
- const { routes, service } = setup();
544
- for (let i = 0; i < 5; i++) {
545
- service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
546
- }
547
- const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
548
- const res = new FakeRes();
549
- await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
550
- assert.equal(res.statusCode, 200);
551
- const data = JSON.parse(res.body);
552
- assert.equal(data.total, 5);
553
- assert.equal(data.page, 2);
554
- assert.equal(data.pageSize, 2);
555
- assert.equal(data.items.length, 2, "second page of 2");
556
- });
557
-
558
- test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
559
- const { routes, service } = setup();
560
- service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
561
- service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
562
- const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
563
- const res = new FakeRes();
564
- await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
565
- const data = JSON.parse(res.body);
566
- assert.equal(data.total, 1);
567
- assert.equal(data.items[0].operation_type, "summarize_compress");
568
- });
569
-
570
- test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
571
- const { routes, service } = setup();
572
- service.saveLlmAudit({
573
- trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
574
- input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
575
- });
576
- service.saveLlmAudit({
577
- trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
578
- input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
579
- });
580
- const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
581
- const res = new FakeRes();
582
- await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
583
- assert.equal(res.statusCode, 200);
584
- const data = JSON.parse(res.body);
585
- assert.equal(data.total_calls, 2);
586
- assert.equal(data.input_tokens, 120);
587
- assert.equal(data.output_tokens, 60);
588
- assert.equal(data.total_tokens, 180);
589
- assert.equal(data.total_duration_ms, 17);
590
- assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
591
- assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
592
- });
593
-
594
- // --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
595
-
596
- test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
597
- const store = createStore(":memory:");
598
- const service = createService({ store, mirror: null, config: {} });
599
- const settings = createSettings(store.db);
600
- const vectorIndex = createVectorIndex({ store });
601
- const embedder = {
602
- embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
603
- modelHash: "text-embedding-3#abc",
604
- dimension: 3
605
- };
606
- // A pre-index row written before the embedder is attached (so it still has no vector).
607
- service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
608
- const routes = [];
609
- const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
610
- createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
611
- const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
612
- const res = new FakeRes();
613
- await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
614
- assert.equal(res.statusCode, 200);
615
- const data = JSON.parse(res.body);
616
- assert.equal(data.indexed, 1, "actual indexed count, not 0");
617
- assert.equal(data.skipped, 0);
618
- assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
619
- assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
620
- assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
621
- });
622
-
623
- // --- /info(反馈预填的插件版本)-----------------------------------------------
624
-
625
- test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
626
- const { routes } = setup(undefined);
627
- const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
628
- const res = new FakeRes();
629
- await route.handler(req("/api/dsh-mneme/info"), res);
630
- assert.equal(res.statusCode, 200);
631
- const data = JSON.parse(res.body);
632
- // 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
633
- const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
634
- assert.equal(data.version, expected);
635
- });
636
-
637
- // --- feature flags(/features:overrides + effective)------------------------
638
-
639
- test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
640
- const { routes } = setup(undefined, "", FLAGS_CFG);
641
- const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
642
- const res = new FakeRes();
643
- await route.handler(req("/api/dsh-mneme/features"), res);
644
- assert.equal(res.statusCode, 200);
645
- const data = JSON.parse(res.body);
646
- assert.deepEqual(data.overrides, {});
647
- // effective 覆盖全部 51 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
648
- // dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
649
- // dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
650
- // recallFusion/signalTransparency、issue #109 新增的实体抽取路由
651
- // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
652
- // issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,
653
- // 以及 issue #125 新增的候选集三键、issue #17(v0.8.0 A1/A3)新增的
654
- // scopeEnabled/strictScope),未覆盖时取 bundle 配置的解析默认值;
655
- // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
656
- // 不编造给前端 → 53 - 2 = 51
657
- assert.equal(Object.keys(data.effective).length, 51);
658
- assert.equal(data.effective.dreamSkipInvalid, true);
659
- assert.equal(data.effective.allowCrossTypeMerge, false);
660
- assert.equal(data.effective.dreamMinIntervalMinutes, 0);
661
- assert.equal(data.effective.dreamMaxTokens, 131072);
662
- assert.equal(data.effective.sleepProvider, "");
663
- assert.equal(data.effective.sleepModel, "");
664
- assert.equal(data.effective.autoInject, true);
665
- assert.equal(data.effective.codingRetrospect, false);
666
- assert.equal(data.effective.distillMaxChars, 24000);
667
- assert.equal(data.effective.codingBoostFactor, 2);
668
- // 新增布尔键(含嵌套点号键)从 bundle 配置的对象子字段/顶层取默认值
669
- assert.equal(data.effective.bm25SearchEnabled, true);
670
- assert.equal(data.effective.conflictFreezeEnabled, false);
671
- assert.equal(data.effective.trustEpistemicWeighting, false);
672
- assert.equal(data.effective.reflectionFailureTracking, true);
673
- assert.equal(data.effective["memoryQualityFilter.enabled"], true);
674
- assert.equal(data.effective["llmAudit.enabled"], true);
675
- // 新增字符串 / URL / 枚举键
676
- assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
677
- assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
678
- assert.equal(data.effective.ollamaModel, "nomic-embed-text");
679
- assert.equal(data.effective.embedProvider, "openai");
680
- // PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
681
- assert.equal(data.effective.recallFusion, "blend");
682
- assert.equal(data.effective.signalTransparency, false);
683
- // issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
684
- assert.equal(data.effective.entityExtractionProvider, "");
685
- assert.equal(data.effective.entityExtractionModel, "");
686
- assert.equal(data.effective.entityExtractionReasoning, "none");
687
- // issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
688
- assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
689
- assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
690
- assert.equal(data.effective.summarizeDedupeMode, "off");
691
- assert.equal(data.effective.summarizeDedupeMinSim, 0.92);
692
- assert.equal(data.effective.summarizeDedupeWindowHours, 24);
693
- // issue #126:sleep 动作集(默认 conflict = 现状;面板可切 full)
694
- assert.equal(data.effective.sleepActionSet, "conflict");
695
- // issue #125:候选集三键(默认 window / 0 / 0.85)
696
- assert.equal(data.effective.dreamCandidateMode, "window");
697
- assert.equal(data.effective.dreamCandidateMax, 0);
698
- assert.equal(data.effective.dreamCandidateMinSim, 0.85);
699
- });
700
-
701
- test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
702
- const { routes, settings } = setup(undefined, "", FLAGS_CFG);
703
- const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
704
- const res = new FakeRes();
705
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
706
- assert.equal(res.statusCode, 200);
707
- const data = JSON.parse(res.body);
708
- assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
709
- assert.equal(data.effective.autoDream, false, "override wins over config default");
710
- assert.equal(data.effective.distillMaxChars, 48000);
711
- assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
712
- assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
713
- });
714
-
715
- test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
716
- const { routes, settings } = setup(undefined, "", FLAGS_CFG);
717
- const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
718
- const patch = {
719
- "memoryQualityFilter.enabled": false,
720
- "llmAudit.enabled": false,
721
- embedProvider: "local",
722
- ollamaBaseUrl: "http://127.0.0.1:11434",
723
- dreamProvider: " siliconflow ",
724
- dreamModel: ""
725
- };
726
- const res = new FakeRes();
727
- await route.handler(req("/api/dsh-mneme/features", "PUT", patch), res);
728
- assert.equal(res.statusCode, 200);
729
- const data = JSON.parse(res.body);
730
- assert.deepEqual(data.overrides, {
731
- "memoryQualityFilter.enabled": false,
732
- "llmAudit.enabled": false,
733
- embedProvider: "local",
734
- ollamaBaseUrl: "http://127.0.0.1:11434",
735
- dreamProvider: "siliconflow",
736
- dreamModel: ""
737
- }, "nested keys persist flat, free strings are trimmed");
738
- // 嵌套键的覆盖值压过 bundle 配置的对象子字段
739
- assert.equal(data.effective["memoryQualityFilter.enabled"], false);
740
- assert.equal(data.effective["llmAudit.enabled"], false);
741
- assert.equal(data.effective.embedProvider, "local");
742
- assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
743
- assert.equal(data.effective.dreamProvider, "siliconflow");
744
- assert.equal(data.effective.dreamModel, "", "empty string is a legal override");
745
- assert.deepEqual(settings.getFeatureFlags(), data.overrides, "overrides persisted");
746
-
747
- // 非法枚举 / 非 http 协议的 URL → 400 且不落库
748
- const badEnum = new FakeRes();
749
- await route.handler(req("/api/dsh-mneme/features", "PUT", { embedProvider: "bogus" }), badEnum);
750
- assert.equal(badEnum.statusCode, 400);
751
- assert.match(JSON.parse(badEnum.body).error, /embedProvider/);
752
-
753
- const badUrl = new FakeRes();
754
- await route.handler(req("/api/dsh-mneme/features", "PUT", { ollamaBaseUrl: "ftp://localhost:11434" }), badUrl);
755
- assert.equal(badUrl.statusCode, 400);
756
- assert.match(JSON.parse(badUrl.body).error, /ollamaBaseUrl/);
757
-
758
- assert.deepEqual(settings.getFeatureFlags(), data.overrides, "rejected writes persist nothing");
759
- });
760
-
761
- test("PUT /api/dsh-mneme/features rejects unknown keys, bad types and bad ranges with 400", async () => {
762
- const { routes, settings } = setup(undefined, "", FLAGS_CFG);
763
- const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
764
-
765
- const unknown = new FakeRes();
766
- await route.handler(req("/api/dsh-mneme/features", "PUT", { noSuchFlag: true }), unknown);
767
- assert.equal(unknown.statusCode, 400);
768
- assert.match(JSON.parse(unknown.body).error, /noSuchFlag/);
769
-
770
- const badType = new FakeRes();
771
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoInject: "yes" }), badType);
772
- assert.equal(badType.statusCode, 400);
773
- assert.match(JSON.parse(badType.body).error, /autoInject/);
774
-
775
- const badRange = new FakeRes();
776
- await route.handler(req("/api/dsh-mneme/features", "PUT", { codingBoostFactor: 9 }), badRange);
777
- assert.equal(badRange.statusCode, 400);
778
- assert.match(JSON.parse(badRange.body).error, /codingBoostFactor/);
779
-
780
- // 空 patch 不携带任何意图,直接 400
781
- const empty = new FakeRes();
782
- await route.handler(req("/api/dsh-mneme/features", "PUT", {}), empty);
783
- assert.equal(empty.statusCode, 400);
784
-
785
- assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
786
- });
787
-
788
- test("PUT /api/dsh-mneme/features is token-gated; GET stays open", async () => {
789
- const { routes } = setup(undefined, "secret-token", FLAGS_CFG);
790
- const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
791
-
792
- const get = new FakeRes();
793
- await route.handler(req("/api/dsh-mneme/features"), get);
794
- assert.equal(get.statusCode, 200, "read stays open without token");
795
-
796
- const put = new FakeRes();
797
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false }), put);
798
- assert.equal(put.statusCode, 401);
799
-
800
- const ok = req("/api/dsh-mneme/features", "PUT", { autoDream: false });
801
- ok.headers = { authorization: "Bearer secret-token" };
802
- const okRes = new FakeRes();
803
- await route.handler(ok, okRes);
804
- assert.equal(okRes.statusCode, 200);
805
- });
806
-
807
- // --- 交互式记忆库面板:日期过滤 / update / memories/entities / export / import / dream-status ---
808
-
809
- test("GET /api/dsh-mneme/list supports updatedFrom/updatedTo and count matches", async () => {
810
- const { routes, service, store } = setup();
811
- service.saveWithDedupe({ type: "preference", title: "旧", content: "1" });
812
- service.saveWithDedupe({ type: "preference", title: "中", content: "2" });
813
- service.saveWithDedupe({ type: "preference", title: "新", content: "3" });
814
- const setAt = (title, at) => store.db.prepare("UPDATE memories SET updated_at = ? WHERE title = ?").run(at, title);
815
- setAt("旧", "2026-08-01T00:00:00.000Z");
816
- setAt("中", "2026-09-01T12:00:00.000Z");
817
- setAt("新", "2026-09-15T23:00:00.000Z");
818
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
819
-
820
- const from = new FakeRes();
821
- await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-01"), from);
822
- const fromData = JSON.parse(from.body);
823
- assert.equal(fromData.items.length, 2);
824
- assert.equal(fromData.total, 2, "count filter matches list filter");
825
- assert.deepEqual(fromData.items.map((m) => m.title).sort(), ["中", "新"]);
826
-
827
- // date-only updatedTo 闭区间含当天全天(中 09-01T12:00 命中)
828
- const range = new FakeRes();
829
- await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-08-01&updatedTo=2026-09-01"), range);
830
- const rangeData = JSON.parse(range.body);
831
- assert.equal(rangeData.items.length, 2);
832
- assert.equal(rangeData.total, 2);
833
-
834
- const to = new FakeRes();
835
- await route.handler(req("/api/dsh-mneme/list?updatedTo=2026-08-31"), to);
836
- const toData = JSON.parse(to.body);
837
- assert.equal(toData.items.length, 1);
838
- assert.equal(toData.items[0].title, "旧");
839
-
840
- // 非法日期值 → 忽略(不报错、不过滤)
841
- const bad = new FakeRes();
842
- await route.handler(req("/api/dsh-mneme/list?updatedFrom=not-a-date&updatedTo=%E4%B9%B1"), bad);
843
- const badData = JSON.parse(bad.body);
844
- assert.equal(badData.items.length, 3);
845
- assert.equal(badData.total, 3);
846
-
847
- // 完整时间戳同样生效
848
- const ts = new FakeRes();
849
- await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-02T00:00:00.000Z"), ts);
850
- const tsData = JSON.parse(ts.body);
851
- assert.equal(tsData.items.length, 1);
852
- assert.equal(tsData.items[0].title, "新");
853
- });
854
-
855
- test("POST /api/dsh-mneme/update edits title/importance and archives", async () => {
856
- const { routes, service } = setup();
857
- const created = service.saveWithDedupe({ type: "preference", title: "原始", content: "原始内容" });
858
- const id = created.memory.id;
859
- const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
860
-
861
- const edit = new FakeRes();
862
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: "改名", importance: 5 }), edit);
863
- assert.equal(edit.statusCode, 200);
864
- const row = JSON.parse(edit.body).memory;
865
- assert.equal(row.title, "改名");
866
- assert.equal(row.importance, 5);
867
- assert.equal(row.archived, false, "archived booleanized in the response row");
868
- assert.notEqual(row.updated_at, created.memory.updated_at, "updated_at advances");
869
-
870
- const archive = new FakeRes();
871
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: true }), archive);
872
- assert.equal(archive.statusCode, 200);
873
- assert.equal(JSON.parse(archive.body).memory.archived, true);
874
- assert.equal(service.getById(id).archived, true);
875
- });
876
-
877
- test("POST /api/dsh-mneme/update archives replaced content into content_history", async () => {
878
- const { routes, service } = setup();
879
- const created = service.saveWithDedupe({ type: "preference", title: "历史", content: "第一版" });
880
- const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
881
- const res = new FakeRes();
882
- await route.handler(req("/api/dsh-mneme/update", "POST", { id: created.memory.id, content: "第二版" }), res);
883
- assert.equal(res.statusCode, 200);
884
- const row = service.getById(created.memory.id);
885
- assert.equal(row.content, "第二版");
886
- assert.equal(row.content_history[0].content, "第一版");
887
- assert.equal(row.content_history[0].source, "human_override");
888
- });
889
-
890
- test("POST /api/dsh-mneme/update returns 400/404 for bad requests", async () => {
891
- const { routes, service } = setup();
892
- const created = service.saveWithDedupe({ type: "preference", title: "存在", content: "x" });
893
- const id = created.memory.id;
894
- const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
895
-
896
- const missing = new FakeRes();
897
- await route.handler(req("/api/dsh-mneme/update", "POST", { title: "无 id" }), missing);
898
- assert.equal(missing.statusCode, 400);
899
-
900
- const noFields = new FakeRes();
901
- await route.handler(req("/api/dsh-mneme/update", "POST", { id }), noFields);
902
- assert.equal(noFields.statusCode, 400);
903
-
904
- const emptyTitle = new FakeRes();
905
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: " " }), emptyTitle);
906
- assert.equal(emptyTitle.statusCode, 400);
907
-
908
- const emptyContent = new FakeRes();
909
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, content: "" }), emptyContent);
910
- assert.equal(emptyContent.statusCode, 400);
911
-
912
- const badImportance = new FakeRes();
913
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, importance: 9 }), badImportance);
914
- assert.equal(badImportance.statusCode, 400);
915
-
916
- const badTags = new FakeRes();
917
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, tags: "x" }), badTags);
918
- assert.equal(badTags.statusCode, 400);
919
-
920
- const badArchived = new FakeRes();
921
- await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: "yes" }), badArchived);
922
- assert.equal(badArchived.statusCode, 400);
923
-
924
- const gone = new FakeRes();
925
- await route.handler(req("/api/dsh-mneme/update", "POST", { id: "no-such-id", title: "x" }), gone);
926
- assert.equal(gone.statusCode, 404);
927
- assert.deepEqual(JSON.parse(gone.body), { error: "not-found" });
928
-
929
- assert.equal(service.getById(id).title, "存在", "failed writes persist nothing");
930
- });
931
-
932
- test("POST /api/dsh-mneme/update is token-gated", async () => {
933
- const { routes } = setup(undefined, "secret-token");
934
- const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
935
- const res = new FakeRes();
936
- await route.handler(req("/api/dsh-mneme/update", "POST", { id: "x", title: "y" }), res);
937
- assert.equal(res.statusCode, 401);
938
- });
939
-
940
- test("GET /api/dsh-mneme/memories/entities returns entities linked to a memory", async () => {
941
- const { routes, service } = setup();
942
- const a = service.saveWithDedupe({ type: "project", title: "A", content: "提到甲" });
943
- const b = service.saveWithDedupe({ type: "project", title: "B", content: "无关" });
944
- const entity = service.createEntity({ name: "甲", type: "person" });
945
- // 同一记忆两次提及同一实体(两条 attr)→ 去重后只出现一次
946
- service.saveAttr({ entity_id: entity.id, attr_key: "角色", attr_value: "负责人", memory_id: a.memory.id });
947
- service.saveAttr({ entity_id: entity.id, attr_key: "团队", attr_value: "前端", memory_id: a.memory.id });
948
- const route = routes.find((r) => r.path === "/api/dsh-mneme/memories/entities");
949
-
950
- const res = new FakeRes();
951
- await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${a.memory.id}`), res);
952
- assert.equal(res.statusCode, 200);
953
- const data = JSON.parse(res.body);
954
- assert.equal(data.entities.length, 1, "deduped by mention");
955
- assert.equal(data.entities[0].name, "甲");
956
- assert.equal(data.entities[0].type, "person");
957
-
958
- const none = new FakeRes();
959
- await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${b.memory.id}`), none);
960
- assert.deepEqual(JSON.parse(none.body), { entities: [] });
961
-
962
- const missing = new FakeRes();
963
- await route.handler(req("/api/dsh-mneme/memories/entities"), missing);
964
- assert.equal(missing.statusCode, 400);
965
- });
966
-
967
- test("GET /api/dsh-mneme/export json carries full rows and version", async () => {
968
- const { routes, service } = setup();
969
- service.saveWithDedupe({ type: "preference", title: "甲", content: "内容甲" });
970
- const hidden = service.saveWithDedupe({ type: "preference", title: "乙", content: "内容乙" });
971
- service.setForget(hidden.memory.id, true);
972
- const route = routes.find((r) => r.path === "/api/dsh-mneme/export");
973
- const res = new FakeRes();
974
- await route.handler(req("/api/dsh-mneme/export"), res);
975
- assert.equal(res.statusCode, 200);
976
- const data = JSON.parse(res.body);
977
- assert.equal(data.count, 2, "export includes forgotten rows");
978
- assert.equal(data.memories.length, 2);
979
- assert.equal(typeof data.version, "string", "version comes from package.json");
980
- assert.ok(data.exported_at);
981
- assert.equal(data.memories.every((m) => typeof m.archived === "boolean" && typeof m.forgotten === "boolean"), true);
982
- const times = data.memories.map((m) => m.updated_at);
983
- assert.deepEqual(times, [...times].sort().reverse(), "updated_at DESC");
984
- assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.json"/);
985
- });
986
-
987
- test("GET /api/dsh-mneme/export markdown feeds straight back through import", async () => {
988
- const { routes, service } = setup();
989
- const a = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文优先" });
990
- const b = service.saveWithDedupe({ type: "preference", title: "风格", content: "简洁" });
991
- const exportRoute = routes.find((r) => r.path === "/api/dsh-mneme/export");
992
- const importRoute = routes.find((r) => r.path === "/api/dsh-mneme/import");
993
-
994
- const res = new FakeRes();
995
- await exportRoute.handler(req("/api/dsh-mneme/export?format=markdown"), res);
996
- assert.equal(res.statusCode, 200);
997
- assert.match(res.headers["Content-Type"], /text\/markdown/);
998
- assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.md"/);
999
- const md = res.body;
1000
- assert.ok(md.includes("- **ID**: `" + a.memory.id + "`"), "anchor line present (readHumanEdits-compatible)");
1001
-
1002
- // 黄金用例:导出文本原样导入 → 解析出全部条目,且字段无漂移
1003
- const back = new FakeRes();
1004
- await importRoute.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: md }), back);
1005
- assert.equal(back.statusCode, 200);
1006
- const data = JSON.parse(back.body);
1007
- assert.equal(data.type, "preference");
1008
- assert.ok(data.merged >= 2, "both memories parsed back");
1009
- assert.equal(service.getById(a.memory.id).title, "语言");
1010
- assert.equal(service.getById(a.memory.id).content, "中文优先");
1011
- assert.equal(service.getById(b.memory.id).title, "风格");
1012
- assert.equal(service.getById(b.memory.id).content, "简洁");
1013
- });
1014
-
1015
- test("POST /api/dsh-mneme/import validates type/markdown and tolerates zero edits", async () => {
1016
- const { routes } = setup();
1017
- const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
1018
-
1019
- const badType = new FakeRes();
1020
- await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preferences", markdown: "x" }), badType);
1021
- assert.equal(badType.statusCode, 400, "TYPE_FILE keys are singular, plural is rejected");
1022
-
1023
- const badMd = new FakeRes();
1024
- await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "" }), badMd);
1025
- assert.equal(badMd.statusCode, 400);
1026
-
1027
- const zero = new FakeRes();
1028
- await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "# 空镜像" }), zero);
1029
- assert.equal(zero.statusCode, 200);
1030
- assert.deepEqual(JSON.parse(zero.body), { merged: 0, type: "preference" });
1031
- });
1032
-
1033
- test("POST /api/dsh-mneme/import is token-gated", async () => {
1034
- const { routes } = setup(undefined, "secret-token");
1035
- const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
1036
- const res = new FakeRes();
1037
- await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "x" }), res);
1038
- assert.equal(res.statusCode, 401);
1039
- });
1040
-
1041
- test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", async () => {
1042
- const { routes, service } = setup();
1043
- service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", provider: "ollama", model: "qwen", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
1044
- service.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "failed", error: "boom", snapshot_hash: "h2", input_count: 0, receipt: "r2" });
1045
- const pending = service.saveConflictPending({ memory_a: "aaaa", memory_b: "bbbb", reason: "矛盾" });
1046
-
1047
- const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1048
- const res = new FakeRes();
1049
- await route.handler(req("/api/dsh-mneme/dream-status"), res);
1050
- assert.equal(res.statusCode, 200);
1051
- const data = JSON.parse(res.body);
1052
- assert.equal(data.runs.length, 2);
1053
- assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
1054
- assert.deepEqual(data.lastRun, data.runs[0]);
1055
- assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
1056
- assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
1057
- assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
1058
- assert.equal(data.runs[0].error, "boom");
1059
- assert.equal(data.runs[1].provider, "ollama");
1060
- assert.equal(data.pendingConflicts, 1);
1061
- assert.deepEqual(data.pendingMemoryIds.sort(), ["aaaa", "bbbb"]);
1062
-
1063
- // 解决后队列清空
1064
- service.resolveConflictPending(pending.id, { winner: "aaaa" });
1065
- const after = new FakeRes();
1066
- await route.handler(req("/api/dsh-mneme/dream-status"), after);
1067
- const afterData = JSON.parse(after.body);
1068
- assert.equal(afterData.pendingConflicts, 0);
1069
- assert.deepEqual(afterData.pendingMemoryIds, []);
1070
- });
1071
-
1072
- // v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。
1073
- test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => {
1074
- const { routes, service } = setup();
1075
- const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1076
- const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1077
- service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1078
-
1079
- const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1080
- const res = new FakeRes();
1081
- await route.handler(req("/api/dsh-mneme/conflicts"), res);
1082
- assert.equal(res.statusCode, 200);
1083
- const data = JSON.parse(res.body);
1084
- assert.equal(data.items.length, 1);
1085
- assert.equal(data.total, 1);
1086
- assert.equal(data.items[0].reason, "主题偏好前后矛盾");
1087
- // saveConflictPending 会按 id 字典序归一 A/B 侧位——断言集合而非位置。
1088
- assert.deepEqual(
1089
- [data.items[0].memory_a.title, data.items[0].memory_b.title].sort(),
1090
- ["界面亮色切换", "界面暗色偏好"]
1091
- );
1092
- assert.equal(data.items[0].memory_a.archived, false);
1093
- assert.equal(data.items[0].memory_b.archived, false);
1094
- // 幽灵侧容忍缺失
1095
- service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
1096
- const res2 = new FakeRes();
1097
- await route.handler(req("/api/dsh-mneme/conflicts"), res2);
1098
- const data2 = JSON.parse(res2.body);
1099
- assert.equal(data2.items.length, 2);
1100
- assert.equal(data2.items.find((x) => x.reason === "对方已删").memory_b.missing, true);
1101
- });
1102
-
1103
- test("POST /api/dsh-mneme/conflicts/resolve stamps and applies the disposition", async () => {
1104
- const { store, routes, service } = setup();
1105
- const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1106
- const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1107
- const pending = service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1108
-
1109
- // store 会把 pair 按 id 字典序归一——从队列行反查「界面亮色切换」所在侧再选。
1110
- const queueRoute = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1111
- const queueRes = new FakeRes();
1112
- await queueRoute.handler(req("/api/dsh-mneme/conflicts"), queueRes);
1113
- const queued = JSON.parse(queueRes.body).items.find((x) => x.id === pending.id);
1114
- const keepSide = queued.memory_a.id === b.id ? "a" : "b";
1115
- // loser 恒为 a(keeper 恒为「界面亮色切换」= b),与 A/B 侧位字母无关
1116
- const loserId = a.id;
1117
-
1118
- const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts/resolve");
1119
- // 保留「界面亮色切换」所在侧(apply 默认 true)→ 该侧保留,另一侧归档
1120
- const res = new FakeRes();
1121
- await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: keepSide }), res);
1122
- assert.equal(res.statusCode, 200);
1123
- const data = JSON.parse(res.body);
1124
- assert.equal(data.ok, true);
1125
- assert.equal(data.conflict.resolved_winner, b.id);
1126
- assert.equal(data.conflict.disposition.ok, true);
1127
- assert.equal(store.getById(b.id).archived, false, "winner kept");
1128
- assert.equal(store.getById(loserId).archived, true, "loser archived by apply");
1129
-
1130
- // 处理过的 id 再 resolve → 幂等重盖(仍是 200;队列视图只列未解决行,
1131
- // 正常 UI 不会再发这个请求,但接口层保持幂等)
1132
- const res2 = new FakeRes();
1133
- await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: "a" }), res2);
1134
- assert.equal(res2.statusCode, 200);
1135
- assert.equal(JSON.parse(res2.body).ok, true);
1136
-
1137
- // 未知 id → 404;缺 id → 400
1138
- const res3 = new FakeRes();
1139
- await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: "nope", winner: "a" }), res3);
1140
- assert.equal(res3.statusCode, 404);
1141
- const res4 = new FakeRes();
1142
- await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", {}), res4);
1143
- assert.equal(res4.statusCode, 400);
1144
-
1145
- // apply:false 只盖章不处置
1146
- const c = service.saveWithDedupe({ type: "decision", title: "丙", content: "内容丙" }).memory;
1147
- const d = service.saveWithDedupe({ type: "decision", title: "丁", content: "内容丁" }).memory;
1148
- const p2 = service.saveConflictPending({ memory_a: c.id, memory_b: d.id, reason: "r2" });
1149
- const res5 = new FakeRes();
1150
- await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: p2.id, apply: false }), res5);
1151
- assert.equal(res5.statusCode, 200);
1152
- assert.equal(JSON.parse(res5.body).conflict.disposition, null);
1153
- assert.equal(store.getById(c.id).archived, false);
1154
- assert.equal(store.getById(d.id).archived, false);
1155
- });
1156
-
1157
- test("parseHumanEdits is the pure core of readHumanEdits (CRLF tolerant)", () => {
1158
- const digest = "0".repeat(64);
1159
- const text = [
1160
- "# x\r\n",
1161
- "\r\n",
1162
- "## 标题\r\n",
1163
- "\r\n",
1164
- "- **ID**: `m1`\n",
1165
- "- **类型**: preference\n",
1166
- "- **重要性**: 3\n",
1167
- "- **标签**: \n",
1168
- "- **更新时间**: 2026-01-01T00:00:00.000Z\n",
1169
- "\n",
1170
- `<!-- mirror-digest: ${digest} -->\n`,
1171
- "正文一段\n",
1172
- "\n",
1173
- "---\n",
1174
- "\n",
1175
- "## 另一条\n",
1176
- "\r\n",
1177
- "- **ID**: `m2`\n",
1178
- "- **类型**: preference\n",
1179
- "- **重要性**: 4\n",
1180
- "- **标签**: \n",
1181
- "- **更新时间**: 2026-01-02T00:00:00.000Z\n",
1182
- "\n",
1183
- "内容二\n",
1184
- "\n",
1185
- "---\n"
1186
- ].join("");
1187
- const edits = parseHumanEdits(text);
1188
- assert.equal(edits.length, 2);
1189
- assert.equal(edits[0].id, "m1");
1190
- assert.equal(edits[0].title, "标题");
1191
- assert.equal(edits[0].content, "正文一段");
1192
- assert.equal(edits[1].id, "m2");
1193
- assert.equal(edits[1].content, "内容二");
1194
- });
1195
-
1196
- test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default list hides them", async () => {
1197
- const { routes, service } = setup();
1198
- service.saveWithDedupe({ type: "preference", title: "在用", content: "keep" });
1199
- service.saveWithDedupe({ type: "project", title: "已归档", content: "gone" });
1200
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1201
- const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1202
-
1203
- const def0 = new FakeRes();
1204
- await route.handler(req("/api/dsh-mneme/list"), def0);
1205
- const target = JSON.parse(def0.body).items.find((m) => m.title === "已归档");
1206
-
1207
- const arch = new FakeRes();
1208
- await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: true }), arch);
1209
- assert.equal(arch.statusCode, 200);
1210
-
1211
- const def = new FakeRes();
1212
- await route.handler(req("/api/dsh-mneme/list"), def);
1213
- assert.equal(JSON.parse(def.body).total, 1, "default list hides archived rows");
1214
-
1215
- const only = new FakeRes();
1216
- await route.handler(req("/api/dsh-mneme/list?archived=only"), only);
1217
- const onlyData = JSON.parse(only.body);
1218
- assert.equal(onlyData.total, 1, "archived-only count matches rows");
1219
- assert.deepEqual(onlyData.items.map((m) => m.title), ["已归档"]);
1220
- assert.equal(onlyData.items[0].archived, true);
1221
-
1222
- // 恢复(unarchive)后归档视图清空、默认列表重新可见
1223
- const restore = new FakeRes();
1224
- await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: false }), restore);
1225
- assert.equal(restore.statusCode, 200);
1226
- const empty = new FakeRes();
1227
- await route.handler(req("/api/dsh-mneme/list?archived=only"), empty);
1228
- assert.equal(JSON.parse(empty.body).total, 0);
1229
- const back = new FakeRes();
1230
- await route.handler(req("/api/dsh-mneme/list"), back);
1231
- assert.equal(JSON.parse(back.body).total, 2);
1232
- });
1233
-
1234
- test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
1235
- const { routes, service } = setup();
1236
- const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
1237
- const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
1238
- const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
1239
- service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
1240
- const ids = [plain, merged, updated].map((r) => r.memory.id);
1241
-
1242
- // 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
1243
- // 内容,不算沉淀。verdict='live' 才有效。
1244
- service.saveReceipt({
1245
- receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
1246
- input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
1247
- verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
1248
- created_at: new Date().toISOString()
1249
- });
1250
- service.saveReceipt({
1251
- receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
1252
- input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
1253
- policy_epoch: 0, created_at: new Date().toISOString()
1254
- });
1255
- service.saveReceipt({
1256
- receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
1257
- input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
1258
- count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
1259
- });
1260
-
1261
- const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1262
- const res = new FakeRes();
1263
- await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
1264
- const data = JSON.parse(res.body);
1265
- assert.deepEqual(
1266
- data.items.map((m) => m.title).sort(),
1267
- ["总览", "被巩固", "被更新"],
1268
- "deposited view = receipt merge/update records ∪ source=dream writes"
1269
- );
1270
- assert.equal(data.total, 3, "total honors the deposited filter");
1271
-
1272
- // 默认列表不受 deposited 过滤影响
1273
- const def = new FakeRes();
1274
- await route.handler(req("/api/dsh-mneme/list"), def);
1275
- assert.equal(JSON.parse(def.body).total, 4);
1276
-
1277
- // 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
1278
- const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1279
- const arch = new FakeRes();
1280
- await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
1281
- assert.equal(arch.statusCode, 200);
1282
- const both = new FakeRes();
1283
- await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
1284
- const bothData = JSON.parse(both.body);
1285
- assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
1286
- assert.equal(bothData.total, 1);
1287
- });
1288
-
1289
- test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=true", async () => {
1290
- // 默认(heatEnabled=false):heat 字段整体缺省——前端徽章据此自动隐藏
1291
- const off = setup();
1292
- off.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1293
- const r0 = new FakeRes();
1294
- await off.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r0);
1295
- const d0 = JSON.parse(r0.body);
1296
- assert.equal(d0.total, 1);
1297
- assert.equal("heat" in d0.items[0], false, "heat must be absent from the wire DTO when the flag is off");
1298
-
1299
- // heatEnabled=true:逐条投影。λ=0 免疫类型(preference)恒 1.0;其余落在
1300
- // [0,1] 区间(新建记忆 Δt≈0 接近满格,衰减数学由 heat.test.js 看门)。
1301
- const on = setup(null, "", { heatEnabled: true });
1302
- on.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1303
- on.service.saveWithDedupe({ type: "history", title: "会话历史", content: "recent" });
1304
- const r1 = new FakeRes();
1305
- await on.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r1);
1306
- const d1 = JSON.parse(r1.body);
1307
- assert.equal(d1.total, 2);
1308
- const byTitle = Object.fromEntries(d1.items.map((m) => [m.title, m.heat]));
1309
- assert.equal(byTitle["免疫型"], 1, "λ=0 immune types stay at full heat");
1310
- for (const v of Object.values(byTitle)) {
1311
- assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
1312
- }
1313
- });
1314
-
1315
- // ---------------------------------------------------------------- llm-providers / test-model
1316
- // Settings-panel support: enumerate host-registered providers/models so the
1317
- // panel can offer a dropdown over real models, and probe connectivity with a
1318
- // minimal LLM call (same harness path the consolidation uses).
1319
-
1320
- function setupLlm(llm, apiToken = "", extraCtx = {}, config = null) {
1321
- const store = createStore(":memory:");
1322
- const service = createService({ store, mirror: null, config: {} });
1323
- const settings = createSettings(store.db);
1324
- const commands = { add: () => {}, remove: () => {}, list: () => [] };
1325
- const routes = [];
1326
- const ctx = {
1327
- webServer: { register(route) { routes.push(route); return () => {}; } },
1328
- llm,
1329
- ...extraCtx
1330
- };
1331
- const api = createApi(ctx, service, settings, commands, undefined, undefined, apiToken, config);
1332
- return { routes };
1333
- }
1334
-
1335
- const MOCK_LLM = {
1336
- listProviders: () => [
1337
- { id: "deepseek", name: "DeepSeek" },
1338
- { id: "broken", name: "Broken Adapter" }
1339
- ],
1340
- listModels: async (provider) => {
1341
- if (provider === "broken") throw new Error("provider not reachable");
1342
- return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
1343
- },
1344
- async *stream(options) {
1345
- MOCK_LLM.lastOptions = options;
1346
- if (options.model === "bad-model") {
1347
- yield {
1348
- type: "finish",
1349
- reason: { kind: "error", failure: { code: "AUTH_FAILED", message: "invalid api key" } }
1350
- };
1351
- return;
1352
- }
1353
- if (options.reasoningEffort === "reject") {
1354
- throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
1355
- }
1356
- yield { type: "text-delta", index: 0, text: " ok " };
1357
- yield { type: "finish", reason: { kind: "stop" } };
1358
- }
1359
- };
1360
-
1361
- test("GET /api/dsh-mneme/llm-providers lists host providers with models, per-provider best effort", async () => {
1362
- const { routes } = setupLlm(MOCK_LLM);
1363
- const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
1364
- const res = new FakeRes();
1365
- await route.handler(req("/api/dsh-mneme/llm-providers"), res);
1366
- assert.equal(res.statusCode, 200);
1367
- const data = JSON.parse(res.body);
1368
- assert.equal(data.providers.length, 2);
1369
- const deepseek = data.providers.find((p) => p.provider === "deepseek");
1370
- assert.deepEqual(deepseek.models.map((m) => m.id), ["deepseek-chat", "deepseek-reasoner"]);
1371
- const broken = data.providers.find((p) => p.provider === "broken");
1372
- assert.deepEqual(broken.models, [], "a provider whose model list throws degrades to empty, not a hard fail");
1373
- });
1374
-
1375
- test("GET /api/dsh-mneme/llm-providers without ctx.llm returns 501", async () => {
1376
- const { routes } = setup();
1377
- const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
1378
- const res = new FakeRes();
1379
- await route.handler(req("/api/dsh-mneme/llm-providers"), res);
1380
- assert.equal(res.statusCode, 501);
1381
- assert.equal(JSON.parse(res.body).error, "llm-unavailable");
1382
- });
1383
-
1384
- test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", async () => {
1385
- const { routes } = setupLlm(MOCK_LLM);
1386
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1387
- const res = new FakeRes();
1388
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1389
- assert.equal(res.statusCode, 200);
1390
- const data = JSON.parse(res.body);
1391
- assert.equal(data.ok, true);
1392
- assert.equal(data.reply, "ok", "reply trimmed");
1393
- assert.ok(data.durationMs >= 0);
1394
- assert.equal(data.modelId, "deepseek:deepseek-chat", "modelId reports the probed route");
1395
- assert.equal(MOCK_LLM.lastOptions.provider, "deepseek");
1396
- assert.equal(MOCK_LLM.lastOptions.purpose, "dsh-mneme-connectivity-test");
1397
- // 回归:探测必须是一条 user 消息(带 source)。system-only 对话自
1398
- // 0.1.6-alpha.1 起被官方 API 整单拒绝(「messages: at least one message
1399
- // is required」),探测按钮会 502。
1400
- assert.equal(MOCK_LLM.lastOptions.messages[0].role, "user");
1401
- assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin");
1402
- assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
1403
- });
1404
-
1405
- test("POST /api/dsh-mneme/test-model forwards reasoningEffort verbatim", async () => {
1406
- const { routes } = setupLlm(MOCK_LLM);
1407
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1408
- const res = new FakeRes();
1409
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat", reasoningEffort: "low" }), res);
1410
- assert.equal(res.statusCode, 200);
1411
- assert.equal(MOCK_LLM.lastOptions.reasoningEffort, "low", "effort forwarded to the probe call");
1412
- });
1413
-
1414
- test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", async () => {
1415
- const { routes } = setupLlm(MOCK_LLM);
1416
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1417
- const res = new FakeRes();
1418
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model" }), res);
1419
- assert.equal(res.statusCode, 502);
1420
- const data = JSON.parse(res.body);
1421
- assert.equal(data.ok, false);
1422
- assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
1423
- });
1424
-
1425
- test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
1426
- const { routes } = setupLlm(MOCK_LLM);
1427
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1428
- const res = new FakeRes();
1429
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
1430
- assert.equal(res.statusCode, 502);
1431
- const data = JSON.parse(res.body);
1432
- assert.equal(data.ok, false);
1433
- assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
1434
- });
1435
-
1436
- test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
1437
- // Empty provider/model = "test whatever consolidation uses now": the route
1438
- // falls back to agentDefaultModel (config dreamProvider/dreamModel absent),
1439
- // the probe runs against that model, and modelId reports what was tested.
1440
- const { routes } = setupLlm(MOCK_LLM, "", {
1441
- agentDefaultModel: { currentSelection: () => ({ provider: "sel-provider", model: "sel-model" }) }
1442
- });
1443
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1444
- const res = new FakeRes();
1445
- await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
1446
- assert.equal(res.statusCode, 200);
1447
- const data = JSON.parse(res.body);
1448
- assert.equal(data.ok, true);
1449
- assert.equal(data.modelId, "sel-provider:sel-model", "probe ran against the resolved consolidation route");
1450
- assert.equal(MOCK_LLM.lastOptions.provider, "sel-provider");
1451
- assert.equal(MOCK_LLM.lastOptions.model, "sel-model");
1452
- });
1453
-
1454
- test("POST /api/dsh-mneme/test-model validates input and llm availability", async () => {
1455
- const { routes } = setupLlm(MOCK_LLM);
1456
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1457
- let res = new FakeRes();
1458
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek" }), res);
1459
- assert.equal(res.statusCode, 400, "partial input (provider without model) rejected");
1460
- assert.equal(JSON.parse(res.body).error, "missing-provider-or-model");
1461
-
1462
- // empty body with no route (no agentDefaultModel, no config route) -> no-route
1463
- res = new FakeRes();
1464
- await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
1465
- assert.equal(res.statusCode, 400, "empty body with no resolvable route rejected");
1466
- assert.equal(JSON.parse(res.body).error, "no-route");
1467
-
1468
- const { routes: routesNoLlm } = setup();
1469
- const routeNoLlm = routesNoLlm.find((r) => r.path === "/api/dsh-mneme/test-model");
1470
- res = new FakeRes();
1471
- await routeNoLlm.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1472
- assert.equal(res.statusCode, 501, "no ctx.llm -> llm-unavailable");
1473
- });
1474
-
1475
- test("POST /api/dsh-mneme/test-model is auth-gated like other expensive endpoints", async () => {
1476
- const { routes } = setupLlm(MOCK_LLM, "secret-token");
1477
- const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1478
- const res = new FakeRes();
1479
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1480
- assert.equal(res.statusCode, 401, "probe spends the user's API quota, so it must require auth");
1481
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { readFileSync } from "node:fs";
5
+ import { createStore } from "../src/store.js";
6
+ import { createService } from "../src/service.js";
7
+ import { createApi } from "../src/api.js";
8
+ import { createSettings } from "../src/settings.js";
9
+ import { createVectorIndex } from "../src/vector-index.js";
10
+ import { Config } from "../src/config.js";
11
+ import { parseHumanEdits } from "../src/mirror.js";
12
+
13
+ // 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
14
+ const FLAGS_CFG = Config({});
15
+
16
+ class FakeRes extends EventEmitter {
17
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
18
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
+ }
21
+
22
+ function req(path, method = "GET", body = null) {
23
+ const r = new EventEmitter();
24
+ r.url = path;
25
+ r.method = method;
26
+ r.headers = {};
27
+ if (body !== null) {
28
+ process.nextTick(() => {
29
+ r.emit("data", Buffer.from(JSON.stringify(body)));
30
+ r.emit("end");
31
+ });
32
+ }
33
+ return r;
34
+ }
35
+
36
+ function setup(embedder, apiToken = "", config = null, semantic = undefined) {
37
+ const store = createStore(":memory:");
38
+ const service = createService({ store, mirror: null, config: {} });
39
+ const settings = createSettings(store.db);
40
+ const commands = {
41
+ add: (def) => settings.addCommand(def),
42
+ remove: (id) => settings.removeCommand(id),
43
+ list: () => settings.listCommands()
44
+ };
45
+ const routes = [];
46
+ const ctx = {
47
+ webServer: {
48
+ register(route) {
49
+ routes.push(route);
50
+ return () => {};
51
+ }
52
+ }
53
+ };
54
+ const api = createApi(ctx, service, settings, commands, embedder, semantic, apiToken, config);
55
+ return { store, service, routes, api, settings, apiToken };
56
+ }
57
+
58
+ function findHandler(routes, path) {
59
+ const route = routes.find((r) => r.path === path || (r.kind === "prefix" && path.startsWith(r.path)));
60
+ return route;
61
+ }
62
+
63
+ test("registers list, search, and get prefix routes", () => {
64
+ const { routes } = setup();
65
+ const paths = routes.map((r) => r.path);
66
+ assert.ok(paths.includes("/api/dsh-mneme/list"));
67
+ assert.ok(paths.includes("/api/dsh-mneme/search"));
68
+ assert.ok(paths.includes("/api/dsh-mneme"));
69
+ });
70
+
71
+ test("GET /api/dsh-mneme/list returns memories as JSON", async () => {
72
+ const { routes, service } = setup();
73
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
74
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
75
+ const res = new FakeRes();
76
+ await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
77
+ assert.equal(res.statusCode, 200);
78
+ const data = JSON.parse(res.body);
79
+ assert.equal(data.items.length, 1);
80
+ assert.equal(data.items[0].title, "语言");
81
+ });
82
+
83
+ test("GET /api/dsh-mneme/search?q= returns matches", async () => {
84
+ const { routes, service } = setup();
85
+ service.saveWithDedupe({ type: "project", title: "记忆插件", content: "SQLite 中文搜索" });
86
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
87
+ const res = new FakeRes();
88
+ await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
89
+ assert.equal(res.statusCode, 200);
90
+ const data = JSON.parse(res.body);
91
+ assert.equal(data.items.length, 1);
92
+ });
93
+
94
+ test("unknown route under prefix returns 404 json", async () => {
95
+ const { routes } = setup();
96
+ const route = findHandler(routes, "/api/dsh-mneme");
97
+ const res = new FakeRes();
98
+ await route.handler(req("/api/dsh-mneme/nope"), res);
99
+ assert.equal(res.statusCode, 404);
100
+ });
101
+
102
+ test("list total excludes forgotten entries", async () => {
103
+ const { routes, service } = setup();
104
+ service.saveWithDedupe({ type: "preference", title: "正常", content: "可见" });
105
+ const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" });
106
+ service.saveWithDedupe({ type: "project", title: "项目", content: "其他类型" });
107
+ service.setForget(forgotten.memory.id, true);
108
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
109
+ const res = new FakeRes();
110
+ await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
111
+ assert.equal(res.statusCode, 200);
112
+ const data = JSON.parse(res.body);
113
+ assert.equal(data.items.length, 1);
114
+ assert.equal(data.total, 1, "total matches visible items, forgotten excluded");
115
+ });
116
+
117
+ test("list honors limit/offset", async () => {
118
+ const { routes, service } = setup();
119
+ service.saveWithDedupe({ type: "preference", title: "a", content: "1" });
120
+ service.saveWithDedupe({ type: "preference", title: "b", content: "2" });
121
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
122
+ const res = new FakeRes();
123
+ await route.handler(req("/api/dsh-mneme/list?limit=1&offset=0"), res);
124
+ const data = JSON.parse(res.body);
125
+ assert.equal(data.items.length, 1);
126
+ assert.equal(data.total, 2);
127
+ });
128
+
129
+ test("responses carry application/json content-type", async () => {
130
+ const { routes } = setup();
131
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
132
+ const res = new FakeRes();
133
+ await route.handler(req("/api/dsh-mneme/list"), res);
134
+ assert.match(res.headers["Content-Type"], /application\/json/);
135
+ });
136
+
137
+ test("handler errors return 500 json instead of leaking to host", async () => {
138
+ const routes = [];
139
+ const ctx = {
140
+ webServer: {
141
+ register(route) {
142
+ routes.push(route);
143
+ return () => {};
144
+ }
145
+ }
146
+ };
147
+ const service = {
148
+ list() { throw new Error("boom"); },
149
+ count() { throw new Error("boom"); },
150
+ search() { throw new Error("boom"); },
151
+ toApiList() { return []; }
152
+ };
153
+ createApi(ctx, service, {}, { add: () => { throw new Error("x"); }, remove: () => false, list: () => [] });
154
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
155
+ const res = new FakeRes();
156
+ await route.handler(req("/api/dsh-mneme/list"), res);
157
+ assert.equal(res.statusCode, 500);
158
+ assert.deepEqual(JSON.parse(res.body), { error: "internal" });
159
+ });
160
+
161
+ test("GET /api/dsh-mneme/profile returns stored profile", async () => {
162
+ const { routes, settings } = setup();
163
+ settings.setProfile("我是前端");
164
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
165
+ const res = new FakeRes();
166
+ await route.handler(req("/api/dsh-mneme/profile"), res);
167
+ assert.equal(res.statusCode, 200);
168
+ assert.equal(JSON.parse(res.body).profile, "我是前端");
169
+ });
170
+
171
+ test("PUT /api/dsh-mneme/profile saves profile", async () => {
172
+ const { routes, settings } = setup();
173
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
174
+ const res = new FakeRes();
175
+ await route.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "新画像" }), res);
176
+ assert.equal(res.statusCode, 200);
177
+ assert.equal(JSON.parse(res.body).profile, "新画像");
178
+ assert.equal(settings.getProfile(), "新画像");
179
+ });
180
+
181
+ test("GET /api/dsh-mneme/rules returns stored rules", async () => {
182
+ const { routes, settings } = setup();
183
+ settings.setRules(["规则1", "规则2"]);
184
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
185
+ const res = new FakeRes();
186
+ await route.handler(req("/api/dsh-mneme/rules"), res);
187
+ assert.equal(res.statusCode, 200);
188
+ assert.deepEqual(JSON.parse(res.body).rules, ["规则1", "规则2"]);
189
+ });
190
+
191
+ test("PUT /api/dsh-mneme/rules saves rules", async () => {
192
+ const { routes, settings } = setup();
193
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
194
+ const res = new FakeRes();
195
+ await route.handler(req("/api/dsh-mneme/rules", "PUT", { rules: ["a", "b"] }), res);
196
+ assert.equal(res.statusCode, 200);
197
+ assert.deepEqual(settings.getRules(), ["a", "b"]);
198
+ });
199
+
200
+ // --- panel mode (light/standard) ---
201
+
202
+ test("GET /api/dsh-mneme/mode defaults to standard", async () => {
203
+ const { routes } = setup();
204
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
205
+ const res = new FakeRes();
206
+ await route.handler(req("/api/dsh-mneme/mode"), res);
207
+ assert.equal(res.statusCode, 200);
208
+ assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
209
+ });
210
+
211
+ test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
212
+ const { routes, settings } = setup();
213
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
214
+
215
+ const put = new FakeRes();
216
+ await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
217
+ assert.equal(put.statusCode, 200);
218
+ assert.deepEqual(JSON.parse(put.body), { mode: "light" });
219
+ assert.equal(settings.getPanelMode(), "light");
220
+
221
+ const back = new FakeRes();
222
+ await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
223
+ assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
224
+
225
+ const bad = new FakeRes();
226
+ await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
227
+ assert.equal(bad.statusCode, 400);
228
+ assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
229
+ });
230
+
231
+ test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
232
+ const { routes } = setup(undefined, "secret-token");
233
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
234
+ const res = new FakeRes();
235
+ await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
236
+ assert.equal(res.statusCode, 401);
237
+ const ok = new FakeRes();
238
+ const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
239
+ authed.headers = { authorization: "Bearer secret-token" };
240
+ await route.handler(authed, ok);
241
+ assert.equal(ok.statusCode, 200);
242
+ });
243
+
244
+ test("GET /api/dsh-mneme/commands lists commands", async () => {
245
+ const { routes, settings } = setup();
246
+ settings.addCommand({ name: "agenda", instruction: "x" });
247
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
248
+ const res = new FakeRes();
249
+ await route.handler(req("/api/dsh-mneme/commands"), res);
250
+ assert.equal(res.statusCode, 200);
251
+ assert.equal(JSON.parse(res.body).commands.length, 1);
252
+ });
253
+
254
+ test("POST /api/dsh-mneme/commands adds a command; DELETE removes", async () => {
255
+ const { routes } = setup();
256
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
257
+ const res = new FakeRes();
258
+ await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "fmt", description: "d", instruction: "格式化" }), res);
259
+ assert.equal(res.statusCode, 200);
260
+ const { command } = JSON.parse(res.body);
261
+ assert.equal(command.name, "fmt");
262
+ const del = new FakeRes();
263
+ await route.handler(req(`/api/dsh-mneme/commands?id=${command.id}`, "DELETE"), del);
264
+ assert.equal(JSON.parse(del.body).removed, true);
265
+ });
266
+
267
+ test("POST /api/dsh-mneme/commands rejects invalid name with 400", async () => {
268
+ const { routes } = setup();
269
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
270
+ const res = new FakeRes();
271
+ await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "Bad Name", instruction: "x" }), res);
272
+ assert.equal(res.statusCode, 400);
273
+ });
274
+
275
+ // --- vector search routes ---
276
+
277
+ function setupWithEmbedder(embedder) {
278
+ const base = setup(embedder);
279
+ base.settings.setVectorConfig({
280
+ enabled: true,
281
+ baseUrl: "https://api.example.com/v1",
282
+ apiKey: "sk-test",
283
+ model: "text-embedding-v3"
284
+ });
285
+ return base;
286
+ }
287
+
288
+ test("vector-config defaults and round-trips through PUT/GET", async () => {
289
+ const { routes, settings } = setup();
290
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
291
+
292
+ const get1 = new FakeRes();
293
+ await route.handler(req("/api/dsh-mneme/vector-config"), get1);
294
+ assert.equal(JSON.parse(get1.body).config.enabled, false);
295
+
296
+ const put = new FakeRes();
297
+ await route.handler(req("/api/dsh-mneme/vector-config", "PUT", { enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-x", model: "text-embedding-3-small" }), put);
298
+ assert.equal(JSON.parse(put.body).config.model, "text-embedding-3-small");
299
+ assert.equal(settings.getVectorConfig().enabled, true);
300
+ });
301
+
302
+ test("search mode=vector merges vector results when embedder returns a vector", async () => {
303
+ const embedder = {
304
+ embed: async () => [1, 0, 0],
305
+ reindexMissing: async () => ({ indexed: 0, skipped: 0 })
306
+ };
307
+ const { routes, store, service } = setupWithEmbedder(embedder);
308
+ const v = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
309
+ store.setEmbedding(v.memory.id, [1, 0, 0]);
310
+ service.saveWithDedupe({ type: "preference", title: "狗", content: "喜欢狗" });
311
+
312
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
313
+ const res = new FakeRes();
314
+ await route.handler(req("/api/dsh-mneme/search?q=%E7%8C%AB&mode=vector"), res);
315
+ const data = JSON.parse(res.body);
316
+ assert.equal(data.mode, "vector");
317
+ assert.equal(data.items.length, 1, "keyword hit + vector fill merged");
318
+ assert.equal(data.items[0].title, "猫");
319
+ });
320
+
321
+ test("search falls back to keyword when embedder disabled or unavailable", async () => {
322
+ // embedder that resolves null (disabled provider)
323
+ const embedder = { embed: async () => null, reindexMissing: async () => ({ indexed: 0, skipped: 0 }) };
324
+ const { routes, service } = setupWithEmbedder(embedder);
325
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
326
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
327
+ const res = new FakeRes();
328
+ await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
329
+ const data = JSON.parse(res.body);
330
+ assert.equal(data.mode, "keyword");
331
+ assert.equal(data.items.length, 1);
332
+ });
333
+
334
+ test("vector-reindex calls embedder and returns counts", async () => {
335
+ const embedder = { reindexMissing: async () => ({ indexed: 2, skipped: 1 }) };
336
+ const { routes } = setupWithEmbedder(embedder);
337
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
338
+ const res = new FakeRes();
339
+ await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
340
+ const data = JSON.parse(res.body);
341
+ assert.equal(data.indexed, 2);
342
+ assert.equal(data.skipped, 1);
343
+ });
344
+
345
+ test("vector-config masks apiKey; empty/masked key on PUT keeps existing", async () => {
346
+ const { routes, settings } = setup();
347
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
348
+
349
+ // PUT stores the real key but responds masked
350
+ const put = new FakeRes();
351
+ await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
352
+ enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-abcdefghijklmnop", model: "m1"
353
+ }), put);
354
+ const putBody = JSON.parse(put.body);
355
+ assert.equal(putBody.config.apiKey, "sk-***mnop", "PUT response masked");
356
+ assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "storage keeps the real key");
357
+
358
+ // GET returns masked key, other fields intact
359
+ const get = new FakeRes();
360
+ await route.handler(req("/api/dsh-mneme/vector-config"), get);
361
+ const getBody = JSON.parse(get.body);
362
+ assert.equal(getBody.config.apiKey, "sk-***mnop");
363
+ assert.equal(getBody.config.baseUrl, "https://api.openai.com/v1");
364
+ assert.equal(getBody.config.enabled, true);
365
+
366
+ // PUT with empty apiKey keeps the previous key
367
+ const put2 = new FakeRes();
368
+ await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
369
+ enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "", model: "m2"
370
+ }), put2);
371
+ assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "empty key keeps existing");
372
+ assert.equal(JSON.parse(put2.body).config.model, "m2");
373
+
374
+ // PUT with a masked apiKey (client round-trip) also keeps the previous key
375
+ const put3 = new FakeRes();
376
+ await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
377
+ enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-***mnop", model: "m3"
378
+ }), put3);
379
+ assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "masked key keeps existing");
380
+ });
381
+
382
+ test("apiToken protects write/secret endpoints while read endpoints stay open", async () => {
383
+ const { routes } = setup(undefined, "secret-token");
384
+ const list = routes.find((r) => r.path === "/api/dsh-mneme/list");
385
+ const profile = routes.find((r) => r.path === "/api/dsh-mneme/profile");
386
+ const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
387
+ const reindex = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
388
+
389
+ // read-only endpoint stays open without a token
390
+ const resList = new FakeRes();
391
+ await list.handler(req("/api/dsh-mneme/list"), resList);
392
+ assert.equal(resList.statusCode, 200, "list stays open");
393
+
394
+ // secret endpoint without token → 401
395
+ const resVec = new FakeRes();
396
+ await vec.handler(req("/api/dsh-mneme/vector-config"), resVec);
397
+ assert.equal(resVec.statusCode, 401, "vector-config GET requires token");
398
+
399
+ // write endpoint without token → 401
400
+ const resProfile = new FakeRes();
401
+ await profile.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "x" }), resProfile);
402
+ assert.equal(resProfile.statusCode, 401, "profile PUT requires token");
403
+
404
+ // reindex without token → 401
405
+ const resReindex = new FakeRes();
406
+ await reindex.handler(req("/api/dsh-mneme/vector-reindex"), resReindex);
407
+ assert.equal(resReindex.statusCode, 401, "vector-reindex requires token");
408
+
409
+ // with a Bearer token everything is allowed
410
+ const authReq = (path, method = "GET", body = null) => {
411
+ const r = req(path, method, body);
412
+ r.headers = { authorization: "Bearer secret-token" };
413
+ return r;
414
+ };
415
+ const resVecOk = new FakeRes();
416
+ await vec.handler(authReq("/api/dsh-mneme/vector-config"), resVecOk);
417
+ assert.equal(resVecOk.statusCode, 200, "vector-config GET with token");
418
+ const resProfileOk = new FakeRes();
419
+ await profile.handler(authReq("/api/dsh-mneme/profile", "PUT", { profile: "hi" }), resProfileOk);
420
+ assert.equal(resProfileOk.statusCode, 200, "profile PUT with token");
421
+
422
+ // wrong token → 401
423
+ const bad = req("/api/dsh-mneme/vector-config");
424
+ bad.headers = { authorization: "Bearer wrong" };
425
+ const resBad = new FakeRes();
426
+ await vec.handler(bad, resBad);
427
+ assert.equal(resBad.statusCode, 401, "wrong token rejected");
428
+ });
429
+
430
+ test("no apiToken configured keeps all endpoints open", async () => {
431
+ const { routes } = setup();
432
+ const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
433
+ const res = new FakeRes();
434
+ await vec.handler(req("/api/dsh-mneme/vector-config"), res);
435
+ assert.equal(res.statusCode, 200, "open when apiToken is unset");
436
+ });
437
+
438
+ // --- #118: /semantic exposes embedder readiness for the status card ----------
439
+
440
+ test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () => {
441
+ const fetchSem = async (embedder) => {
442
+ const { routes } = setup(embedder);
443
+ const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
444
+ const res = new FakeRes();
445
+ await sem.handler(req("/api/dsh-mneme/semantic"), res);
446
+ assert.equal(res.statusCode, 200);
447
+ return JSON.parse(res.body);
448
+ };
449
+ // no embedder → everything null
450
+ assert.equal((await fetchSem(undefined)).embedProvider, null);
451
+ assert.equal((await fetchSem(undefined)).ready, null, "no embedder → ready null");
452
+
453
+ // Ollama-style embedder mid-init (ready:false) → ready false
454
+ class OllamaEmbedder { constructor() { this.ready = false; } }
455
+ const mid = await fetchSem(new OllamaEmbedder());
456
+ assert.equal(mid.embedProvider, "OllamaEmbedder");
457
+ assert.equal(mid.ready, false, "not-yet-ready embedder → ready false");
458
+
459
+ // ready after init
460
+ const ready = new OllamaEmbedder(); ready.ready = true;
461
+ assert.equal((await fetchSem(ready)).ready, true);
462
+
463
+ // legacy OpenAI embedder has no `ready` prop → treated as ready
464
+ assert.equal((await fetchSem({ embed() {} })).ready, true, "no ready prop → assumed ready");
465
+
466
+ // legacy embedder carries an explicit display name (constructor.name of a
467
+ // literal is "Object", which the status card must not render verbatim)
468
+ const named = await fetchSem({ name: "OpenAI", embed() {} });
469
+ assert.equal(named.embedProvider, "OpenAI", "explicit name beats constructor.name");
470
+ assert.equal(named.ready, true);
471
+ });
472
+
473
+ // --- #135: /semantic must tell "not configured" apart from "degraded" ---------
474
+
475
+ test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async () => {
476
+ const fetchSem = async (embedder, semantic) => {
477
+ const { routes } = setup(embedder, "", null, semantic);
478
+ const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
479
+ const res = new FakeRes();
480
+ await sem.handler(req("/api/dsh-mneme/semantic"), res);
481
+ assert.equal(res.statusCode, 200);
482
+ return JSON.parse(res.body);
483
+ };
484
+ // Stats double: `total` is the memory count, `embedded` the indexed count.
485
+ const index = (embeddedCount, totalCount) => ({
486
+ vectorIndex: { getStats: () => ({ embeddedCount, totalCount }) }
487
+ });
488
+ const openai = (ready, configured) => ({
489
+ name: "OpenAI",
490
+ get ready() { return ready; },
491
+ get configured() { return configured; },
492
+ embed() {}
493
+ });
494
+
495
+ // No embedder at all → everything reports absence, not a false green.
496
+ const none = await fetchSem(undefined, index(0, 0));
497
+ assert.equal(none.configured, null);
498
+ assert.equal(none.reason, "no-embedder");
499
+ assert.equal(none.degraded, false);
500
+ assert.deepEqual(none.coverage, { embedded: 0, total: 0, ratio: null },
501
+ "empty store → ratio null (0 would read as 'nothing embedded')");
502
+
503
+ // Configured embedder still initializing: unreachable ≠ unconfigured.
504
+ assert.equal((await fetchSem(openai(false, true), index(0, 346))).reason, "initializing");
505
+
506
+ // Never configured: reported as such even though the index has memories.
507
+ const unconfigured = await fetchSem(openai(false, false), index(0, 346));
508
+ assert.equal(unconfigured.ready, false);
509
+ assert.equal(unconfigured.configured, false);
510
+ assert.equal(unconfigured.reason, "not-configured");
511
+ assert.equal(unconfigured.degraded, false, "unconfigured was never meant to index");
512
+ assert.equal(unconfigured.coverage.ratio, 0);
513
+
514
+ // Configured, memories present, zero vectors — the state this machine sat in
515
+ // for weeks while the card showed a healthy provider.
516
+ const degraded = await fetchSem(openai(true, true), index(0, 346));
517
+ assert.equal(degraded.reason, null, "a complete config has no abnormal reason");
518
+ assert.equal(degraded.degraded, true);
519
+ assert.equal(degraded.coverage.ratio, 0);
520
+
521
+ // Healthy index.
522
+ const ok = await fetchSem(openai(true, true), index(173, 346));
523
+ assert.equal(ok.degraded, false);
524
+ assert.equal(ok.coverage.ratio, 0.5);
525
+
526
+ // Adapters without a `configured` field (third-party/test doubles) keep the
527
+ // old contract: assumed configured, `ready` decides.
528
+ const legacy = await fetchSem({ embed() {} }, index(5, 10));
529
+ assert.equal(legacy.configured, true);
530
+ assert.equal(legacy.reason, null);
531
+ assert.equal(legacy.ready, true);
532
+
533
+ // No stats provider (no vector index) must not throw — coverage falls back
534
+ // to zeros and an empty store is never "degraded".
535
+ const noIndex = await fetchSem(openai(true, true), null);
536
+ assert.deepEqual(noIndex.coverage, { embedded: 0, total: 0, ratio: null });
537
+ assert.equal(noIndex.degraded, false);
538
+ });
539
+
540
+ // --- Bug8: llm-audit API (pagination + stats) --------------------------------
541
+
542
+ test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
543
+ const { routes, service } = setup();
544
+ for (let i = 0; i < 5; i++) {
545
+ service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
546
+ }
547
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
548
+ const res = new FakeRes();
549
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
550
+ assert.equal(res.statusCode, 200);
551
+ const data = JSON.parse(res.body);
552
+ assert.equal(data.total, 5);
553
+ assert.equal(data.page, 2);
554
+ assert.equal(data.pageSize, 2);
555
+ assert.equal(data.items.length, 2, "second page of 2");
556
+ });
557
+
558
+ test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
559
+ const { routes, service } = setup();
560
+ service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
561
+ service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
562
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
563
+ const res = new FakeRes();
564
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
565
+ const data = JSON.parse(res.body);
566
+ assert.equal(data.total, 1);
567
+ assert.equal(data.items[0].operation_type, "summarize_compress");
568
+ });
569
+
570
+ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
571
+ const { routes, service } = setup();
572
+ service.saveLlmAudit({
573
+ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
574
+ input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
575
+ });
576
+ service.saveLlmAudit({
577
+ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
578
+ input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
579
+ });
580
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
581
+ const res = new FakeRes();
582
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
583
+ assert.equal(res.statusCode, 200);
584
+ const data = JSON.parse(res.body);
585
+ assert.equal(data.total_calls, 2);
586
+ assert.equal(data.input_tokens, 120);
587
+ assert.equal(data.output_tokens, 60);
588
+ assert.equal(data.total_tokens, 180);
589
+ assert.equal(data.total_duration_ms, 17);
590
+ assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
591
+ assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
592
+ });
593
+
594
+ // --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
595
+
596
+ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
597
+ const store = createStore(":memory:");
598
+ const service = createService({ store, mirror: null, config: {} });
599
+ const settings = createSettings(store.db);
600
+ const vectorIndex = createVectorIndex({ store });
601
+ const embedder = {
602
+ embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
603
+ modelHash: "text-embedding-3#abc",
604
+ dimension: 3
605
+ };
606
+ // A pre-index row written before the embedder is attached (so it still has no vector).
607
+ service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
608
+ const routes = [];
609
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
610
+ createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
611
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
612
+ const res = new FakeRes();
613
+ await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
614
+ assert.equal(res.statusCode, 200);
615
+ const data = JSON.parse(res.body);
616
+ assert.equal(data.indexed, 1, "actual indexed count, not 0");
617
+ assert.equal(data.skipped, 0);
618
+ assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
619
+ assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
620
+ assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
621
+ });
622
+
623
+ // --- /info(反馈预填的插件版本)-----------------------------------------------
624
+
625
+ test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
626
+ const { routes } = setup(undefined);
627
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
628
+ const res = new FakeRes();
629
+ await route.handler(req("/api/dsh-mneme/info"), res);
630
+ assert.equal(res.statusCode, 200);
631
+ const data = JSON.parse(res.body);
632
+ // 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
633
+ const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
634
+ assert.equal(data.version, expected);
635
+ });
636
+
637
+ // --- feature flags(/features:overrides + effective)------------------------
638
+
639
+ test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
640
+ const { routes } = setup(undefined, "", FLAGS_CFG);
641
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
642
+ const res = new FakeRes();
643
+ await route.handler(req("/api/dsh-mneme/features"), res);
644
+ assert.equal(res.statusCode, 200);
645
+ const data = JSON.parse(res.body);
646
+ assert.deepEqual(data.overrides, {});
647
+ // effective 覆盖全部 52 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
648
+ // dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
649
+ // dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
650
+ // recallFusion/signalTransparency、issue #109 新增的实体抽取路由
651
+ // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
652
+ // issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,
653
+ // 以及 issue #125 新增的候选集三键、issue #17(v0.8.0 A1/A3)新增的
654
+ // scopeEnabled/strictScope、issue #194 新增的 resilientModelDownload),
655
+ // 未覆盖时取 bundle 配置的解析默认值;
656
+ // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
657
+ // 不编造给前端 → 58 - 2 = 56(issue #164① 新增 injectContentMaxChars,
658
+ // issue #164 叙述条批次新增 dreamNarrativeEnabled/dreamNarrativeMinCluster)
659
+ assert.equal(Object.keys(data.effective).length, 56);
660
+ assert.equal(data.effective.dreamSkipInvalid, true);
661
+ assert.equal(data.effective.allowCrossTypeMerge, false);
662
+ assert.equal(data.effective.dreamMinIntervalMinutes, 0);
663
+ assert.equal(data.effective.dreamMaxTokens, 131072);
664
+ assert.equal(data.effective.sleepProvider, "");
665
+ assert.equal(data.effective.sleepModel, "");
666
+ assert.equal(data.effective.autoInject, true);
667
+ assert.equal(data.effective.codingRetrospect, false);
668
+ assert.equal(data.effective.distillMaxChars, 24000);
669
+ assert.equal(data.effective.codingBoostFactor, 2);
670
+ // 新增布尔键(含嵌套点号键)从 bundle 配置的对象子字段/顶层取默认值
671
+ assert.equal(data.effective.bm25SearchEnabled, true);
672
+ assert.equal(data.effective.conflictFreezeEnabled, false);
673
+ assert.equal(data.effective.trustEpistemicWeighting, false);
674
+ assert.equal(data.effective.reflectionFailureTracking, true);
675
+ assert.equal(data.effective["memoryQualityFilter.enabled"], true);
676
+ assert.equal(data.effective["llmAudit.enabled"], true);
677
+ // 新增字符串 / URL / 枚举键
678
+ assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
679
+ assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
680
+ assert.equal(data.effective.ollamaModel, "nomic-embed-text");
681
+ assert.equal(data.effective.embedProvider, "openai");
682
+ // PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
683
+ assert.equal(data.effective.recallFusion, "blend");
684
+ assert.equal(data.effective.signalTransparency, false);
685
+ // issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
686
+ assert.equal(data.effective.entityExtractionProvider, "");
687
+ assert.equal(data.effective.entityExtractionModel, "");
688
+ assert.equal(data.effective.entityExtractionReasoning, "none");
689
+ // issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
690
+ assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
691
+ assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
692
+ assert.equal(data.effective.summarizeDedupeMode, "off");
693
+ assert.equal(data.effective.summarizeDedupeMinSim, 0.92);
694
+ assert.equal(data.effective.summarizeDedupeWindowHours, 24);
695
+ // issue #126:sleep 动作集(默认 conflict = 现状;面板可切 full)
696
+ assert.equal(data.effective.sleepActionSet, "conflict");
697
+ // issue #125:候选集三键(默认 window / 0 / 0.85)
698
+ assert.equal(data.effective.dreamCandidateMode, "window");
699
+ assert.equal(data.effective.dreamCandidateMax, 0);
700
+ assert.equal(data.effective.dreamCandidateMinSim, 0.85);
701
+ });
702
+
703
+ test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
704
+ const { routes, settings } = setup(undefined, "", FLAGS_CFG);
705
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
706
+ const res = new FakeRes();
707
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
708
+ assert.equal(res.statusCode, 200);
709
+ const data = JSON.parse(res.body);
710
+ assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
711
+ assert.equal(data.effective.autoDream, false, "override wins over config default");
712
+ assert.equal(data.effective.distillMaxChars, 48000);
713
+ assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
714
+ assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
715
+ });
716
+
717
+ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
718
+ const { routes, settings } = setup(undefined, "", FLAGS_CFG);
719
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
720
+ const patch = {
721
+ "memoryQualityFilter.enabled": false,
722
+ "llmAudit.enabled": false,
723
+ embedProvider: "local",
724
+ ollamaBaseUrl: "http://127.0.0.1:11434",
725
+ dreamProvider: " siliconflow ",
726
+ dreamModel: ""
727
+ };
728
+ const res = new FakeRes();
729
+ await route.handler(req("/api/dsh-mneme/features", "PUT", patch), res);
730
+ assert.equal(res.statusCode, 200);
731
+ const data = JSON.parse(res.body);
732
+ assert.deepEqual(data.overrides, {
733
+ "memoryQualityFilter.enabled": false,
734
+ "llmAudit.enabled": false,
735
+ embedProvider: "local",
736
+ ollamaBaseUrl: "http://127.0.0.1:11434",
737
+ dreamProvider: "siliconflow",
738
+ dreamModel: ""
739
+ }, "nested keys persist flat, free strings are trimmed");
740
+ // 嵌套键的覆盖值压过 bundle 配置的对象子字段
741
+ assert.equal(data.effective["memoryQualityFilter.enabled"], false);
742
+ assert.equal(data.effective["llmAudit.enabled"], false);
743
+ assert.equal(data.effective.embedProvider, "local");
744
+ assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
745
+ assert.equal(data.effective.dreamProvider, "siliconflow");
746
+ assert.equal(data.effective.dreamModel, "", "empty string is a legal override");
747
+ assert.deepEqual(settings.getFeatureFlags(), data.overrides, "overrides persisted");
748
+
749
+ // 非法枚举 / 非 http 协议的 URL → 400 且不落库
750
+ const badEnum = new FakeRes();
751
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { embedProvider: "bogus" }), badEnum);
752
+ assert.equal(badEnum.statusCode, 400);
753
+ assert.match(JSON.parse(badEnum.body).error, /embedProvider/);
754
+
755
+ const badUrl = new FakeRes();
756
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { ollamaBaseUrl: "ftp://localhost:11434" }), badUrl);
757
+ assert.equal(badUrl.statusCode, 400);
758
+ assert.match(JSON.parse(badUrl.body).error, /ollamaBaseUrl/);
759
+
760
+ assert.deepEqual(settings.getFeatureFlags(), data.overrides, "rejected writes persist nothing");
761
+ });
762
+
763
+ test("PUT /api/dsh-mneme/features rejects unknown keys, bad types and bad ranges with 400", async () => {
764
+ const { routes, settings } = setup(undefined, "", FLAGS_CFG);
765
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
766
+
767
+ const unknown = new FakeRes();
768
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { noSuchFlag: true }), unknown);
769
+ assert.equal(unknown.statusCode, 400);
770
+ assert.match(JSON.parse(unknown.body).error, /noSuchFlag/);
771
+
772
+ const badType = new FakeRes();
773
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { autoInject: "yes" }), badType);
774
+ assert.equal(badType.statusCode, 400);
775
+ assert.match(JSON.parse(badType.body).error, /autoInject/);
776
+
777
+ const badRange = new FakeRes();
778
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { codingBoostFactor: 9 }), badRange);
779
+ assert.equal(badRange.statusCode, 400);
780
+ assert.match(JSON.parse(badRange.body).error, /codingBoostFactor/);
781
+
782
+ // 空 patch 不携带任何意图,直接 400
783
+ const empty = new FakeRes();
784
+ await route.handler(req("/api/dsh-mneme/features", "PUT", {}), empty);
785
+ assert.equal(empty.statusCode, 400);
786
+
787
+ assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
788
+ });
789
+
790
+ test("PUT /api/dsh-mneme/features is token-gated; GET stays open", async () => {
791
+ const { routes } = setup(undefined, "secret-token", FLAGS_CFG);
792
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
793
+
794
+ const get = new FakeRes();
795
+ await route.handler(req("/api/dsh-mneme/features"), get);
796
+ assert.equal(get.statusCode, 200, "read stays open without token");
797
+
798
+ const put = new FakeRes();
799
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false }), put);
800
+ assert.equal(put.statusCode, 401);
801
+
802
+ const ok = req("/api/dsh-mneme/features", "PUT", { autoDream: false });
803
+ ok.headers = { authorization: "Bearer secret-token" };
804
+ const okRes = new FakeRes();
805
+ await route.handler(ok, okRes);
806
+ assert.equal(okRes.statusCode, 200);
807
+ });
808
+
809
+ // --- 交互式记忆库面板:日期过滤 / update / memories/entities / export / import / dream-status ---
810
+
811
+ test("GET /api/dsh-mneme/list supports updatedFrom/updatedTo and count matches", async () => {
812
+ const { routes, service, store } = setup();
813
+ service.saveWithDedupe({ type: "preference", title: "旧", content: "1" });
814
+ service.saveWithDedupe({ type: "preference", title: "中", content: "2" });
815
+ service.saveWithDedupe({ type: "preference", title: "新", content: "3" });
816
+ const setAt = (title, at) => store.db.prepare("UPDATE memories SET updated_at = ? WHERE title = ?").run(at, title);
817
+ setAt("旧", "2026-08-01T00:00:00.000Z");
818
+ setAt("中", "2026-09-01T12:00:00.000Z");
819
+ setAt("新", "2026-09-15T23:00:00.000Z");
820
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
821
+
822
+ const from = new FakeRes();
823
+ await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-01"), from);
824
+ const fromData = JSON.parse(from.body);
825
+ assert.equal(fromData.items.length, 2);
826
+ assert.equal(fromData.total, 2, "count filter matches list filter");
827
+ assert.deepEqual(fromData.items.map((m) => m.title).sort(), ["中", "新"]);
828
+
829
+ // date-only updatedTo 闭区间含当天全天(中 09-01T12:00 命中)
830
+ const range = new FakeRes();
831
+ await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-08-01&updatedTo=2026-09-01"), range);
832
+ const rangeData = JSON.parse(range.body);
833
+ assert.equal(rangeData.items.length, 2);
834
+ assert.equal(rangeData.total, 2);
835
+
836
+ const to = new FakeRes();
837
+ await route.handler(req("/api/dsh-mneme/list?updatedTo=2026-08-31"), to);
838
+ const toData = JSON.parse(to.body);
839
+ assert.equal(toData.items.length, 1);
840
+ assert.equal(toData.items[0].title, "旧");
841
+
842
+ // 非法日期值 → 忽略(不报错、不过滤)
843
+ const bad = new FakeRes();
844
+ await route.handler(req("/api/dsh-mneme/list?updatedFrom=not-a-date&updatedTo=%E4%B9%B1"), bad);
845
+ const badData = JSON.parse(bad.body);
846
+ assert.equal(badData.items.length, 3);
847
+ assert.equal(badData.total, 3);
848
+
849
+ // 完整时间戳同样生效
850
+ const ts = new FakeRes();
851
+ await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-02T00:00:00.000Z"), ts);
852
+ const tsData = JSON.parse(ts.body);
853
+ assert.equal(tsData.items.length, 1);
854
+ assert.equal(tsData.items[0].title, "新");
855
+ });
856
+
857
+ test("POST /api/dsh-mneme/update edits title/importance and archives", async () => {
858
+ const { routes, service } = setup();
859
+ const created = service.saveWithDedupe({ type: "preference", title: "原始", content: "原始内容" });
860
+ const id = created.memory.id;
861
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
862
+
863
+ const edit = new FakeRes();
864
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: "改名", importance: 5 }), edit);
865
+ assert.equal(edit.statusCode, 200);
866
+ const row = JSON.parse(edit.body).memory;
867
+ assert.equal(row.title, "改名");
868
+ assert.equal(row.importance, 5);
869
+ assert.equal(row.archived, false, "archived booleanized in the response row");
870
+ assert.notEqual(row.updated_at, created.memory.updated_at, "updated_at advances");
871
+
872
+ const archive = new FakeRes();
873
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: true }), archive);
874
+ assert.equal(archive.statusCode, 200);
875
+ assert.equal(JSON.parse(archive.body).memory.archived, true);
876
+ assert.equal(service.getById(id).archived, true);
877
+ });
878
+
879
+ test("POST /api/dsh-mneme/update archives replaced content into content_history", async () => {
880
+ const { routes, service } = setup();
881
+ const created = service.saveWithDedupe({ type: "preference", title: "历史", content: "第一版" });
882
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
883
+ const res = new FakeRes();
884
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id: created.memory.id, content: "第二版" }), res);
885
+ assert.equal(res.statusCode, 200);
886
+ const row = service.getById(created.memory.id);
887
+ assert.equal(row.content, "第二版");
888
+ assert.equal(row.content_history[0].content, "第一版");
889
+ assert.equal(row.content_history[0].source, "human_override");
890
+ });
891
+
892
+ test("POST /api/dsh-mneme/update returns 400/404 for bad requests", async () => {
893
+ const { routes, service } = setup();
894
+ const created = service.saveWithDedupe({ type: "preference", title: "存在", content: "x" });
895
+ const id = created.memory.id;
896
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
897
+
898
+ const missing = new FakeRes();
899
+ await route.handler(req("/api/dsh-mneme/update", "POST", { title: "无 id" }), missing);
900
+ assert.equal(missing.statusCode, 400);
901
+
902
+ const noFields = new FakeRes();
903
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id }), noFields);
904
+ assert.equal(noFields.statusCode, 400);
905
+
906
+ const emptyTitle = new FakeRes();
907
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: " " }), emptyTitle);
908
+ assert.equal(emptyTitle.statusCode, 400);
909
+
910
+ const emptyContent = new FakeRes();
911
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, content: "" }), emptyContent);
912
+ assert.equal(emptyContent.statusCode, 400);
913
+
914
+ const badImportance = new FakeRes();
915
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, importance: 9 }), badImportance);
916
+ assert.equal(badImportance.statusCode, 400);
917
+
918
+ const badTags = new FakeRes();
919
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, tags: "x" }), badTags);
920
+ assert.equal(badTags.statusCode, 400);
921
+
922
+ const badArchived = new FakeRes();
923
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: "yes" }), badArchived);
924
+ assert.equal(badArchived.statusCode, 400);
925
+
926
+ const gone = new FakeRes();
927
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id: "no-such-id", title: "x" }), gone);
928
+ assert.equal(gone.statusCode, 404);
929
+ assert.deepEqual(JSON.parse(gone.body), { error: "not-found" });
930
+
931
+ assert.equal(service.getById(id).title, "存在", "failed writes persist nothing");
932
+ });
933
+
934
+ test("POST /api/dsh-mneme/update is token-gated", async () => {
935
+ const { routes } = setup(undefined, "secret-token");
936
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
937
+ const res = new FakeRes();
938
+ await route.handler(req("/api/dsh-mneme/update", "POST", { id: "x", title: "y" }), res);
939
+ assert.equal(res.statusCode, 401);
940
+ });
941
+
942
+ test("GET /api/dsh-mneme/memories/entities returns entities linked to a memory", async () => {
943
+ const { routes, service } = setup();
944
+ const a = service.saveWithDedupe({ type: "project", title: "A", content: "提到甲" });
945
+ const b = service.saveWithDedupe({ type: "project", title: "B", content: "无关" });
946
+ const entity = service.createEntity({ name: "甲", type: "person" });
947
+ // 同一记忆两次提及同一实体(两条 attr)→ 去重后只出现一次
948
+ service.saveAttr({ entity_id: entity.id, attr_key: "角色", attr_value: "负责人", memory_id: a.memory.id });
949
+ service.saveAttr({ entity_id: entity.id, attr_key: "团队", attr_value: "前端", memory_id: a.memory.id });
950
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memories/entities");
951
+
952
+ const res = new FakeRes();
953
+ await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${a.memory.id}`), res);
954
+ assert.equal(res.statusCode, 200);
955
+ const data = JSON.parse(res.body);
956
+ assert.equal(data.entities.length, 1, "deduped by mention");
957
+ assert.equal(data.entities[0].name, "甲");
958
+ assert.equal(data.entities[0].type, "person");
959
+
960
+ const none = new FakeRes();
961
+ await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${b.memory.id}`), none);
962
+ assert.deepEqual(JSON.parse(none.body), { entities: [] });
963
+
964
+ const missing = new FakeRes();
965
+ await route.handler(req("/api/dsh-mneme/memories/entities"), missing);
966
+ assert.equal(missing.statusCode, 400);
967
+ });
968
+
969
+ test("GET /api/dsh-mneme/export json carries full rows and version", async () => {
970
+ const { routes, service } = setup();
971
+ service.saveWithDedupe({ type: "preference", title: "甲", content: "内容甲" });
972
+ const hidden = service.saveWithDedupe({ type: "preference", title: "乙", content: "内容乙" });
973
+ service.setForget(hidden.memory.id, true);
974
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/export");
975
+ const res = new FakeRes();
976
+ await route.handler(req("/api/dsh-mneme/export"), res);
977
+ assert.equal(res.statusCode, 200);
978
+ const data = JSON.parse(res.body);
979
+ assert.equal(data.count, 2, "export includes forgotten rows");
980
+ assert.equal(data.memories.length, 2);
981
+ assert.equal(typeof data.version, "string", "version comes from package.json");
982
+ assert.ok(data.exported_at);
983
+ assert.equal(data.memories.every((m) => typeof m.archived === "boolean" && typeof m.forgotten === "boolean"), true);
984
+ const times = data.memories.map((m) => m.updated_at);
985
+ assert.deepEqual(times, [...times].sort().reverse(), "updated_at DESC");
986
+ assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.json"/);
987
+ });
988
+
989
+ test("GET /api/dsh-mneme/export markdown feeds straight back through import", async () => {
990
+ const { routes, service } = setup();
991
+ const a = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文优先" });
992
+ const b = service.saveWithDedupe({ type: "preference", title: "风格", content: "简洁" });
993
+ const exportRoute = routes.find((r) => r.path === "/api/dsh-mneme/export");
994
+ const importRoute = routes.find((r) => r.path === "/api/dsh-mneme/import");
995
+
996
+ const res = new FakeRes();
997
+ await exportRoute.handler(req("/api/dsh-mneme/export?format=markdown"), res);
998
+ assert.equal(res.statusCode, 200);
999
+ assert.match(res.headers["Content-Type"], /text\/markdown/);
1000
+ assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.md"/);
1001
+ const md = res.body;
1002
+ assert.ok(md.includes("- **ID**: `" + a.memory.id + "`"), "anchor line present (readHumanEdits-compatible)");
1003
+
1004
+ // 黄金用例:导出文本原样导入 → 解析出全部条目,且字段无漂移
1005
+ const back = new FakeRes();
1006
+ await importRoute.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: md }), back);
1007
+ assert.equal(back.statusCode, 200);
1008
+ const data = JSON.parse(back.body);
1009
+ assert.equal(data.type, "preference");
1010
+ assert.ok(data.merged >= 2, "both memories parsed back");
1011
+ assert.equal(service.getById(a.memory.id).title, "语言");
1012
+ assert.equal(service.getById(a.memory.id).content, "中文优先");
1013
+ assert.equal(service.getById(b.memory.id).title, "风格");
1014
+ assert.equal(service.getById(b.memory.id).content, "简洁");
1015
+ });
1016
+
1017
+ test("POST /api/dsh-mneme/import validates type/markdown and tolerates zero edits", async () => {
1018
+ const { routes } = setup();
1019
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
1020
+
1021
+ const badType = new FakeRes();
1022
+ await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preferences", markdown: "x" }), badType);
1023
+ assert.equal(badType.statusCode, 400, "TYPE_FILE keys are singular, plural is rejected");
1024
+
1025
+ const badMd = new FakeRes();
1026
+ await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "" }), badMd);
1027
+ assert.equal(badMd.statusCode, 400);
1028
+
1029
+ const zero = new FakeRes();
1030
+ await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "# 空镜像" }), zero);
1031
+ assert.equal(zero.statusCode, 200);
1032
+ assert.deepEqual(JSON.parse(zero.body), { merged: 0, type: "preference" });
1033
+ });
1034
+
1035
+ test("POST /api/dsh-mneme/import is token-gated", async () => {
1036
+ const { routes } = setup(undefined, "secret-token");
1037
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
1038
+ const res = new FakeRes();
1039
+ await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "x" }), res);
1040
+ assert.equal(res.statusCode, 401);
1041
+ });
1042
+
1043
+ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", async () => {
1044
+ const { routes, service } = setup();
1045
+ service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", provider: "ollama", model: "qwen", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
1046
+ service.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "failed", error: "boom", snapshot_hash: "h2", input_count: 0, receipt: "r2" });
1047
+ const pending = service.saveConflictPending({ memory_a: "aaaa", memory_b: "bbbb", reason: "矛盾" });
1048
+
1049
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1050
+ const res = new FakeRes();
1051
+ await route.handler(req("/api/dsh-mneme/dream-status"), res);
1052
+ assert.equal(res.statusCode, 200);
1053
+ const data = JSON.parse(res.body);
1054
+ assert.equal(data.runs.length, 2);
1055
+ assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
1056
+ assert.deepEqual(data.lastRun, data.runs[0]);
1057
+ assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
1058
+ assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
1059
+ assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
1060
+ assert.equal(data.runs[0].error, "boom");
1061
+ assert.equal(data.runs[1].provider, "ollama");
1062
+ assert.equal(data.pendingConflicts, 1);
1063
+ assert.deepEqual(data.pendingMemoryIds.sort(), ["aaaa", "bbbb"]);
1064
+
1065
+ // 解决后队列清空
1066
+ service.resolveConflictPending(pending.id, { winner: "aaaa" });
1067
+ const after = new FakeRes();
1068
+ await route.handler(req("/api/dsh-mneme/dream-status"), after);
1069
+ const afterData = JSON.parse(after.body);
1070
+ assert.equal(afterData.pendingConflicts, 0);
1071
+ assert.deepEqual(afterData.pendingMemoryIds, []);
1072
+ });
1073
+
1074
+ // v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。
1075
+ test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => {
1076
+ const { routes, service } = setup();
1077
+ const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1078
+ const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1079
+ service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1080
+
1081
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1082
+ const res = new FakeRes();
1083
+ await route.handler(req("/api/dsh-mneme/conflicts"), res);
1084
+ assert.equal(res.statusCode, 200);
1085
+ const data = JSON.parse(res.body);
1086
+ assert.equal(data.items.length, 1);
1087
+ assert.equal(data.total, 1);
1088
+ assert.equal(data.items[0].reason, "主题偏好前后矛盾");
1089
+ // saveConflictPending 会按 id 字典序归一 A/B 侧位——断言集合而非位置。
1090
+ assert.deepEqual(
1091
+ [data.items[0].memory_a.title, data.items[0].memory_b.title].sort(),
1092
+ ["界面亮色切换", "界面暗色偏好"]
1093
+ );
1094
+ assert.equal(data.items[0].memory_a.archived, false);
1095
+ assert.equal(data.items[0].memory_b.archived, false);
1096
+ // 幽灵侧容忍缺失
1097
+ service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
1098
+ const res2 = new FakeRes();
1099
+ await route.handler(req("/api/dsh-mneme/conflicts"), res2);
1100
+ const data2 = JSON.parse(res2.body);
1101
+ assert.equal(data2.items.length, 2);
1102
+ assert.equal(data2.items.find((x) => x.reason === "对方已删").memory_b.missing, true);
1103
+ });
1104
+
1105
+ test("POST /api/dsh-mneme/conflicts/resolve stamps and applies the disposition", async () => {
1106
+ const { store, routes, service } = setup();
1107
+ const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1108
+ const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1109
+ const pending = service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1110
+
1111
+ // store 会把 pair 按 id 字典序归一——从队列行反查「界面亮色切换」所在侧再选。
1112
+ const queueRoute = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1113
+ const queueRes = new FakeRes();
1114
+ await queueRoute.handler(req("/api/dsh-mneme/conflicts"), queueRes);
1115
+ const queued = JSON.parse(queueRes.body).items.find((x) => x.id === pending.id);
1116
+ const keepSide = queued.memory_a.id === b.id ? "a" : "b";
1117
+ // loser 恒为 a(keeper 恒为「界面亮色切换」= b),与 A/B 侧位字母无关
1118
+ const loserId = a.id;
1119
+
1120
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts/resolve");
1121
+ // 保留「界面亮色切换」所在侧(apply 默认 true)→ 该侧保留,另一侧归档
1122
+ const res = new FakeRes();
1123
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: keepSide }), res);
1124
+ assert.equal(res.statusCode, 200);
1125
+ const data = JSON.parse(res.body);
1126
+ assert.equal(data.ok, true);
1127
+ assert.equal(data.conflict.resolved_winner, b.id);
1128
+ assert.equal(data.conflict.disposition.ok, true);
1129
+ assert.equal(store.getById(b.id).archived, false, "winner kept");
1130
+ assert.equal(store.getById(loserId).archived, true, "loser archived by apply");
1131
+
1132
+ // 处理过的 id 再 resolve → 幂等重盖(仍是 200;队列视图只列未解决行,
1133
+ // 正常 UI 不会再发这个请求,但接口层保持幂等)
1134
+ const res2 = new FakeRes();
1135
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: "a" }), res2);
1136
+ assert.equal(res2.statusCode, 200);
1137
+ assert.equal(JSON.parse(res2.body).ok, true);
1138
+
1139
+ // 未知 id → 404;缺 id → 400
1140
+ const res3 = new FakeRes();
1141
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: "nope", winner: "a" }), res3);
1142
+ assert.equal(res3.statusCode, 404);
1143
+ const res4 = new FakeRes();
1144
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", {}), res4);
1145
+ assert.equal(res4.statusCode, 400);
1146
+
1147
+ // apply:false 只盖章不处置
1148
+ const c = service.saveWithDedupe({ type: "decision", title: "丙", content: "内容丙" }).memory;
1149
+ const d = service.saveWithDedupe({ type: "decision", title: "丁", content: "内容丁" }).memory;
1150
+ const p2 = service.saveConflictPending({ memory_a: c.id, memory_b: d.id, reason: "r2" });
1151
+ const res5 = new FakeRes();
1152
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: p2.id, apply: false }), res5);
1153
+ assert.equal(res5.statusCode, 200);
1154
+ assert.equal(JSON.parse(res5.body).conflict.disposition, null);
1155
+ assert.equal(store.getById(c.id).archived, false);
1156
+ assert.equal(store.getById(d.id).archived, false);
1157
+ });
1158
+
1159
+ test("parseHumanEdits is the pure core of readHumanEdits (CRLF tolerant)", () => {
1160
+ const digest = "0".repeat(64);
1161
+ const text = [
1162
+ "# x\r\n",
1163
+ "\r\n",
1164
+ "## 标题\r\n",
1165
+ "\r\n",
1166
+ "- **ID**: `m1`\n",
1167
+ "- **类型**: preference\n",
1168
+ "- **重要性**: 3\n",
1169
+ "- **标签**: \n",
1170
+ "- **更新时间**: 2026-01-01T00:00:00.000Z\n",
1171
+ "\n",
1172
+ `<!-- mirror-digest: ${digest} -->\n`,
1173
+ "正文一段\n",
1174
+ "\n",
1175
+ "---\n",
1176
+ "\n",
1177
+ "## 另一条\n",
1178
+ "\r\n",
1179
+ "- **ID**: `m2`\n",
1180
+ "- **类型**: preference\n",
1181
+ "- **重要性**: 4\n",
1182
+ "- **标签**: \n",
1183
+ "- **更新时间**: 2026-01-02T00:00:00.000Z\n",
1184
+ "\n",
1185
+ "内容二\n",
1186
+ "\n",
1187
+ "---\n"
1188
+ ].join("");
1189
+ const edits = parseHumanEdits(text);
1190
+ assert.equal(edits.length, 2);
1191
+ assert.equal(edits[0].id, "m1");
1192
+ assert.equal(edits[0].title, "标题");
1193
+ assert.equal(edits[0].content, "正文一段");
1194
+ assert.equal(edits[1].id, "m2");
1195
+ assert.equal(edits[1].content, "内容二");
1196
+ });
1197
+
1198
+ test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default list hides them", async () => {
1199
+ const { routes, service } = setup();
1200
+ service.saveWithDedupe({ type: "preference", title: "在用", content: "keep" });
1201
+ service.saveWithDedupe({ type: "project", title: "已归档", content: "gone" });
1202
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1203
+ const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1204
+
1205
+ const def0 = new FakeRes();
1206
+ await route.handler(req("/api/dsh-mneme/list"), def0);
1207
+ const target = JSON.parse(def0.body).items.find((m) => m.title === "已归档");
1208
+
1209
+ const arch = new FakeRes();
1210
+ await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: true }), arch);
1211
+ assert.equal(arch.statusCode, 200);
1212
+
1213
+ const def = new FakeRes();
1214
+ await route.handler(req("/api/dsh-mneme/list"), def);
1215
+ assert.equal(JSON.parse(def.body).total, 1, "default list hides archived rows");
1216
+
1217
+ const only = new FakeRes();
1218
+ await route.handler(req("/api/dsh-mneme/list?archived=only"), only);
1219
+ const onlyData = JSON.parse(only.body);
1220
+ assert.equal(onlyData.total, 1, "archived-only count matches rows");
1221
+ assert.deepEqual(onlyData.items.map((m) => m.title), ["已归档"]);
1222
+ assert.equal(onlyData.items[0].archived, true);
1223
+
1224
+ // 恢复(unarchive)后归档视图清空、默认列表重新可见
1225
+ const restore = new FakeRes();
1226
+ await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: false }), restore);
1227
+ assert.equal(restore.statusCode, 200);
1228
+ const empty = new FakeRes();
1229
+ await route.handler(req("/api/dsh-mneme/list?archived=only"), empty);
1230
+ assert.equal(JSON.parse(empty.body).total, 0);
1231
+ const back = new FakeRes();
1232
+ await route.handler(req("/api/dsh-mneme/list"), back);
1233
+ assert.equal(JSON.parse(back.body).total, 2);
1234
+ });
1235
+
1236
+ test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
1237
+ const { routes, service } = setup();
1238
+ const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
1239
+ const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
1240
+ const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
1241
+ service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
1242
+ const ids = [plain, merged, updated].map((r) => r.memory.id);
1243
+
1244
+ // 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
1245
+ // 内容,不算沉淀。verdict='live' 才有效。
1246
+ service.saveReceipt({
1247
+ receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
1248
+ input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
1249
+ verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
1250
+ created_at: new Date().toISOString()
1251
+ });
1252
+ service.saveReceipt({
1253
+ receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
1254
+ input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
1255
+ policy_epoch: 0, created_at: new Date().toISOString()
1256
+ });
1257
+ service.saveReceipt({
1258
+ receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
1259
+ input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
1260
+ count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
1261
+ });
1262
+
1263
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1264
+ const res = new FakeRes();
1265
+ await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
1266
+ const data = JSON.parse(res.body);
1267
+ assert.deepEqual(
1268
+ data.items.map((m) => m.title).sort(),
1269
+ ["总览", "被巩固", "被更新"],
1270
+ "deposited view = receipt merge/update records ∪ source=dream writes"
1271
+ );
1272
+ assert.equal(data.total, 3, "total honors the deposited filter");
1273
+
1274
+ // 默认列表不受 deposited 过滤影响
1275
+ const def = new FakeRes();
1276
+ await route.handler(req("/api/dsh-mneme/list"), def);
1277
+ assert.equal(JSON.parse(def.body).total, 4);
1278
+
1279
+ // 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
1280
+ const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1281
+ const arch = new FakeRes();
1282
+ await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
1283
+ assert.equal(arch.statusCode, 200);
1284
+ const both = new FakeRes();
1285
+ await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
1286
+ const bothData = JSON.parse(both.body);
1287
+ assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
1288
+ assert.equal(bothData.total, 1);
1289
+ });
1290
+
1291
+ test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=true", async () => {
1292
+ // 默认(heatEnabled=false):heat 字段整体缺省——前端徽章据此自动隐藏
1293
+ const off = setup();
1294
+ off.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1295
+ const r0 = new FakeRes();
1296
+ await off.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r0);
1297
+ const d0 = JSON.parse(r0.body);
1298
+ assert.equal(d0.total, 1);
1299
+ assert.equal("heat" in d0.items[0], false, "heat must be absent from the wire DTO when the flag is off");
1300
+
1301
+ // heatEnabled=true:逐条投影。λ=0 免疫类型(preference)恒 1.0;其余落在
1302
+ // [0,1] 区间(新建记忆 Δt≈0 接近满格,衰减数学由 heat.test.js 看门)。
1303
+ const on = setup(null, "", { heatEnabled: true });
1304
+ on.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1305
+ on.service.saveWithDedupe({ type: "history", title: "会话历史", content: "recent" });
1306
+ const r1 = new FakeRes();
1307
+ await on.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r1);
1308
+ const d1 = JSON.parse(r1.body);
1309
+ assert.equal(d1.total, 2);
1310
+ const byTitle = Object.fromEntries(d1.items.map((m) => [m.title, m.heat]));
1311
+ assert.equal(byTitle["免疫型"], 1, "λ=0 immune types stay at full heat");
1312
+ for (const v of Object.values(byTitle)) {
1313
+ assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
1314
+ }
1315
+ });
1316
+
1317
+ // ---------------------------------------------------------------- llm-providers / test-model
1318
+ // Settings-panel support: enumerate host-registered providers/models so the
1319
+ // panel can offer a dropdown over real models, and probe connectivity with a
1320
+ // minimal LLM call (same harness path the consolidation uses).
1321
+
1322
+ function setupLlm(llm, apiToken = "", extraCtx = {}, config = null) {
1323
+ const store = createStore(":memory:");
1324
+ const service = createService({ store, mirror: null, config: {} });
1325
+ const settings = createSettings(store.db);
1326
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
1327
+ const routes = [];
1328
+ const ctx = {
1329
+ webServer: { register(route) { routes.push(route); return () => {}; } },
1330
+ llm,
1331
+ ...extraCtx
1332
+ };
1333
+ const api = createApi(ctx, service, settings, commands, undefined, undefined, apiToken, config);
1334
+ return { routes };
1335
+ }
1336
+
1337
+ const MOCK_LLM = {
1338
+ listProviders: () => [
1339
+ { id: "deepseek", name: "DeepSeek" },
1340
+ { id: "broken", name: "Broken Adapter" }
1341
+ ],
1342
+ listModels: async (provider) => {
1343
+ if (provider === "broken") throw new Error("provider not reachable");
1344
+ return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
1345
+ },
1346
+ async *stream(options) {
1347
+ MOCK_LLM.lastOptions = options;
1348
+ if (options.model === "bad-model") {
1349
+ yield {
1350
+ type: "finish",
1351
+ reason: { kind: "error", failure: { code: "AUTH_FAILED", message: "invalid api key" } }
1352
+ };
1353
+ return;
1354
+ }
1355
+ if (options.reasoningEffort === "reject") {
1356
+ throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
1357
+ }
1358
+ yield { type: "text-delta", index: 0, text: " ok " };
1359
+ yield { type: "finish", reason: { kind: "stop" } };
1360
+ }
1361
+ };
1362
+
1363
+ test("GET /api/dsh-mneme/llm-providers lists host providers with models, per-provider best effort", async () => {
1364
+ const { routes } = setupLlm(MOCK_LLM);
1365
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
1366
+ const res = new FakeRes();
1367
+ await route.handler(req("/api/dsh-mneme/llm-providers"), res);
1368
+ assert.equal(res.statusCode, 200);
1369
+ const data = JSON.parse(res.body);
1370
+ assert.equal(data.providers.length, 2);
1371
+ const deepseek = data.providers.find((p) => p.provider === "deepseek");
1372
+ assert.deepEqual(deepseek.models.map((m) => m.id), ["deepseek-chat", "deepseek-reasoner"]);
1373
+ const broken = data.providers.find((p) => p.provider === "broken");
1374
+ assert.deepEqual(broken.models, [], "a provider whose model list throws degrades to empty, not a hard fail");
1375
+ });
1376
+
1377
+ test("GET /api/dsh-mneme/llm-providers without ctx.llm returns 501", async () => {
1378
+ const { routes } = setup();
1379
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
1380
+ const res = new FakeRes();
1381
+ await route.handler(req("/api/dsh-mneme/llm-providers"), res);
1382
+ assert.equal(res.statusCode, 501);
1383
+ assert.equal(JSON.parse(res.body).error, "llm-unavailable");
1384
+ });
1385
+
1386
+ test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", async () => {
1387
+ const { routes } = setupLlm(MOCK_LLM);
1388
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1389
+ const res = new FakeRes();
1390
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1391
+ assert.equal(res.statusCode, 200);
1392
+ const data = JSON.parse(res.body);
1393
+ assert.equal(data.ok, true);
1394
+ assert.equal(data.reply, "ok", "reply trimmed");
1395
+ assert.ok(data.durationMs >= 0);
1396
+ assert.equal(data.modelId, "deepseek:deepseek-chat", "modelId reports the probed route");
1397
+ assert.equal(MOCK_LLM.lastOptions.provider, "deepseek");
1398
+ assert.equal(MOCK_LLM.lastOptions.purpose, "dsh-mneme-connectivity-test");
1399
+ // 回归:探测必须是一条 user 消息(带 source)。system-only 对话自
1400
+ // 0.1.6-alpha.1 起被官方 API 整单拒绝(「messages: at least one message
1401
+ // is required」),探测按钮会 502。
1402
+ assert.equal(MOCK_LLM.lastOptions.messages[0].role, "user");
1403
+ assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin");
1404
+ assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
1405
+ });
1406
+
1407
+ test("POST /api/dsh-mneme/test-model forwards reasoningEffort verbatim", async () => {
1408
+ const { routes } = setupLlm(MOCK_LLM);
1409
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1410
+ const res = new FakeRes();
1411
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat", reasoningEffort: "low" }), res);
1412
+ assert.equal(res.statusCode, 200);
1413
+ assert.equal(MOCK_LLM.lastOptions.reasoningEffort, "low", "effort forwarded to the probe call");
1414
+ });
1415
+
1416
+ test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", async () => {
1417
+ const { routes } = setupLlm(MOCK_LLM);
1418
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1419
+ const res = new FakeRes();
1420
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model" }), res);
1421
+ assert.equal(res.statusCode, 502);
1422
+ const data = JSON.parse(res.body);
1423
+ assert.equal(data.ok, false);
1424
+ assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
1425
+ });
1426
+
1427
+ test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
1428
+ const { routes } = setupLlm(MOCK_LLM);
1429
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1430
+ const res = new FakeRes();
1431
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
1432
+ assert.equal(res.statusCode, 502);
1433
+ const data = JSON.parse(res.body);
1434
+ assert.equal(data.ok, false);
1435
+ assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
1436
+ });
1437
+
1438
+ test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
1439
+ // Empty provider/model = "test whatever consolidation uses now": the route
1440
+ // falls back to agentDefaultModel (config dreamProvider/dreamModel absent),
1441
+ // the probe runs against that model, and modelId reports what was tested.
1442
+ const { routes } = setupLlm(MOCK_LLM, "", {
1443
+ agentDefaultModel: { currentSelection: () => ({ provider: "sel-provider", model: "sel-model" }) }
1444
+ });
1445
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1446
+ const res = new FakeRes();
1447
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
1448
+ assert.equal(res.statusCode, 200);
1449
+ const data = JSON.parse(res.body);
1450
+ assert.equal(data.ok, true);
1451
+ assert.equal(data.modelId, "sel-provider:sel-model", "probe ran against the resolved consolidation route");
1452
+ assert.equal(MOCK_LLM.lastOptions.provider, "sel-provider");
1453
+ assert.equal(MOCK_LLM.lastOptions.model, "sel-model");
1454
+ });
1455
+
1456
+ test("POST /api/dsh-mneme/test-model validates input and llm availability", async () => {
1457
+ const { routes } = setupLlm(MOCK_LLM);
1458
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1459
+ let res = new FakeRes();
1460
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek" }), res);
1461
+ assert.equal(res.statusCode, 400, "partial input (provider without model) rejected");
1462
+ assert.equal(JSON.parse(res.body).error, "missing-provider-or-model");
1463
+
1464
+ // empty body with no route (no agentDefaultModel, no config route) -> no-route
1465
+ res = new FakeRes();
1466
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
1467
+ assert.equal(res.statusCode, 400, "empty body with no resolvable route rejected");
1468
+ assert.equal(JSON.parse(res.body).error, "no-route");
1469
+
1470
+ const { routes: routesNoLlm } = setup();
1471
+ const routeNoLlm = routesNoLlm.find((r) => r.path === "/api/dsh-mneme/test-model");
1472
+ res = new FakeRes();
1473
+ await routeNoLlm.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1474
+ assert.equal(res.statusCode, 501, "no ctx.llm -> llm-unavailable");
1475
+ });
1476
+
1477
+ test("POST /api/dsh-mneme/test-model is auth-gated like other expensive endpoints", async () => {
1478
+ const { routes } = setupLlm(MOCK_LLM, "secret-token");
1479
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1480
+ const res = new FakeRes();
1481
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1482
+ assert.equal(res.statusCode, 401, "probe spends the user's API quota, so it must require auth");
1483
+ });
1484
+
1485
+ test("GET /api/dsh-mneme/inject-status: suppressed only when minimal preset observed (issue #182)", async () => {
1486
+ const store = createStore(":memory:");
1487
+ const service = createService({ store, mirror: null, config: {} });
1488
+ const settings = createSettings(store.db);
1489
+ const routes = [];
1490
+ let onEvent = null;
1491
+ const ctx = {
1492
+ webServer: {
1493
+ register(route) {
1494
+ routes.push(route);
1495
+ return () => {};
1496
+ }
1497
+ },
1498
+ on(type, fn) {
1499
+ onEvent = fn;
1500
+ return () => {};
1501
+ }
1502
+ };
1503
+ const api = createApi(
1504
+ ctx,
1505
+ service,
1506
+ settings,
1507
+ { add() {}, remove() {}, list() { return []; } },
1508
+ undefined,
1509
+ undefined,
1510
+ "",
1511
+ Config({}) // autoInject default true
1512
+ );
1513
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-status");
1514
+
1515
+ // 未观测到任何会话:agentPreset=null,不猜、不压制。
1516
+ let res = new FakeRes();
1517
+ await route.handler(req("/api/dsh-mneme/inject-status"), res);
1518
+ assert.equal(res.statusCode, 200);
1519
+ assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: null, suppressed: false });
1520
+
1521
+ // minimal 会话事件到达(session/event 的第一参为 session)→ suppressed。
1522
+ onEvent({ header: { agentPreset: "minimal" } });
1523
+ res = new FakeRes();
1524
+ await route.handler(req("/api/dsh-mneme/inject-status"), res);
1525
+ assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: "minimal", suppressed: true });
1526
+
1527
+ // standard 会话 → 无提示。
1528
+ onEvent({ header: { agentPreset: "standard" } });
1529
+ res = new FakeRes();
1530
+ await route.handler(req("/api/dsh-mneme/inject-status"), res);
1531
+ assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: "standard", suppressed: false });
1532
+
1533
+ // 头里没有 agentPreset(降级形状)→ 回落 null,静默不猜。
1534
+ onEvent({ header: {} });
1535
+ res = new FakeRes();
1536
+ await route.handler(req("/api/dsh-mneme/inject-status"), res);
1537
+ assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: null, suppressed: false });
1538
+
1539
+ // 非 GET → 404(与其他只读端点同款)。
1540
+ res = new FakeRes();
1541
+ await route.handler(req("/api/dsh-mneme/inject-status", "POST"), res);
1542
+ assert.equal(res.statusCode, 404);
1543
+ });
1544
+
1545
+ test("inject-status: autoInject off is never suppressed (user's own choice, not a black box)", async () => {
1546
+ const store = createStore(":memory:");
1547
+ const service = createService({ store, mirror: null, config: {} });
1548
+ const settings = createSettings(store.db);
1549
+ const routes = [];
1550
+ let onEvent = null;
1551
+ const ctx = {
1552
+ webServer: {
1553
+ register(route) {
1554
+ routes.push(route);
1555
+ return () => {};
1556
+ }
1557
+ },
1558
+ on(type, fn) {
1559
+ onEvent = fn;
1560
+ return () => {};
1561
+ }
1562
+ };
1563
+ const api = createApi(
1564
+ ctx,
1565
+ service,
1566
+ settings,
1567
+ { add() {}, remove() {}, list() { return []; } },
1568
+ undefined,
1569
+ undefined,
1570
+ "",
1571
+ Config({ autoInject: false })
1572
+ );
1573
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-status");
1574
+ onEvent({ header: { agentPreset: "minimal" } });
1575
+ const res = new FakeRes();
1576
+ await route.handler(req("/api/dsh-mneme/inject-status"), res);
1577
+ assert.deepEqual(JSON.parse(res.body), { autoInject: false, agentPreset: "minimal", suppressed: false });
1578
+ });