@modusensus/dsh-mneme 0.8.13 → 0.8.15

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