@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
package/src/api.js CHANGED
@@ -1,1433 +1,1605 @@
1
- import { URL } from "node:url";
2
- import { timingSafeEqual, randomBytes } from "node:crypto";
3
- import { FEATURE_FLAG_SPEC } from "./settings.js";
4
- import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
5
- import { langOf } from "./lang.js";
6
- import { computeHeat } from "./heat.js";
7
- import { describeStreamFailure, resolveRoute } from "./dream.js";
8
- import { getInjectionSnapshot } from "./inject.js";
9
- import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
10
- import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
11
- import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
12
-
13
- // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
14
- function sendJson(res, status, payload, headers = {}) {
15
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
16
- res.end(JSON.stringify(payload));
17
- }
18
-
19
- // 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
20
- function sendAttachment(res, status, contentType, filename, body) {
21
- res.writeHead(status, {
22
- "Content-Type": contentType,
23
- "Content-Disposition": `attachment; filename="${filename}"`
24
- });
25
- res.end(body);
26
- }
27
-
28
- // 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
29
- // 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
30
-
31
- // Defaults for the standalone external API — keep in step with the schema
32
- // defaults in config.js (externalApiPort / externalApiHost).
33
- const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
34
-
35
- /** Merge persisted external-api kv over the defaults for client display. */
36
- function fullExternalConfig(kv = {}) {
37
- return {
38
- enabled: kv.enabled === true,
39
- port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
40
- host: kv.host || EXTERNAL_API_DEFAULTS.host,
41
- token: kv.token || ""
42
- };
43
- }
44
-
45
- /**
46
- * Mask an API key for client display: keep a recognizable prefix and suffix,
47
- * hide the middle. Empty keys stay empty; short keys are fully hidden.
48
- * The mask only exists in the API layer — storage keeps the real key.
49
- */
50
- function maskApiKey(key) {
51
- if (!key) return "";
52
- if (key.length <= 8) return "***";
53
- return `${key.slice(0, 3)}***${key.slice(-4)}`;
54
- }
55
-
56
- /** True when the request carries the configured apiToken (or no token is set). */
57
- function isAuthorized(req, apiToken) {
58
- if (!apiToken) return true;
59
- const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
60
- const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
61
- if (token === "" || token.length !== apiToken.length) return false;
62
- // Constant-time comparison: avoid leaking the token via timing when the API
63
- // is exposed beyond loopback.
64
- return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
65
- }
66
-
67
- /**
68
- * Reject a request with 401 when auth is enabled and the token is missing or
69
- * wrong. Returns true when the request may proceed.
70
- */
71
- function requireAuth(req, res, apiToken) {
72
- if (isAuthorized(req, apiToken)) return true;
73
- sendJson(res, 401, { error: "unauthorized" });
74
- return false;
75
- }
76
-
77
- /** Collect the request body as text (tolerant of empty/invalid bodies). */
78
- function readBody(req) {
79
- return new Promise((resolve) => {
80
- let body = "";
81
- req.on("data", (chunk) => { body += chunk; });
82
- req.on("end", () => resolve(body));
83
- req.on("error", () => resolve(""));
84
- });
85
- }
86
-
87
- function parseBody(text) {
88
- try {
89
- return JSON.parse(text || "{}");
90
- } catch {
91
- return {};
92
- }
93
- }
94
-
95
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
96
- const disposers = [];
97
-
98
- // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
99
- // 知道当前会话的 agentPreset。注入回调在 minimal 下被宿主整体压制(#175 的
100
- // 定论),不能作为检测源;session/event 是 preset 无关钩子(蒸馏同款),任
101
- // 何事件都带完整 session 对象。只记最近一次会话头——这是面板展示用的「当前
102
- // 会话」最佳近似,不是逐会话账本。读法与 scope.js 同源(session.header 冻结
103
- // 的 SessionHeader,降级 requestHeader().config;agentPreset 取不到留 null,
104
- // 面板对 null 静默)。任何失败静默:纯可观测性,不新增故障面。
105
- let lastAgentPreset = null;
106
- try {
107
- const unsubscribe = ctx.on?.("session/event", (session) => {
108
- try {
109
- const header = session?.header ?? session?.requestHeader?.()?.config ?? null;
110
- const preset = header?.agentPreset;
111
- lastAgentPreset = typeof preset === "string" && preset.trim() ? preset.trim() : null;
112
- } catch { /* 观测失败不影响会话流 */ }
113
- });
114
- if (typeof unsubscribe === "function") disposers.push(unsubscribe);
115
- } catch { /* 宿主无 session/event 时面板提示退化为「未知」,不报错 */ }
116
-
117
- // webServer 在 index.js 的 inject 声明中(cordis 等宿主服务就绪后 apply),
118
- // 这里做防御性读取:cordis ctx 的 Proxy 不允许直接访问未 inject 的属性
119
- // (会抛 "cannot get property without inject"),用 ctx.reflect.get 免
120
- // inject 读取(未提供返回 undefined);对象字面量 mock ctx(测试)没有
121
- // reflect,退回直接属性访问。createApi 只在有 webServer 时被调用(index.js
122
- // 守卫 + 测试 mock),故下方 register 用之非空。
123
- const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
124
-
125
- // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
126
- // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
127
- // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
128
- const flagKeys = [
129
- ...FEATURE_FLAG_SPEC.booleans,
130
- ...Object.keys(FEATURE_FLAG_SPEC.ints),
131
- ...Object.keys(FEATURE_FLAG_SPEC.numbers),
132
- ...FEATURE_FLAG_SPEC.strings,
133
- ...FEATURE_FLAG_SPEC.urls,
134
- ...Object.keys(FEATURE_FLAG_SPEC.enums)
135
- ];
136
- // 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
137
- // 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
138
- // 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
139
- // effective 会静默少两个键(configFlagValue 取不到 undefined 就不写进去),
140
- // 面板上看不出来、PUT 也存不进去——test/api.test.js 的计数锁就是钉这件事的。
141
- const NESTED_FLAG_PATHS = {
142
- "memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
143
- "llmAudit.enabled": ["llmAudit", "enabled"],
144
- "writeAdmission.enabled": ["writeAdmission", "enabled"],
145
- "writeAdmission.enforce": ["writeAdmission", "enforce"]
146
- // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
147
- // 的默认分支即可。
148
- };
149
- function configFlagValue(key) {
150
- const path = NESTED_FLAG_PATHS[key];
151
- if (path) return config?.[path[0]]?.[path[1]];
152
- return config?.[key];
153
- }
154
- function featureSnapshot() {
155
- const overrides = settings.getFeatureFlags();
156
- const effective = {};
157
- for (const key of flagKeys) {
158
- if (overrides[key] !== undefined) effective[key] = overrides[key];
159
- else {
160
- const value = configFlagValue(key);
161
- if (value !== undefined) effective[key] = value;
162
- }
163
- }
164
- return { overrides, effective };
165
- }
166
-
167
- // Ensure the service has an embedder when the API layer was handed one
168
- // (tests wire the embedder through the API instead of index.js). Without
169
- // this, /api/dsh-mneme/search would silently degrade to keyword-only.
170
- if (embedder && typeof service.setEmbedder === "function") {
171
- service.setEmbedder(embedder);
172
- }
173
-
174
- const register = (route) => {
175
- disposers.push(webServer.register(route));
176
- };
177
-
178
- // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
179
- register({
180
- kind: "prefix",
181
- path: "/api/dsh-mneme",
182
- handler(req, res) {
183
- sendJson(res, 404, { error: "not-found" });
184
- }
185
- });
186
-
187
- // 反馈入口预填用的插件版本(面板「帮助与反馈」卡片拉取)。读取失败(打包
188
- // 环境)返回 "unknown",链接照常可用,纯展示信息不拦截。
189
- register({
190
- kind: "exact",
191
- path: "/api/dsh-mneme/info",
192
- handler(req, res) {
193
- sendJson(res, 200, { version: PACKAGE_VERSION });
194
- }
195
- });
196
-
197
- // 版本自检(#174 后续):运行版本 vs npm registry latest。只读,宿主围栏
198
- // 统一鉴权(与 dream-status 同款,面板 apiFetch 直接可用)。registry 查询
199
- // 全失败时 latest=null、status=unknown——前端对 unknown 完全静默,版本提示
200
- // 是锦上添花,绝不因它新增故障面。
201
- register({
202
- kind: "exact",
203
- path: "/api/dsh-mneme/version-check",
204
- handler(req, res) {
205
- if (req.method !== "GET") {
206
- sendJson(res, 404, { error: "not-found" });
207
- return;
208
- }
209
- fetchLatestVersion()
210
- .then((latest) => sendJson(res, 200, {
211
- version: PACKAGE_VERSION,
212
- latest,
213
- status: classify(PACKAGE_VERSION, latest),
214
- checkedAt: new Date().toISOString()
215
- }))
216
- .catch(() => sendJson(res, 200, { version: PACKAGE_VERSION, latest: null, status: "unknown" }));
217
- }
218
- });
219
-
220
- register({
221
- kind: "exact",
222
- path: "/api/dsh-mneme/list",
223
- handler(req, res) {
224
- try {
225
- const url = new URL(req.url, "http://localhost");
226
- const type = url.searchParams.get("type") ?? undefined;
227
- const limit = Number(url.searchParams.get("limit") ?? 50);
228
- const offset = Number(url.searchParams.get("offset") ?? 0);
229
- // order=chrono: pure newest-first for paged browsing; default keeps
230
- // the importance-ranked order other callers rely on.
231
- const order = url.searchParams.get("order") ?? undefined;
232
- // minImportance: numeric lower bound on importance (absent/NaN → no
233
- // floor); source: exact match on the source column (empty → no filter).
234
- const minRaw = url.searchParams.get("minImportance");
235
- const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
236
- ? Number(minRaw)
237
- : undefined;
238
- const source = url.searchParams.get("source") || undefined;
239
- // updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
240
- // 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
241
- // 完成,非法值在那里被忽略——这里原样透传即可。
242
- const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
243
- const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
244
- // archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
245
- // 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
246
- const onlyArchived = url.searchParams.get("archived") === "only";
247
- // deposited=only:只看 autoDream 巩固过的记忆(receipt_chain 的
248
- // merge/update verdict ∪ source=dream 直写)——状态页「查看全部」
249
- // 与记忆库的「沉淀」筛选 chip 共用这个视图。
250
- const depositedOnly = url.searchParams.get("deposited") === "only";
251
- const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
252
- // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
253
- // schema 严格复用 toApiList,扩展只发生在 HTTP 层。
254
- // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
255
- // (heat.js 纯函数,λ=0 免疫类型恒 1.0);字段缺省时前端徽章自动隐藏。
256
- const heatOn = config?.heatEnabled === true;
257
- const items = service.toApiList(rows).map((m, i) => ({
258
- ...m,
259
- archived: rows[i].archived === true || rows[i].archived === 1,
260
- quality_score: rows[i].quality_score ?? null,
261
- ...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
262
- }));
263
- // Total honors the same filters as the rows, or the pager's
264
- // has-more math breaks whenever minImportance/source/updated-at
265
- // bounds are active.
266
- sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) });
267
- } catch {
268
- sendJson(res, 500, { error: "internal" });
269
- }
270
- }
271
- });
272
-
273
- // 记忆复用统计(#217,只读聚合):Top-N 召回 + 僵尸率 + 覆盖度标注。窗口
274
- // 参数钳制 1-365 天(缺省 30),聚合口径见 service.recallStats;注入命中
275
- // 率待注入留痕口径拍板后接入。失败走既有 500 JSON 惯例,前端整卡不渲染。
276
- register({
277
- kind: "exact",
278
- path: "/api/dsh-mneme/recall-stats",
279
- handler(req, res) {
280
- if (req.method !== "GET") {
281
- sendJson(res, 404, { error: "not-found" });
282
- return;
283
- }
284
- try {
285
- const url = new URL(req.url, "http://localhost");
286
- const raw = Number(url.searchParams.get("window"));
287
- const windowDays = Number.isInteger(raw) && raw >= 1 && raw <= 365 ? raw : 30;
288
- sendJson(res, 200, service.recallStats({ windowDays }));
289
- } catch {
290
- sendJson(res, 500, { error: "internal" });
291
- }
292
- }
293
- });
294
-
295
- register({
296
- kind: "exact",
297
- path: "/api/dsh-mneme/search",
298
- handler(req, res) {
299
- try {
300
- const url = new URL(req.url, "http://localhost");
301
- const q = url.searchParams.get("q") ?? "";
302
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
303
- // mode selects the recall strategy (defaults to auto):
304
- // auto (default) keyword first, vector fills remaining slots
305
- // hybrid vector first, keyword fills remaining slots; scores of
306
- // memories hit by both sides are weight-blended
307
- // vector vector only, falls back to keyword when the vector path
308
- // is unavailable (no embedder or a throwing one)
309
- // keyword literal text only; never queries the embedder
310
- // rerank=false disables the cross-encoder reorder for this request;
311
- // the response `mode` field reports which path actually produced rows.
312
- const mode = url.searchParams.get("mode") ?? "auto";
313
- const rerank = url.searchParams.get("rerank") !== "false";
314
- const query = q.trim();
315
- if (!query) {
316
- sendJson(res, 200, { items: [], mode: "keyword" });
317
- return;
318
- }
319
- // Route through the unified semantic pipeline; any vector/rerank
320
- // failure degrades to keyword results inside searchMemories. The
321
- // returned promise lets the test double await the async search.
322
- return Promise.resolve(
323
- service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
324
- ).then((rows) => {
325
- // mode reflects what actually happened: rows marked `vector` came
326
- // through the semantic path, everything else is keyword fallback.
327
- const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
328
- sendJson(res, 200, { items: service.toApiList(rows), mode: used });
329
- }).catch(() => {
330
- sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
331
- });
332
- } catch {
333
- sendJson(res, 500, { error: "internal" });
334
- }
335
- }
336
- });
337
-
338
- // --- user profile ---
339
- register({
340
- kind: "exact",
341
- path: "/api/dsh-mneme/profile",
342
- handler(req, res) {
343
- try {
344
- if (req.method === "PUT" || req.method === "POST") {
345
- if (!requireAuth(req, res, apiToken)) return;
346
- return readBody(req).then((text) => {
347
- const body = parseBody(text);
348
- settings.setProfile(typeof body.profile === "string" ? body.profile : "");
349
- sendJson(res, 200, { profile: settings.getProfile() });
350
- });
351
- }
352
- sendJson(res, 200, { profile: settings.getProfile() });
353
- } catch {
354
- sendJson(res, 500, { error: "internal" });
355
- }
356
- }
357
- });
358
-
359
- // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
360
- // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
361
- // through the same harness path the consolidation uses and reports success /
362
- // failure + latency + error. Reuses describeStreamFailure so the panel shows
363
- // the same error wording the audit rows carry. Optional reasoningEffort
364
- // mirrors what the consolidation would send — the panel can verify a model
365
- // actually accepts the configured effort without tripping a full run.
366
- async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
367
- const startedAt = Date.now();
368
- const elapsed = () => Date.now() - startedAt;
369
- const modelId = `${provider}:${model}`;
370
- try {
371
- let reply = "";
372
- let error = "";
373
- for await (const chunk of llm.stream({
374
- provider,
375
- model,
376
- purpose: "dsh-mneme-connectivity-test",
377
- // 给思考模型留出「思考完还有正文」的空间:16 会被 reasoning 全部吃掉,
378
- // reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
379
- // 按钮无放大风险。
380
- maxTokens: 1024,
381
- ...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
382
- // role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
383
- // 抽成顶层参数,system-only 对话会被官方 API 以
384
- // 「messages: at least one message is required」整单拒绝——探测按钮 502。
385
- // user 消息在任何适配器下都是一条合法对话,探测从此不怕 host 演进。
386
- messages: [
387
- {
388
- role: "user",
389
- content: [{ type: "text", text: "Reply with exactly one word: ok" }],
390
- source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
391
- }
392
- ]
393
- })) {
394
- if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
395
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
396
- error = describeStreamFailure(chunk.reason);
397
- break;
398
- }
399
- }
400
- if (error) return { ok: false, durationMs: elapsed(), error, modelId };
401
- return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
402
- } catch (error) {
403
- return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
404
- }
405
- }
406
-
407
- // List every provider + model the host has registered, so the panel can offer
408
- // a dropdown over REAL models instead of free-text guesses. Read-only, no auth
409
- // (same as list/search). Best-effort per provider: one whose model list can't
410
- // be resolved yields an empty models array instead of failing the whole call.
411
- register({
412
- kind: "exact",
413
- path: "/api/dsh-mneme/llm-providers",
414
- handler(req, res) {
415
- try {
416
- const llm = ctx.llm;
417
- if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
418
- return sendJson(res, 501, { error: "llm-unavailable" });
419
- }
420
- let providers;
421
- try { providers = llm.listProviders() ?? []; } catch { providers = []; }
422
- return Promise.all(providers.map(async (p) => {
423
- let models = [];
424
- try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
425
- return {
426
- provider: p.id,
427
- models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
428
- };
429
- })).then((rows) => sendJson(res, 200, { providers: rows }));
430
- } catch {
431
- return sendJson(res, 500, { error: "internal" });
432
- }
433
- }
434
- });
435
-
436
- // Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
437
- // reports ok / failure + latency + error. Auth-gated: it spends the user's
438
- // API quota, so a stray local page must not be able to drain it via loopback.
439
- register({
440
- kind: "exact",
441
- path: "/api/dsh-mneme/test-model",
442
- handler(req, res) {
443
- try {
444
- if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
445
- if (!requireAuth(req, res, apiToken)) return;
446
- return readBody(req).then(async (text) => {
447
- const body = parseBody(text);
448
- let provider = typeof body.provider === "string" ? body.provider.trim() : "";
449
- let model = typeof body.model === "string" ? body.model.trim() : "";
450
- if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
451
- if (!provider && !model) {
452
- // 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
453
- // 让面板一键测"当前巩固模型"而无需手填。
454
- const route = resolveRoute(ctx, config ?? {}, ctx.logger);
455
- if (!route) return sendJson(res, 400, { error: "no-route" });
456
- provider = route.provider;
457
- model = route.model;
458
- }
459
- const llm = ctx.llm;
460
- if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
461
- const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
462
- return sendJson(res, result.ok ? 200 : 502, result);
463
- });
464
- } catch {
465
- return sendJson(res, 500, { error: "internal" });
466
- }
467
- }
468
- });
469
-
470
- // --- delete one memory by id ---
471
- // Mutation route: apiToken-gated like the profile/rules writes above. POST
472
- // body is JSON { id }. store.remove deletes silently, so existence is checked
473
- // up front to give clients a distinguishable 404 instead of a fake success.
474
- register({
475
- kind: "exact",
476
- path: "/api/dsh-mneme/delete",
477
- handler(req, res) {
478
- try {
479
- if (req.method !== "POST") {
480
- sendJson(res, 404, { error: "not-found" });
481
- return;
482
- }
483
- if (!requireAuth(req, res, apiToken)) return;
484
- return readBody(req).then((text) => {
485
- const body = parseBody(text);
486
- const id = typeof body.id === "string" ? body.id.trim() : "";
487
- if (!id) {
488
- sendJson(res, 400, { error: "missing-id" });
489
- return;
490
- }
491
- if (!service.getById(id)) {
492
- sendJson(res, 404, { error: "not-found" });
493
- return;
494
- }
495
- service.remove(id);
496
- sendJson(res, 200, { ok: true });
497
- });
498
- } catch {
499
- sendJson(res, 500, { error: "internal" });
500
- }
501
- }
502
- });
503
-
504
- // --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
505
- // POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
506
- // 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
507
- // ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
508
- // updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
509
- // 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
510
- // archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
511
- register({
512
- kind: "exact",
513
- path: "/api/dsh-mneme/update",
514
- handler(req, res) {
515
- try {
516
- if (req.method !== "POST") {
517
- sendJson(res, 404, { error: "not-found" });
518
- return;
519
- }
520
- if (!requireAuth(req, res, apiToken)) return;
521
- return readBody(req).then((text) => {
522
- const body = parseBody(text);
523
- const id = typeof body.id === "string" ? body.id.trim() : "";
524
- if (!id) {
525
- sendJson(res, 400, { error: "missing-id" });
526
- return;
527
- }
528
- const patch = {};
529
- if (body.title !== undefined) {
530
- if (typeof body.title !== "string" || !body.title.trim()) {
531
- sendJson(res, 400, { error: "invalid-title" });
532
- return;
533
- }
534
- patch.title = body.title.trim();
535
- }
536
- if (body.content !== undefined) {
537
- if (typeof body.content !== "string" || !body.content.trim()) {
538
- sendJson(res, 400, { error: "invalid-content" });
539
- return;
540
- }
541
- patch.content = body.content.trim();
542
- }
543
- if (body.importance !== undefined) {
544
- if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
545
- sendJson(res, 400, { error: "invalid-importance" });
546
- return;
547
- }
548
- patch.importance = body.importance;
549
- }
550
- if (body.tags !== undefined) {
551
- if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
552
- sendJson(res, 400, { error: "invalid-tags" });
553
- return;
554
- }
555
- patch.tags = body.tags;
556
- }
557
- // v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
558
- // 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
559
- // 审计行)在 service.updateMemory 内做,路由只做形状校验。
560
- if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
561
- sendJson(res, 400, { error: "invalid-agent-scope" });
562
- return;
563
- }
564
- if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
565
- sendJson(res, 400, { error: "invalid-workspace-scope" });
566
- return;
567
- }
568
- if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
569
- if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
570
- if (body.archived !== undefined && typeof body.archived !== "boolean") {
571
- sendJson(res, 400, { error: "invalid-archived" });
572
- return;
573
- }
574
- if (Object.keys(patch).length === 0 && body.archived === undefined) {
575
- sendJson(res, 400, { error: "no-fields" });
576
- return;
577
- }
578
- const existing = service.getById(id);
579
- if (!existing) {
580
- sendJson(res, 404, { error: "not-found" });
581
- return;
582
- }
583
- // 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
584
- if (patch.content !== undefined && patch.content !== existing.content) {
585
- const history = Array.isArray(existing.content_history) ? existing.content_history : [];
586
- patch.content_history = [
587
- { content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
588
- ...history
589
- ].slice(0, 20);
590
- }
591
- if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
592
- if (body.archived !== undefined) service.setArchived(id, body.archived);
593
- sendJson(res, 200, { memory: service.getById(id) });
594
- });
595
- } catch {
596
- sendJson(res, 500, { error: "internal" });
597
- }
598
- }
599
- });
600
-
601
- // --- rules ---
602
- register({
603
- kind: "exact",
604
- path: "/api/dsh-mneme/rules",
605
- handler(req, res) {
606
- try {
607
- if (req.method === "PUT" || req.method === "POST") {
608
- if (!requireAuth(req, res, apiToken)) return;
609
- return readBody(req).then((text) => {
610
- const body = parseBody(text);
611
- settings.setRules(Array.isArray(body.rules) ? body.rules : []);
612
- sendJson(res, 200, { rules: settings.getRules() });
613
- });
614
- }
615
- sendJson(res, 200, { rules: settings.getRules() });
616
- } catch {
617
- sendJson(res, 500, { error: "internal" });
618
- }
619
- }
620
- });
621
-
622
- // --- vector search config ---
623
- register({
624
- kind: "exact",
625
- path: "/api/dsh-mneme/vector-config",
626
- handler(req, res) {
627
- try {
628
- // Secret-bearing endpoint: fully protected when apiToken is set.
629
- if (!requireAuth(req, res, apiToken)) return;
630
- if (req.method === "PUT" || req.method === "POST") {
631
- return readBody(req).then((text) => {
632
- const body = parseBody(text);
633
- // An empty apiKey, or one that already looks masked (round-trips
634
- // through maskApiKey unchanged), means "keep the existing key".
635
- // Only a fresh, unmasked key is treated as a real replacement.
636
- const prev = settings.getVectorConfig();
637
- const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
638
- const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
639
- const key = isMaskedOrEmpty
640
- ? (prev?.apiKey ?? "")
641
- : incoming;
642
- const cfg = settings.setVectorConfig({
643
- enabled: body.enabled,
644
- baseUrl: body.baseUrl,
645
- apiKey: key,
646
- model: body.model
647
- });
648
- sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
649
- });
650
- }
651
- const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
652
- sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
653
- } catch {
654
- sendJson(res, 500, { error: "internal" });
655
- }
656
- }
657
- });
658
-
659
- // --- vector re-index (backfill embeddings for rows missing them) ---
660
- register({
661
- kind: "exact",
662
- path: "/api/dsh-mneme/vector-reindex",
663
- handler(req, res) {
664
- try {
665
- if (!requireAuth(req, res, apiToken)) return;
666
- if (!embedder) {
667
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
668
- return;
669
- }
670
- const url = new URL(req.url, "http://localhost");
671
- const limit = Number(url.searchParams.get("limit") ?? 100);
672
- // Unified re-index entry: works for both the legacy OpenAI embedder and
673
- // the new local/ollama backends (which have no reindexMissing method).
674
- const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
675
- const task = viaIndex
676
- ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
677
- : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
678
- // Return the chain so awaiting callers (tests/health checks) observe the
679
- // finished response rather than racing the async backfill.
680
- return task.then((result) => {
681
- sendJson(res, 200, result);
682
- }).catch(() => {
683
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
684
- });
685
- } catch {
686
- sendJson(res, 500, { error: "internal" });
687
- }
688
- }
689
- });
690
-
691
- // --- semantic pipeline status (model, index, reranker) ---
692
- register({
693
- kind: "exact",
694
- path: "/api/dsh-mneme/semantic",
695
- handler(req, res) {
696
- try {
697
- const stats = semantic?.vectorIndex?.getStats?.() ?? null;
698
- // #118: expose readiness so the status card can tell "initializing /
699
- // unreachable" from "disabled". An embedder carrying no `ready` prop at
700
- // all (test doubles, third-party adapters) is assumed usable.
701
- // issue #135: 但「可用」和「配了」是两件事 —— legacy OpenAI embedder
702
- // 此前恒报 ready,于是 baseUrl/apiKey/model 全空时状态卡依然是绿的
703
- // ("绿色假阳性")。下面把两者分开,并附上索引覆盖度。
704
- const ready = embedder ? ("ready" in embedder ? embedder.ready === true : true) : null;
705
- // local/ollama 的配置来自 settings 兜底,没有「未配置」态:它们的
706
- // ready=false 表示「还在初始化」,不是「没配」。
707
- const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
708
- const embedded = Number(stats?.embeddedCount ?? 0);
709
- const total = Number(stats?.totalCount ?? 0);
710
- sendJson(res, 200, {
711
- ready,
712
- configured,
713
- // 机器可读的原因;null = 一切正常。面板按它选文案,不用猜布尔组合。
714
- reason: !embedder ? "no-embedder"
715
- : configured === false ? "not-configured"
716
- : ready !== true ? "initializing"
717
- : null,
718
- // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
719
- coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
720
- // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
721
- // 处于 ready:true + embeddedCount:0,面板却显示正常)。
722
- degraded: configured === true && total > 0 && embedded === 0,
723
- // Explicit display name first: the legacy OpenAI-compatible embedder
724
- // is an object literal, so constructor.name would be "Object".
725
- embedProvider: embedder ? (embedder.name ?? embedder.constructor?.name ?? "unknown") : null,
726
- modelHash: embedder?.modelHash ?? null,
727
- dimension: embedder?.dimension ?? null,
728
- reranker: semantic?.reranker ? "ready" : null,
729
- index: stats,
730
- // issue #131 / PR-A:自管运行时的状态。无论当前 provider 是不是 local
731
- // 都报 —— 面板需要区分「已收编但没启用」和「要用本地却没收编」。
732
- // 这里只做结构检查(不跑推理),失败降级成状态对象,不会让本端点 500。
733
- // 必须经 publicRuntimeStatus 投影:本端点是免鉴权的,绝对路径与原始错误
734
- // 文本不能出去(CWE-200)。完整诊断走本机 CLI 的 status。
735
- localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir: config?.runtimeDir ?? "" }))
736
- });
737
- } catch {
738
- sendJson(res, 500, { error: "internal" });
739
- }
740
- }
741
- });
742
-
743
- // --- 一键取回自管运行时(issue #131 / PR-C:三档来源)-------------------------
744
- // 写路径(requireAuth):它会往 ~/.dsh/mneme/runtime/ 落一份依赖闭包。
745
- //
746
- // 第一档源固定取本 profile 的 node_modules(由插件自身路径推出),不接受客户端传源目录 ——
747
- // 「从任意目录往运行时目录里搬东西」没有必要成为对外能力。
748
- //
749
- // 一律回 200:请求本身合法,成不成看 body 里的 ok/status/reason。让 HTTP 码只表达
750
- // 「有没有鉴权/路径对不对」,结论交给 ok —— 面板要能显示「收编了但闭包缺件」这种
751
- // 中间状态,用一个 4xx 概括会把信息压掉。
752
- register({
753
- kind: "exact",
754
- path: "/api/dsh-mneme/runtime/provision",
755
- handler(req, res) {
756
- try {
757
- if (req.method !== "POST") {
758
- sendJson(res, 405, { error: "method-not-allowed" });
759
- return;
760
- }
761
- if (!requireAuth(req, res, apiToken)) return;
762
- return readBody(req).then(async (text) => {
763
- const body = parseBody(text);
764
- const runtimeDir = config?.runtimeDir ?? "";
765
- // 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
766
- // 所以这个端点会**联网**:它可能拉一份几十到上百 MB 的闭包。
767
- const result = await provisionRuntime({
768
- hostModulesDir: hostModulesDir(import.meta.url),
769
- runtimeDir,
770
- localTarballDir: config?.runtimeTarballDir ?? "",
771
- mirror: config?.runtimeMirror ?? "",
772
- overwrite: body?.overwrite === true
773
- });
774
- // 顺手带回最新状态,面板不必再打一次 /semantic(且它走的是免鉴权投影)。
775
- sendJson(res, 200, {
776
- ...result,
777
- localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir }))
778
- });
779
- });
780
- } catch {
781
- sendJson(res, 500, { error: "internal" });
782
- }
783
- }
784
- });
785
-
786
- // --- LLM audit trail (Bug8): paginated read + aggregate stats ---
787
- // Read-only endpoints, so like list/search/semantic they stay open even when
788
- // apiToken is set. The stats aggregate budget by source over the last N days.
789
- register({
790
- kind: "exact",
791
- path: "/api/dsh-mneme/semantic/llm-audit",
792
- handler(req, res) {
793
- try {
794
- const url = new URL(req.url, "http://localhost");
795
- const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
796
- const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
797
- const source = url.searchParams.get("source") ?? undefined;
798
- // 写入准入(#254)的 session_key 是内部计数键——session_id 不是对外的归属
799
- // 标识(见 scope.js 文件头),而本端点按注释在 apiToken 之下也保持开放,所以
800
- // 不回传它。按会话离线聚合直接读库。
801
- const items = (service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [])
802
- .map((row) => (row.session_key === undefined ? row : { ...row, session_key: undefined }));
803
- const total = service.countLlmAudits?.({ source }) ?? items.length;
804
- sendJson(res, 200, { items, total, page, pageSize });
805
- } catch {
806
- sendJson(res, 500, { error: "internal" });
807
- }
808
- }
809
- });
810
-
811
- register({
812
- kind: "exact",
813
- path: "/api/dsh-mneme/semantic/llm-audit/stats",
814
- handler(req, res) {
815
- try {
816
- const url = new URL(req.url, "http://localhost");
817
- const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 7) || 7));
818
- const stats = service.getLlmAuditStats?.({ days }) ?? null;
819
- sendJson(res, 200, stats ?? { error: "unavailable" });
820
- } catch {
821
- sendJson(res, 500, { error: "internal" });
822
- }
823
- }
824
- });
825
-
826
- // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
827
- // Read-only like list/search/semantic, so it stays open when apiToken is set.
828
- // BFS from the root entity over entity_relations (both directions; the
829
- // idx_relations_from/to indexes keep a 2-hop walk in the tens of ms even
830
- // for a few thousand nodes). `distance` on each node is the hop count from
831
- // the root so the UI can shade the frontier. The API is graph-traversal
832
- // only — nodes carry no attr payload; hover summaries come from
833
- // /semantic/graph/entity-attrs.
834
- register({
835
- kind: "exact",
836
- path: "/api/dsh-mneme/semantic/graph/ego",
837
- handler(req, res) {
838
- try {
839
- const url = new URL(req.url, "http://localhost");
840
- const name = (url.searchParams.get("entity") ?? "").trim();
841
- if (!name) {
842
- sendJson(res, 400, { error: "missing-entity" });
843
- return;
844
- }
845
- const root = service.findEntityByName?.(name);
846
- if (!root) {
847
- sendJson(res, 404, { error: "entity-not-found" });
848
- return;
849
- }
850
- const depth = Math.max(1, Math.min(2, Number(url.searchParams.get("depth") ?? 1) || 1));
851
- const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 40) || 40));
852
-
853
- const nodes = new Map([[root.id, { ...root, distance: 0 }]]);
854
- let frontier = [root.id];
855
- for (let d = 1; d <= depth && nodes.size < limit; d++) {
856
- const next = [];
857
- for (const id of frontier) {
858
- for (const rel of service.getRelations?.(id) ?? []) {
859
- const other = rel.from_entity === id ? rel.to_entity : rel.from_entity;
860
- if (nodes.has(other) || nodes.size >= limit) continue;
861
- const entity = service.findEntityById?.(other);
862
- if (!entity) continue;
863
- nodes.set(other, { ...entity, distance: d });
864
- next.push(other);
865
- }
866
- }
867
- frontier = next;
868
- }
869
-
870
- // Collect every relation whose endpoints both survived the limit cut;
871
- // each edge is visited twice (once per endpoint) so dedupe by id.
872
- const edgeMap = new Map();
873
- for (const id of nodes.keys()) {
874
- for (const rel of service.getRelations?.(id) ?? []) {
875
- if (nodes.has(rel.from_entity) && nodes.has(rel.to_entity)) {
876
- edgeMap.set(rel.id, rel);
877
- }
878
- }
879
- }
880
-
881
- sendJson(res, 200, {
882
- root: { id: root.id, name: root.name, type: root.type ?? null, mention_count: root.mention_count ?? 1 },
883
- nodes: [...nodes.values()].map((n) => ({
884
- id: n.id,
885
- name: n.name,
886
- type: n.type ?? null,
887
- mention_count: n.mention_count ?? 1,
888
- distance: n.distance,
889
- // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(max),前端据此
890
- // 缩放节点大小/明暗。heatEnabled=false 时 entityHeat 返回 null。
891
- heat: service.entityHeat?.(n.id) ?? null
892
- })),
893
- edges: [...edgeMap.values()].map((e) => ({
894
- id: e.id,
895
- from: e.from_entity,
896
- to: e.to_entity,
897
- relation_type: e.relation_type,
898
- memory_id: e.memory_id ?? null,
899
- created_at: e.created_at
900
- }))
901
- });
902
- } catch {
903
- sendJson(res, 500, { error: "internal" });
904
- }
905
- }
906
- });
907
-
908
- // --- entity attrs: current valid attrs for one entity (graph hover panel) ---
909
- // Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
910
- // graph panel's fallback list when the ego graph is too sparse to draw.
911
- register({
912
- kind: "exact",
913
- path: "/api/dsh-mneme/semantic/graph/entity-attrs",
914
- handler(req, res) {
915
- try {
916
- const url = new URL(req.url, "http://localhost");
917
- const name = (url.searchParams.get("entity") ?? "").trim();
918
- if (!name) {
919
- sendJson(res, 400, { error: "missing-entity" });
920
- return;
921
- }
922
- const entity = service.findEntityByName?.(name);
923
- if (!entity) {
924
- sendJson(res, 404, { error: "entity-not-found" });
925
- return;
926
- }
927
- const attrs = service.getCurrentAttrs?.(entity.id) ?? [];
928
- sendJson(res, 200, {
929
- entity: { id: entity.id, name: entity.name, type: entity.type ?? null, mention_count: entity.mention_count ?? 1 },
930
- attrs: Array.isArray(attrs)
931
- ? attrs.map((a) => ({
932
- key: a.attr_key,
933
- value: a.attr_value,
934
- confidence: a.confidence ?? null,
935
- valid_from: a.valid_from ?? null
936
- }))
937
- : []
938
- });
939
- } catch {
940
- sendJson(res, 500, { error: "internal" });
941
- }
942
- }
943
- });
944
-
945
- // --- entity directory (entity gene v0.3.0): read-only list for the UI ---
946
- // Flat entity list (newest-seen first). Attributes and relations stay on
947
- // their own per-entity endpoints, the directory only carries the columns
948
- // the rail needs: name, type, mention_count, first/last seen.
949
- register({
950
- kind: "exact",
951
- path: "/api/dsh-mneme/entities",
952
- handler(req, res) {
953
- try {
954
- const url = new URL(req.url, "http://localhost");
955
- const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
956
- const entities = service.listEntities?.({ limit }) ?? [];
957
- sendJson(res, 200, { entities, total: entities.length });
958
- } catch {
959
- sendJson(res, 500, { error: "internal" });
960
- }
961
- }
962
- });
963
-
964
- // --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
965
- // 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
966
- // 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
967
- // 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
968
- register({
969
- kind: "exact",
970
- path: "/api/dsh-mneme/memories/entities",
971
- handler(req, res) {
972
- try {
973
- const url = new URL(req.url, "http://localhost");
974
- const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
975
- if (!memoryId) {
976
- sendJson(res, 400, { error: "missing-memory-id" });
977
- return;
978
- }
979
- sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
980
- } catch {
981
- sendJson(res, 500, { error: "internal" });
982
- }
983
- }
984
- });
985
-
986
- // --- 导出(面板备份/迁移)---------------------------------------------------
987
- // 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC。
988
- // markdown:一个文档 = 文档级 frontmatter + 按类型分节。frontmatter 只放文档
989
- // 最前一份(分节不再各自带文件头,否则 9 个 `---` 块串在一个 .md 里,外部
990
- // frontmatter 解析器只认第一段、其余降级成正文),它的 coverage: all 就是一句
991
- // 看得见的声明:这份导出含 archived/forgotten(document 指针行除外——它两个
992
- // 落点都不进,见 mirror.js 的 MIRROR_EXCLUDED_TYPES)。条目块与磁盘镜像同构(共用
993
- // renderMirrorText 的条目渲染),所以导出文本能被 /import 原样吃回;「同构」
994
- // 止于条目——磁盘镜像只写活跃集,导出的行集更宽,两者的覆盖声明因此不同
995
- // (active-only vs all)。
996
- // 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
997
- // 不需要流式。
998
- register({
999
- kind: "exact",
1000
- path: "/api/dsh-mneme/export",
1001
- handler(req, res) {
1002
- try {
1003
- const url = new URL(req.url, "http://localhost");
1004
- const format = url.searchParams.get("format") ?? "json";
1005
- if (format !== "json" && format !== "markdown") {
1006
- sendJson(res, 400, { error: "invalid-format" });
1007
- return;
1008
- }
1009
- const rows = service.all?.() ?? [];
1010
- const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
1011
- if (format === "json") {
1012
- sendJson(res, 200, {
1013
- exported_at: new Date().toISOString(),
1014
- version: PACKAGE_VERSION,
1015
- count: rows.length,
1016
- memories: rows
1017
- }, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
1018
- return;
1019
- }
1020
- const byType = {};
1021
- for (const row of rows) {
1022
- // 没有 TYPE_FILE 条目的 type 不进 markdown 导出,只读 type(#296 的
1023
- // document 指针行)同样不进:导出/导入是 round-trip 通道,只读视图没有
1024
- // 可回填的正文。两个排除都在 mirror.js 那张表里(MIRROR_EXCLUDED_TYPES /
1025
- // MIRROR_READONLY_TYPES)。json 分支是全字段导出,不受影响。
1026
- if (TYPE_FILE[row.type] && !MIRROR_READONLY_TYPES.has(row.type)) (byType[row.type] ??= []).push(row);
1027
- }
1028
- const sections = [];
1029
- for (const type of Object.keys(TYPE_FILE)) {
1030
- if (byType[type]?.length) {
1031
- sections.push(renderMirrorText(type, byType[type], langOf(config), { fileHeader: false }));
1032
- }
1033
- }
1034
- const included = Object.values(byType).flat();
1035
- const docHeader = renderFileHeader("memory-export", included, "all");
1036
- sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, docHeader + sections.join("\n"));
1037
- } catch {
1038
- sendJson(res, 500, { error: "internal" });
1039
- }
1040
- }
1041
- });
1042
-
1043
- // --- 导入(Markdown 镜像回填)----------------------------------------------
1044
- // 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键,
1045
- // markdown 是与镜像文件同构的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
1046
- // 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
1047
- // 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
1048
- register({
1049
- kind: "exact",
1050
- path: "/api/dsh-mneme/import",
1051
- handler(req, res) {
1052
- try {
1053
- if (req.method !== "POST") {
1054
- sendJson(res, 404, { error: "not-found" });
1055
- return;
1056
- }
1057
- if (!requireAuth(req, res, apiToken)) return;
1058
- return readBody(req).then((text) => {
1059
- const body = parseBody(text);
1060
- if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
1061
- sendJson(res, 400, { error: "invalid-type" });
1062
- return;
1063
- }
1064
- // 只读 type 是合法 type,只是没有可回填的正文(#296):单独的码,调用方
1065
- // 不会被误导去改 type 名重试。
1066
- if (MIRROR_READONLY_TYPES.has(body.type)) {
1067
- sendJson(res, 400, { error: "readonly-type" });
1068
- return;
1069
- }
1070
- if (typeof body.markdown !== "string" || !body.markdown.trim()) {
1071
- sendJson(res, 400, { error: "invalid-markdown" });
1072
- return;
1073
- }
1074
- const edits = parseHumanEdits(body.markdown);
1075
- service.mergeHumanEdits(body.type, edits);
1076
- sendJson(res, 200, { merged: edits.length, type: body.type });
1077
- });
1078
- } catch {
1079
- sendJson(res, 500, { error: "internal" });
1080
- }
1081
- }
1082
- });
1083
-
1084
- // --- 巩固状态(dream 面板)--------------------------------------------------
1085
- // 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
1086
- // pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
1087
- // 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
1088
- // 只读通道(bookkeeping 语义,不触发写钩子)。
1089
- register({
1090
- kind: "exact",
1091
- path: "/api/dsh-mneme/dream-status",
1092
- handler(req, res) {
1093
- try {
1094
- const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
1095
- created_at: r.created_at,
1096
- status: r.status,
1097
- provider: r.provider ?? null,
1098
- model: r.model ?? null,
1099
- error: r.error ?? null,
1100
- run_type: r.run_type ?? "auto",
1101
- // sleep 审计:heat/时间分层降级决策计数(工作动态可展示"降级 N 条")。
1102
- // 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
1103
- demotion: r.decisions?.demotion
1104
- ? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
1105
- : null
1106
- }));
1107
- const pendingConflicts = service.countConflictPending?.() ?? 0;
1108
- const ids = new Set();
1109
- for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
1110
- if (ids.size >= 200) break;
1111
- if (row.memory_a) ids.add(row.memory_a);
1112
- if (ids.size >= 200) break;
1113
- if (row.memory_b) ids.add(row.memory_b);
1114
- }
1115
- sendJson(res, 200, {
1116
- lastRun: runs[0] ?? null,
1117
- runs,
1118
- pendingConflicts,
1119
- pendingMemoryIds: [...ids].slice(0, 200)
1120
- });
1121
- } catch {
1122
- sendJson(res, 500, { error: "internal" });
1123
- }
1124
- }
1125
- });
1126
-
1127
- // --- 冲突集中处理(v0.8.0):队列 + 人工确认 --------------------------------
1128
- // GET /conflicts:未解决冲突 + 双方当前内容(此前面板只有计数与徽章,
1129
- // resolveConflictPending 在存储层躺着没有任何调用方)。POST /conflicts/resolve:
1130
- // {id, winner: "a"|"b"|null, apply}——盖章审计,apply 时按 dream 非冻结
1131
- // conflict 路径同款处置(service.resolveConflictPending 内部复用
1132
- // applyDecisions:CAS + 事务 + 注记 + 幂等)。认证与其他写路由一致。
1133
- register({
1134
- kind: "exact",
1135
- path: "/api/dsh-mneme/conflicts",
1136
- handler(req, res) {
1137
- try {
1138
- if (req.method !== "GET") {
1139
- sendJson(res, 404, { error: "not-found" });
1140
- return;
1141
- }
1142
- if (!requireAuth(req, res, apiToken)) return;
1143
- const url = new URL(req.url, "http://localhost");
1144
- const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
1145
- const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
1146
- const items = service.listConflictQueue({ limit, offset });
1147
- sendJson(res, 200, { items, total: service.countConflictPending?.() ?? items.length });
1148
- } catch {
1149
- sendJson(res, 500, { error: "internal" });
1150
- }
1151
- }
1152
- });
1153
-
1154
- register({
1155
- kind: "exact",
1156
- path: "/api/dsh-mneme/conflicts/resolve",
1157
- handler(req, res) {
1158
- try {
1159
- if (req.method !== "POST") {
1160
- sendJson(res, 404, { error: "not-found" });
1161
- return;
1162
- }
1163
- if (!requireAuth(req, res, apiToken)) return;
1164
- return readBody(req).then((text) => {
1165
- let body = {};
1166
- try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
1167
- const id = typeof body.id === "string" ? body.id.trim() : "";
1168
- if (!id) {
1169
- sendJson(res, 400, { error: "missing-id" });
1170
- return;
1171
- }
1172
- const winner = body.winner === "a" || body.winner === "b" ? body.winner : null;
1173
- const apply = body.apply !== false;
1174
- const result = service.resolveConflictPending(id, { winner, apply });
1175
- if (!result) {
1176
- sendJson(res, 404, { error: "not-found" });
1177
- return;
1178
- }
1179
- sendJson(res, 200, { ok: true, conflict: result });
1180
- }).catch(() => sendJson(res, 500, { error: "internal" }));
1181
- } catch {
1182
- sendJson(res, 500, { error: "internal" });
1183
- }
1184
- }
1185
- });
1186
-
1187
- // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
1188
- // Auth-gated; only returns a sanitized error code (never raw last_error which
1189
- // may leak paths/token-like strings/internal hosts). On state read failure it
1190
- // reports unknown/degraded (fail-closed) instead of a false dirty=false.
1191
- register({
1192
- kind: "exact",
1193
- path: "/api/dsh-mneme/health",
1194
- handler(req, res) {
1195
- if (!requireAuth(req, res, apiToken)) return;
1196
- let state = null;
1197
- try {
1198
- state = service.getMirrorHealth?.() ?? null;
1199
- } catch {
1200
- // read failure is itself a health signal: do not report a false clean
1201
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
1202
- return;
1203
- }
1204
- if (!state) {
1205
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
1206
- return;
1207
- }
1208
- // Real read failure surfaces as dirty === null (peer blocker 5): report
1209
- // unknown explicitly instead of collapsing into a false "ok"/"degraded".
1210
- if (state.dirty === null) {
1211
- sendJson(res, 200, {
1212
- mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
1213
- });
1214
- return;
1215
- }
1216
- // Sanitized: boolean dirty + coarse status only; error string is mapped to
1217
- // a bounded code, never echoed verbatim.
1218
- let code = null;
1219
- if (state.last_error) {
1220
- const e = String(state.last_error);
1221
- code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
1222
- }
1223
- sendJson(res, 200, {
1224
- mirror: {
1225
- dirty: state.dirty === true,
1226
- status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
1227
- last_error: code,
1228
- last_attempt: state.last_attempt ?? null,
1229
- success_at: state.success_at ?? null
1230
- }
1231
- });
1232
- }
1233
- });
1234
-
1235
- // --- panel mode (v0.7.12): light / standard -------------------------------
1236
- // Persists the Web panel's feature preset into the settings kv
1237
- // ("panel_mode"). PUT requires auth like every other settings write; the
1238
- // value is validated against the enum. index.js applies the light preset on
1239
- // the next boot (persisted mode wins over the bundle config).
1240
- register({
1241
- kind: "exact",
1242
- path: "/api/dsh-mneme/mode",
1243
- handler(req, res) {
1244
- try {
1245
- if (req.method === "PUT" || req.method === "POST") {
1246
- if (!requireAuth(req, res, apiToken)) return;
1247
- return readBody(req).then((text) => {
1248
- const body = parseBody(text);
1249
- if (body.mode !== "light" && body.mode !== "standard") {
1250
- sendJson(res, 400, { error: "invalid-mode" });
1251
- return;
1252
- }
1253
- settings.setPanelMode(body.mode);
1254
- sendJson(res, 200, { mode: settings.getPanelMode() });
1255
- });
1256
- }
1257
- sendJson(res, 200, { mode: settings.getPanelMode() });
1258
- } catch {
1259
- sendJson(res, 500, { error: "internal" });
1260
- }
1261
- }
1262
- });
1263
-
1264
- // --- external API settings (the standalone server's panel-facing config) ---
1265
- register({
1266
- kind: "exact",
1267
- path: "/api/dsh-mneme/external-api",
1268
- handler(req, res) {
1269
- try {
1270
- if (req.method === "PUT" || req.method === "POST") {
1271
- if (!requireAuth(req, res, apiToken)) return;
1272
- return readBody(req).then((text) => {
1273
- const body = parseBody(text);
1274
- const patch = {};
1275
- if (body.enabled !== undefined) patch.enabled = body.enabled === true;
1276
- if (body.port !== undefined) {
1277
- const port = Number(body.port);
1278
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
1279
- sendJson(res, 400, { error: "invalid-port" });
1280
- return;
1281
- }
1282
- patch.port = port;
1283
- }
1284
- if (body.host !== undefined) {
1285
- const host = String(body.host).trim();
1286
- if (!host || host.includes("://")) {
1287
- sendJson(res, 400, { error: "invalid-host" });
1288
- return;
1289
- }
1290
- patch.host = host;
1291
- }
1292
- sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
1293
- });
1294
- }
1295
- // GET: always hand back a token — the standalone server generates its
1296
- // own on first enabled boot, but the panel displays it before that,
1297
- // so materialize and persist one here.
1298
- const kv = settings.getExternalApi() ?? {};
1299
- if (!kv.token) {
1300
- kv.token = randomBytes(24).toString("base64url");
1301
- settings.setExternalApi({ token: kv.token });
1302
- }
1303
- sendJson(res, 200, { config: fullExternalConfig(kv) });
1304
- } catch {
1305
- sendJson(res, 500, { error: "internal" });
1306
- }
1307
- }
1308
- });
1309
-
1310
- // --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
1311
- // 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
1312
- // 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
1313
- // 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
1314
- // 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
1315
- // 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
1316
- register({
1317
- kind: "exact",
1318
- path: "/api/dsh-mneme/features",
1319
- handler(req, res) {
1320
- try {
1321
- if (req.method === "PUT" || req.method === "POST") {
1322
- if (!requireAuth(req, res, apiToken)) return;
1323
- return readBody(req).then((text) => {
1324
- const body = parseBody(text);
1325
- if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
1326
- sendJson(res, 400, { error: "invalid-patch" });
1327
- return;
1328
- }
1329
- try {
1330
- settings.setFeatureFlags(body);
1331
- } catch (error) {
1332
- sendJson(res, 400, { error: error.message });
1333
- return;
1334
- }
1335
- sendJson(res, 200, featureSnapshot());
1336
- });
1337
- }
1338
- sendJson(res, 200, featureSnapshot());
1339
- } catch {
1340
- sendJson(res, 500, { error: "internal" });
1341
- }
1342
- }
1343
- });
1344
-
1345
- // 注入状态(#182,只读):面板状态页据此决定是否渲染「极简模式下注入按宿主
1346
- // 设计关闭」提示卡。suppressed=true 需同时满足:autoInject 生效值开着(否则
1347
- // 关注入是用户自己的选择,不是黑盒)、会话头 preset 为 minimal、且 preset 确
1348
- // 实观测到了(null=未知会话/宿主不支持,面板静默不猜)。preset 值按字符串
1349
- // 精确匹配 "minimal"——host bundle 的 preset id(#175 逐字证据),不做模糊。
1350
- register({
1351
- kind: "exact",
1352
- path: "/api/dsh-mneme/inject-status",
1353
- handler(req, res) {
1354
- try {
1355
- if (req.method !== "GET") {
1356
- sendJson(res, 404, { error: "not-found" });
1357
- return;
1358
- }
1359
- const effective = featureSnapshot().effective;
1360
- const autoInject = effective.autoInject === true;
1361
- const agentPreset = lastAgentPreset;
1362
- const suppressed = autoInject && agentPreset === "minimal";
1363
- sendJson(res, 200, { autoInject, agentPreset, suppressed });
1364
- } catch {
1365
- sendJson(res, 500, { error: "internal" });
1366
- }
1367
- }
1368
- });
1369
-
1370
- // 注入预览(#179,只读):最近一帧 systemPrompt 组装的构成与体积——快照由
1371
- // inject.js 在真实渲染路径旁路缓存(同一份 candidates,不二次检索)。null =
1372
- // 尚未发生过渲染(新宿主/新会话)或 autoInject 关闭(注入器未注册),面板对
1373
- // 两者都以「暂无预览」呈现,不区分也不猜。
1374
- register({
1375
- kind: "exact",
1376
- path: "/api/dsh-mneme/inject-preview",
1377
- handler(req, res) {
1378
- try {
1379
- if (req.method !== "GET") {
1380
- sendJson(res, 404, { error: "not-found" });
1381
- return;
1382
- }
1383
- sendJson(res, 200, { snapshot: getInjectionSnapshot() });
1384
- } catch {
1385
- sendJson(res, 500, { error: "internal" });
1386
- }
1387
- }
1388
- });
1389
-
1390
- // --- custom commands ---
1391
- register({
1392
- kind: "exact",
1393
- path: "/api/dsh-mneme/commands",
1394
- handler(req, res) {
1395
- try {
1396
- if (req.method === "POST") {
1397
- if (!requireAuth(req, res, apiToken)) return;
1398
- return readBody(req).then((text) => {
1399
- const body = parseBody(text);
1400
- try {
1401
- const command = commands.add({
1402
- name: body.name,
1403
- description: body.description,
1404
- instruction: body.instruction
1405
- });
1406
- sendJson(res, 200, { command });
1407
- } catch (error) {
1408
- sendJson(res, 400, { error: error.message });
1409
- }
1410
- });
1411
- }
1412
- if (req.method === "DELETE") {
1413
- if (!requireAuth(req, res, apiToken)) return;
1414
- const url = new URL(req.url, "http://localhost");
1415
- const id = url.searchParams.get("id");
1416
- const removed = id ? commands.remove(id) : false;
1417
- sendJson(res, 200, { removed });
1418
- return;
1419
- }
1420
- sendJson(res, 200, { commands: commands.list() });
1421
- } catch {
1422
- sendJson(res, 500, { error: "internal" });
1423
- }
1424
- }
1425
- });
1426
-
1427
- return {
1428
- routes: 32,
1429
- dispose: () => {
1430
- for (const dispose of disposers) dispose();
1431
- }
1432
- };
1433
- }
1
+ import { URL } from "node:url";
2
+ import { timingSafeEqual, randomBytes } from "node:crypto";
3
+ import { FEATURE_FLAG_SPEC } from "./settings.js";
4
+ import { TYPES } from "./store.js";
5
+ import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
6
+ import { langOf } from "./lang.js";
7
+ import { computeHeat } from "./heat.js";
8
+ import { describeStreamFailure, resolveRoute } from "./dream.js";
9
+ import { getInjectionSnapshot } from "./inject.js";
10
+ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
11
+ import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
12
+ import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
13
+ import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
14
+
15
+ // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
16
+ function sendJson(res, status, payload, headers = {}) {
17
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
18
+ res.end(JSON.stringify(payload));
19
+ }
20
+
21
+ // 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
22
+ function sendAttachment(res, status, contentType, filename, body) {
23
+ res.writeHead(status, {
24
+ "Content-Type": contentType,
25
+ "Content-Disposition": `attachment; filename="${filename}"`
26
+ });
27
+ res.end(body);
28
+ }
29
+
30
+ // 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
31
+ // 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
32
+
33
+ // Defaults for the standalone external API — keep in step with the schema
34
+ // defaults in config.js (externalApiPort / externalApiHost).
35
+ const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
36
+
37
+ /** Merge persisted external-api kv over the defaults for client display. */
38
+ function fullExternalConfig(kv = {}) {
39
+ return {
40
+ enabled: kv.enabled === true,
41
+ port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
42
+ host: kv.host || EXTERNAL_API_DEFAULTS.host,
43
+ token: kv.token || ""
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Mask an API key for client display: keep a recognizable prefix and suffix,
49
+ * hide the middle. Empty keys stay empty; short keys are fully hidden.
50
+ * The mask only exists in the API layer — storage keeps the real key.
51
+ */
52
+ function maskApiKey(key) {
53
+ if (!key) return "";
54
+ if (key.length <= 8) return "***";
55
+ return `${key.slice(0, 3)}***${key.slice(-4)}`;
56
+ }
57
+
58
+ /** True when the request carries the configured apiToken (or no token is set). */
59
+ function isAuthorized(req, apiToken) {
60
+ if (!apiToken) return true;
61
+ const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
62
+ const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
63
+ if (token === "" || token.length !== apiToken.length) return false;
64
+ // Constant-time comparison: avoid leaking the token via timing when the API
65
+ // is exposed beyond loopback.
66
+ return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
67
+ }
68
+
69
+ /**
70
+ * Reject a request with 401 when auth is enabled and the token is missing or
71
+ * wrong. Returns true when the request may proceed.
72
+ */
73
+ function requireAuth(req, res, apiToken) {
74
+ if (isAuthorized(req, apiToken)) return true;
75
+ sendJson(res, 401, { error: "unauthorized" });
76
+ return false;
77
+ }
78
+
79
+ /** Collect the request body as text (tolerant of empty/invalid bodies). */
80
+ function readBody(req) {
81
+ return new Promise((resolve) => {
82
+ let body = "";
83
+ req.on("data", (chunk) => { body += chunk; });
84
+ req.on("end", () => resolve(body));
85
+ req.on("error", () => resolve(""));
86
+ });
87
+ }
88
+
89
+ function parseBody(text) {
90
+ try {
91
+ return JSON.parse(text || "{}");
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
98
+ // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
99
+ // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
100
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) {
101
+ const disposers = [];
102
+
103
+ // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
104
+ // 知道当前会话的 agentPreset。注入回调在 minimal 下被宿主整体压制(#175 的
105
+ // 定论),不能作为检测源;session/event 是 preset 无关钩子(蒸馏同款),任
106
+ // 何事件都带完整 session 对象。只记最近一次会话头——这是面板展示用的「当前
107
+ // 会话」最佳近似,不是逐会话账本。读法与 scope.js 同源(session.header 冻结
108
+ // 的 SessionHeader,降级 requestHeader().config;agentPreset 取不到留 null,
109
+ // 面板对 null 静默)。任何失败静默:纯可观测性,不新增故障面。
110
+ let lastAgentPreset = null;
111
+ try {
112
+ const unsubscribe = ctx.on?.("session/event", (session) => {
113
+ try {
114
+ const header = session?.header ?? session?.requestHeader?.()?.config ?? null;
115
+ const preset = header?.agentPreset;
116
+ lastAgentPreset = typeof preset === "string" && preset.trim() ? preset.trim() : null;
117
+ } catch { /* 观测失败不影响会话流 */ }
118
+ });
119
+ if (typeof unsubscribe === "function") disposers.push(unsubscribe);
120
+ } catch { /* 宿主无 session/event 时面板提示退化为「未知」,不报错 */ }
121
+
122
+ // webServer 在 index.js 的 inject 声明中(cordis 等宿主服务就绪后 apply),
123
+ // 这里做防御性读取:cordis ctx 的 Proxy 不允许直接访问未 inject 的属性
124
+ // (会抛 "cannot get property without inject"),用 ctx.reflect.get 免
125
+ // inject 读取(未提供返回 undefined);对象字面量 mock ctx(测试)没有
126
+ // reflect,退回直接属性访问。createApi 只在有 webServer 时被调用(index.js
127
+ // 守卫 + 测试 mock),故下方 register 用之非空。
128
+ const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
129
+
130
+ // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
131
+ // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
132
+ // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
133
+ const flagKeys = [
134
+ ...FEATURE_FLAG_SPEC.booleans,
135
+ ...Object.keys(FEATURE_FLAG_SPEC.ints),
136
+ ...Object.keys(FEATURE_FLAG_SPEC.numbers),
137
+ ...FEATURE_FLAG_SPEC.strings,
138
+ ...FEATURE_FLAG_SPEC.urls,
139
+ ...Object.keys(FEATURE_FLAG_SPEC.enums)
140
+ ];
141
+ // 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
142
+ // 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
143
+ // 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
144
+ // effective 会静默少两个键(configFlagValue 取不到 undefined 就不写进去),
145
+ // 面板上看不出来、PUT 也存不进去——test/api.test.js 的计数锁就是钉这件事的。
146
+ const NESTED_FLAG_PATHS = {
147
+ "memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
148
+ "llmAudit.enabled": ["llmAudit", "enabled"],
149
+ "writeAdmission.enabled": ["writeAdmission", "enabled"],
150
+ "writeAdmission.enforce": ["writeAdmission", "enforce"],
151
+ // Issue #380:注入前判定。漏登记的后果是 effective 静默缺键、面板盲——
152
+ // 上面的 round-trip 测试专门锁这张表与白名单成对。
153
+ "preInjectGate.enabled": ["preInjectGate", "enabled"],
154
+ "preInjectGate.enforce": ["preInjectGate", "enforce"],
155
+ // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
156
+ // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
157
+ "distillRecords.enabled": ["distillRecords", "enabled"],
158
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
159
+ // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
160
+ // 的默认分支即可。
161
+ };
162
+ function configFlagValue(key) {
163
+ const path = NESTED_FLAG_PATHS[key];
164
+ if (path) return config?.[path[0]]?.[path[1]];
165
+ return config?.[key];
166
+ }
167
+ function featureSnapshot() {
168
+ const overrides = settings.getFeatureFlags();
169
+ const effective = {};
170
+ for (const key of flagKeys) {
171
+ if (overrides[key] !== undefined) effective[key] = overrides[key];
172
+ else {
173
+ const value = configFlagValue(key);
174
+ if (value !== undefined) effective[key] = value;
175
+ }
176
+ }
177
+ return { overrides, effective };
178
+ }
179
+
180
+ // Ensure the service has an embedder when the API layer was handed one
181
+ // (tests wire the embedder through the API instead of index.js). Without
182
+ // this, /api/dsh-mneme/search would silently degrade to keyword-only.
183
+ if (embedder && typeof service.setEmbedder === "function") {
184
+ service.setEmbedder(embedder);
185
+ }
186
+
187
+ const register = (route) => {
188
+ disposers.push(webServer.register(route));
189
+ };
190
+
191
+ // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
192
+ register({
193
+ kind: "prefix",
194
+ path: "/api/dsh-mneme",
195
+ handler(req, res) {
196
+ sendJson(res, 404, { error: "not-found" });
197
+ }
198
+ });
199
+
200
+ // 反馈入口预填用的插件版本(面板「帮助与反馈」卡片拉取)。读取失败(打包
201
+ // 环境)返回 "unknown",链接照常可用,纯展示信息不拦截。
202
+ register({
203
+ kind: "exact",
204
+ path: "/api/dsh-mneme/info",
205
+ handler(req, res) {
206
+ sendJson(res, 200, { version: PACKAGE_VERSION });
207
+ }
208
+ });
209
+
210
+ // 版本自检(#174 后续):运行版本 vs npm registry latest。只读,宿主围栏
211
+ // 统一鉴权(与 dream-status 同款,面板 apiFetch 直接可用)。registry 查询
212
+ // 全失败时 latest=null、status=unknown——前端对 unknown 完全静默,版本提示
213
+ // 是锦上添花,绝不因它新增故障面。
214
+ register({
215
+ kind: "exact",
216
+ path: "/api/dsh-mneme/version-check",
217
+ handler(req, res) {
218
+ if (req.method !== "GET") {
219
+ sendJson(res, 404, { error: "not-found" });
220
+ return;
221
+ }
222
+ fetchLatestVersion()
223
+ .then((latest) => sendJson(res, 200, {
224
+ version: PACKAGE_VERSION,
225
+ latest,
226
+ status: classify(PACKAGE_VERSION, latest),
227
+ checkedAt: new Date().toISOString()
228
+ }))
229
+ .catch(() => sendJson(res, 200, { version: PACKAGE_VERSION, latest: null, status: "unknown" }));
230
+ }
231
+ });
232
+
233
+ register({
234
+ kind: "exact",
235
+ path: "/api/dsh-mneme/list",
236
+ handler(req, res) {
237
+ try {
238
+ const url = new URL(req.url, "http://localhost");
239
+ const type = url.searchParams.get("type") ?? undefined;
240
+ const limit = Number(url.searchParams.get("limit") ?? 50);
241
+ const offset = Number(url.searchParams.get("offset") ?? 0);
242
+ // order=chrono: pure newest-first for paged browsing; default keeps
243
+ // the importance-ranked order other callers rely on.
244
+ const order = url.searchParams.get("order") ?? undefined;
245
+ // minImportance: numeric lower bound on importance (absent/NaN → no
246
+ // floor); source: exact match on the source column (empty → no filter).
247
+ const minRaw = url.searchParams.get("minImportance");
248
+ const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
249
+ ? Number(minRaw)
250
+ : undefined;
251
+ const source = url.searchParams.get("source") || undefined;
252
+ // updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
253
+ // 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
254
+ // 完成,非法值在那里被忽略——这里原样透传即可。
255
+ const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
256
+ const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
257
+ // archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
258
+ // 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
259
+ const onlyArchived = url.searchParams.get("archived") === "only";
260
+ // deposited=only:只看 autoDream 巩固过的记忆(receipt_chain 的
261
+ // merge/update verdict ∪ source=dream 直写)——状态页「查看全部」
262
+ // 与记忆库的「沉淀」筛选 chip 共用这个视图。
263
+ const depositedOnly = url.searchParams.get("deposited") === "only";
264
+ const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
265
+ // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
266
+ // schema 严格复用 toApiList,扩展只发生在 HTTP 层。
267
+ // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
268
+ // (heat.js 纯函数,λ=0 免疫类型恒 1.0);字段缺省时前端徽章自动隐藏。
269
+ const heatOn = config?.heatEnabled === true;
270
+ const items = service.toApiList(rows).map((m, i) => ({
271
+ ...m,
272
+ archived: rows[i].archived === true || rows[i].archived === 1,
273
+ quality_score: rows[i].quality_score ?? null,
274
+ ...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
275
+ }));
276
+ // Total honors the same filters as the rows, or the pager's
277
+ // has-more math breaks whenever minImportance/source/updated-at
278
+ // bounds are active.
279
+ sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) });
280
+ } catch {
281
+ sendJson(res, 500, { error: "internal" });
282
+ }
283
+ }
284
+ });
285
+
286
+ // 记忆复用统计(#217,只读聚合):Top-N 召回 + 僵尸率 + 覆盖度标注。窗口
287
+ // 参数钳制 1-365 天(缺省 30),聚合口径见 service.recallStats;注入命中
288
+ // 率待注入留痕口径拍板后接入。失败走既有 500 JSON 惯例,前端整卡不渲染。
289
+ register({
290
+ kind: "exact",
291
+ path: "/api/dsh-mneme/recall-stats",
292
+ handler(req, res) {
293
+ if (req.method !== "GET") {
294
+ sendJson(res, 404, { error: "not-found" });
295
+ return;
296
+ }
297
+ try {
298
+ const url = new URL(req.url, "http://localhost");
299
+ const raw = Number(url.searchParams.get("window"));
300
+ const windowDays = Number.isInteger(raw) && raw >= 1 && raw <= 365 ? raw : 30;
301
+ sendJson(res, 200, service.recallStats({ windowDays }));
302
+ } catch {
303
+ sendJson(res, 500, { error: "internal" });
304
+ }
305
+ }
306
+ });
307
+
308
+ register({
309
+ kind: "exact",
310
+ path: "/api/dsh-mneme/search",
311
+ handler(req, res) {
312
+ try {
313
+ const url = new URL(req.url, "http://localhost");
314
+ const q = url.searchParams.get("q") ?? "";
315
+ const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
316
+ // mode selects the recall strategy (defaults to auto):
317
+ // auto (default) keyword first, vector fills remaining slots
318
+ // hybrid vector first, keyword fills remaining slots; scores of
319
+ // memories hit by both sides are weight-blended
320
+ // vector vector only, falls back to keyword when the vector path
321
+ // is unavailable (no embedder or a throwing one)
322
+ // keyword literal text only; never queries the embedder
323
+ // rerank=false disables the cross-encoder reorder for this request;
324
+ // the response `mode` field reports which path actually produced rows.
325
+ const mode = url.searchParams.get("mode") ?? "auto";
326
+ const rerank = url.searchParams.get("rerank") !== "false";
327
+ const query = q.trim();
328
+ if (!query) {
329
+ sendJson(res, 200, { items: [], mode: "keyword" });
330
+ return;
331
+ }
332
+ // Route through the unified semantic pipeline; any vector/rerank
333
+ // failure degrades to keyword results inside searchMemories. The
334
+ // returned promise lets the test double await the async search.
335
+ return Promise.resolve(
336
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
337
+ ).then((rows) => {
338
+ // mode reflects what actually happened: rows marked `vector` came
339
+ // through the semantic path, everything else is keyword fallback.
340
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
341
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
342
+ }).catch(() => {
343
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
344
+ });
345
+ } catch {
346
+ sendJson(res, 500, { error: "internal" });
347
+ }
348
+ }
349
+ });
350
+
351
+ // --- user profile ---
352
+ register({
353
+ kind: "exact",
354
+ path: "/api/dsh-mneme/profile",
355
+ handler(req, res) {
356
+ try {
357
+ if (req.method === "PUT" || req.method === "POST") {
358
+ if (!requireAuth(req, res, apiToken)) return;
359
+ return readBody(req).then((text) => {
360
+ const body = parseBody(text);
361
+ settings.setProfile(typeof body.profile === "string" ? body.profile : "");
362
+ sendJson(res, 200, { profile: settings.getProfile() });
363
+ });
364
+ }
365
+ sendJson(res, 200, { profile: settings.getProfile() });
366
+ } catch {
367
+ sendJson(res, 500, { error: "internal" });
368
+ }
369
+ }
370
+ });
371
+
372
+ // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
373
+ // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
374
+ // through the same harness path the consolidation uses and reports success /
375
+ // failure + latency + error. Reuses describeStreamFailure so the panel shows
376
+ // the same error wording the audit rows carry. Optional reasoningEffort
377
+ // mirrors what the consolidation would send — the panel can verify a model
378
+ // actually accepts the configured effort without tripping a full run.
379
+ async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
380
+ const startedAt = Date.now();
381
+ const elapsed = () => Date.now() - startedAt;
382
+ const modelId = `${provider}:${model}`;
383
+ try {
384
+ let reply = "";
385
+ let error = "";
386
+ for await (const chunk of llm.stream({
387
+ provider,
388
+ model,
389
+ purpose: "dsh-mneme-connectivity-test",
390
+ // 给思考模型留出「思考完还有正文」的空间:16 会被 reasoning 全部吃掉,
391
+ // reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
392
+ // 按钮无放大风险。
393
+ maxTokens: 1024,
394
+ ...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
395
+ // role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
396
+ // 抽成顶层参数,system-only 对话会被官方 API 以
397
+ // 「messages: at least one message is required」整单拒绝——探测按钮 502。
398
+ // user 消息在任何适配器下都是一条合法对话,探测从此不怕 host 演进。
399
+ messages: [
400
+ {
401
+ role: "user",
402
+ content: [{ type: "text", text: "Reply with exactly one word: ok" }],
403
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
404
+ }
405
+ ]
406
+ })) {
407
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
408
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
409
+ error = describeStreamFailure(chunk.reason);
410
+ break;
411
+ }
412
+ }
413
+ if (error) return { ok: false, durationMs: elapsed(), error, modelId };
414
+ return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
415
+ } catch (error) {
416
+ return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
417
+ }
418
+ }
419
+
420
+ // List every provider + model the host has registered, so the panel can offer
421
+ // a dropdown over REAL models instead of free-text guesses. Read-only, no auth
422
+ // (same as list/search). Best-effort per provider: one whose model list can't
423
+ // be resolved yields an empty models array instead of failing the whole call.
424
+ register({
425
+ kind: "exact",
426
+ path: "/api/dsh-mneme/llm-providers",
427
+ handler(req, res) {
428
+ try {
429
+ const llm = ctx.llm;
430
+ if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
431
+ return sendJson(res, 501, { error: "llm-unavailable" });
432
+ }
433
+ let providers;
434
+ try { providers = llm.listProviders() ?? []; } catch { providers = []; }
435
+ return Promise.all(providers.map(async (p) => {
436
+ let models = [];
437
+ try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
438
+ return {
439
+ provider: p.id,
440
+ models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
441
+ };
442
+ })).then((rows) => sendJson(res, 200, { providers: rows }));
443
+ } catch {
444
+ return sendJson(res, 500, { error: "internal" });
445
+ }
446
+ }
447
+ });
448
+
449
+ // Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
450
+ // reports ok / failure + latency + error. Auth-gated: it spends the user's
451
+ // API quota, so a stray local page must not be able to drain it via loopback.
452
+ register({
453
+ kind: "exact",
454
+ path: "/api/dsh-mneme/test-model",
455
+ handler(req, res) {
456
+ try {
457
+ if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
458
+ if (!requireAuth(req, res, apiToken)) return;
459
+ return readBody(req).then(async (text) => {
460
+ const body = parseBody(text);
461
+ let provider = typeof body.provider === "string" ? body.provider.trim() : "";
462
+ let model = typeof body.model === "string" ? body.model.trim() : "";
463
+ if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
464
+ if (!provider && !model) {
465
+ // 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
466
+ // 让面板一键测"当前巩固模型"而无需手填。
467
+ const route = resolveRoute(ctx, config ?? {}, ctx.logger);
468
+ if (!route) return sendJson(res, 400, { error: "no-route" });
469
+ provider = route.provider;
470
+ model = route.model;
471
+ }
472
+ const llm = ctx.llm;
473
+ if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
474
+ const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
475
+ return sendJson(res, result.ok ? 200 : 502, result);
476
+ });
477
+ } catch {
478
+ return sendJson(res, 500, { error: "internal" });
479
+ }
480
+ }
481
+ });
482
+
483
+ // --- delete one memory by id ---
484
+ // Mutation route: apiToken-gated like the profile/rules writes above. POST
485
+ // body is JSON { id }. store.remove deletes silently, so existence is checked
486
+ // up front to give clients a distinguishable 404 instead of a fake success.
487
+ register({
488
+ kind: "exact",
489
+ path: "/api/dsh-mneme/delete",
490
+ handler(req, res) {
491
+ try {
492
+ if (req.method !== "POST") {
493
+ sendJson(res, 404, { error: "not-found" });
494
+ return;
495
+ }
496
+ if (!requireAuth(req, res, apiToken)) return;
497
+ return readBody(req).then((text) => {
498
+ const body = parseBody(text);
499
+ const id = typeof body.id === "string" ? body.id.trim() : "";
500
+ if (!id) {
501
+ sendJson(res, 400, { error: "missing-id" });
502
+ return;
503
+ }
504
+ if (!service.getById(id)) {
505
+ sendJson(res, 404, { error: "not-found" });
506
+ return;
507
+ }
508
+ service.remove(id);
509
+ sendJson(res, 200, { ok: true });
510
+ });
511
+ } catch {
512
+ sendJson(res, 500, { error: "internal" });
513
+ }
514
+ }
515
+ });
516
+
517
+ // --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
518
+ // POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
519
+ // 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
520
+ // ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
521
+ // updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
522
+ // 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
523
+ // archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
524
+ register({
525
+ kind: "exact",
526
+ path: "/api/dsh-mneme/update",
527
+ handler(req, res) {
528
+ try {
529
+ if (req.method !== "POST") {
530
+ sendJson(res, 404, { error: "not-found" });
531
+ return;
532
+ }
533
+ if (!requireAuth(req, res, apiToken)) return;
534
+ return readBody(req).then((text) => {
535
+ const body = parseBody(text);
536
+ const id = typeof body.id === "string" ? body.id.trim() : "";
537
+ if (!id) {
538
+ sendJson(res, 400, { error: "missing-id" });
539
+ return;
540
+ }
541
+ const patch = {};
542
+ if (body.title !== undefined) {
543
+ if (typeof body.title !== "string" || !body.title.trim()) {
544
+ sendJson(res, 400, { error: "invalid-title" });
545
+ return;
546
+ }
547
+ patch.title = body.title.trim();
548
+ }
549
+ if (body.content !== undefined) {
550
+ if (typeof body.content !== "string" || !body.content.trim()) {
551
+ sendJson(res, 400, { error: "invalid-content" });
552
+ return;
553
+ }
554
+ patch.content = body.content.trim();
555
+ }
556
+ if (body.importance !== undefined) {
557
+ if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
558
+ sendJson(res, 400, { error: "invalid-importance" });
559
+ return;
560
+ }
561
+ patch.importance = body.importance;
562
+ }
563
+ if (body.tags !== undefined) {
564
+ if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
565
+ sendJson(res, 400, { error: "invalid-tags" });
566
+ return;
567
+ }
568
+ patch.tags = body.tags;
569
+ }
570
+ // v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
571
+ // 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
572
+ // 审计行)在 service.updateMemory 内做,路由只做形状校验。
573
+ if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
574
+ sendJson(res, 400, { error: "invalid-agent-scope" });
575
+ return;
576
+ }
577
+ if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
578
+ sendJson(res, 400, { error: "invalid-workspace-scope" });
579
+ return;
580
+ }
581
+ if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
582
+ if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
583
+ if (body.archived !== undefined && typeof body.archived !== "boolean") {
584
+ sendJson(res, 400, { error: "invalid-archived" });
585
+ return;
586
+ }
587
+ if (Object.keys(patch).length === 0 && body.archived === undefined) {
588
+ sendJson(res, 400, { error: "no-fields" });
589
+ return;
590
+ }
591
+ const existing = service.getById(id);
592
+ if (!existing) {
593
+ sendJson(res, 404, { error: "not-found" });
594
+ return;
595
+ }
596
+ // 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
597
+ if (patch.content !== undefined && patch.content !== existing.content) {
598
+ const history = Array.isArray(existing.content_history) ? existing.content_history : [];
599
+ patch.content_history = [
600
+ { content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
601
+ ...history
602
+ ].slice(0, 20);
603
+ }
604
+ if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
605
+ if (body.archived !== undefined) service.setArchived(id, body.archived);
606
+ sendJson(res, 200, { memory: service.getById(id) });
607
+ });
608
+ } catch {
609
+ sendJson(res, 500, { error: "internal" });
610
+ }
611
+ }
612
+ });
613
+
614
+ // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)---------------
615
+ // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只
616
+ // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/
617
+ // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope,
618
+ // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。
619
+ //
620
+ // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏):
621
+ // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版:
622
+ // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用,
623
+ // 放宽是为了兼容「先建壳再补内容」的既有客户端。
624
+ // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe
625
+ // 会 throw,这里提前给干净错误码而不是让它落成 500。
626
+ // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽
627
+ // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit
628
+ // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」
629
+ // 不同,因为这里根本没有该字段的语义)。
630
+ register({
631
+ kind: "exact",
632
+ path: "/api/dsh-mneme/create",
633
+ handler(req, res) {
634
+ try {
635
+ if (req.method !== "POST") {
636
+ sendJson(res, 404, { error: "not-found" });
637
+ return;
638
+ }
639
+ if (!requireAuth(req, res, apiToken)) return;
640
+ return readBody(req).then((text) => {
641
+ const body = parseBody(text);
642
+ if (!TYPES.has(body.type)) {
643
+ sendJson(res, 400, { error: "invalid-type" });
644
+ return;
645
+ }
646
+ if (body.type === "document") {
647
+ sendJson(res, 400, { error: "document-requires-register" });
648
+ return;
649
+ }
650
+ if (typeof body.title !== "string" || !body.title.trim()) {
651
+ sendJson(res, 400, { error: "invalid-title" });
652
+ return;
653
+ }
654
+ if (typeof body.content !== "string" || !body.content.trim()) {
655
+ sendJson(res, 400, { error: "invalid-content" });
656
+ return;
657
+ }
658
+ if (body.importance !== undefined
659
+ && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) {
660
+ sendJson(res, 400, { error: "invalid-importance" });
661
+ return;
662
+ }
663
+ if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) {
664
+ sendJson(res, 400, { error: "invalid-tags" });
665
+ return;
666
+ }
667
+ try {
668
+ const result = service.saveWithDedupe({
669
+ type: body.type,
670
+ title: body.title.trim(),
671
+ content: body.content.trim(),
672
+ importance: body.importance,
673
+ tags: body.tags ?? [],
674
+ // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。
675
+ source: "panel"
676
+ });
677
+ // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板
678
+ // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来:
679
+ // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把
680
+ // 「被拒」伪装成「服务器出错」——两者的处置完全不同。
681
+ if (result.action === "denied") {
682
+ sendJson(res, 422, { error: "write-rejected", reason: result.reason });
683
+ return;
684
+ }
685
+ // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」
686
+ // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。
687
+ sendJson(res, result.action === "created" ? 201 : 200, {
688
+ ...service.toApiList([result.memory])[0],
689
+ action: result.action
690
+ });
691
+ } catch (error) {
692
+ ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`);
693
+ sendJson(res, 500, { error: "internal" });
694
+ }
695
+ });
696
+ } catch {
697
+ sendJson(res, 500, { error: "internal" });
698
+ }
699
+ }
700
+ });
701
+
702
+ // --- maintenance/reclaim(#386 第 4 条:面板有了回收入口)------------------
703
+ // 与 standalone 数据面的 POST /maintenance/reclaim 是同一能力的两张皮:口径(错误码
704
+ // 字符串、默认值、报告形状)只写在 src/reclaim-http.js 里,这里与数据面都调它。
705
+ // 差别只有鉴权与路径——两张皮的其余部分必须没有第二份实现(#411 的教训:以前是整段
706
+ // 复制,于是 #410 修好这边之后那边还停在旧形态)。
707
+ //
708
+ // 为什么 GET 也能跑:dry-run 是只读的(src/maintenance.js 里 dry-run 与执行同一条
709
+ // 代码路径,只是不落 UPDATE/VACUUM),面板 mount 时拿它渲染「现在能回收多少」。
710
+ //
711
+ // 为什么两法都要 requireAuth:这是不可逆内容丢弃的入口,不能有例外。
712
+ register({
713
+ kind: "exact",
714
+ path: "/api/dsh-mneme/maintenance/reclaim",
715
+ handler(req, res) {
716
+ try {
717
+ if (req.method === "GET") {
718
+ if (!requireAuth(req, res, apiToken)) return;
719
+ if (!maintenance) {
720
+ sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
721
+ return;
722
+ }
723
+ const parsed = parseReclaimQuery(new URL(req.url, "http://localhost").searchParams.get("olderThanDays"));
724
+ if (!parsed.ok) {
725
+ sendJson(res, 400, { error: parsed.error });
726
+ return;
727
+ }
728
+ const { status, body } = reclaimReply({
729
+ maintenance,
730
+ dryRun: true,
731
+ olderThanDays: parsed.olderThanDays,
732
+ warn: (msg) => ctx.logger?.warn?.(msg)
733
+ });
734
+ sendJson(res, status, body);
735
+ return;
736
+ }
737
+ if (req.method !== "POST") {
738
+ sendJson(res, 404, { error: "not-found" });
739
+ return;
740
+ }
741
+ if (!requireAuth(req, res, apiToken)) return;
742
+ return readBody(req).then((text) => {
743
+ const parsed = parseReclaimJson(text);
744
+ if (!parsed.ok) {
745
+ sendJson(res, 400, { error: parsed.error });
746
+ return;
747
+ }
748
+ if (!maintenance) {
749
+ sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
750
+ return;
751
+ }
752
+ const fields = readReclaimFields(parsed.body);
753
+ if (!fields.ok) {
754
+ sendJson(res, 400, { error: fields.error });
755
+ return;
756
+ }
757
+ const { status, body } = reclaimReply({
758
+ maintenance,
759
+ dryRun: fields.dryRun,
760
+ olderThanDays: fields.olderThanDays,
761
+ vacuum: fields.vacuum,
762
+ confirm: fields.confirm,
763
+ warn: (msg) => ctx.logger?.warn?.(msg)
764
+ });
765
+ sendJson(res, status, body);
766
+ });
767
+ } catch {
768
+ sendJson(res, 500, { error: "internal" });
769
+ }
770
+ }
771
+ });
772
+
773
+ // --- rules ---
774
+ register({
775
+ kind: "exact",
776
+ path: "/api/dsh-mneme/rules",
777
+ handler(req, res) {
778
+ try {
779
+ if (req.method === "PUT" || req.method === "POST") {
780
+ if (!requireAuth(req, res, apiToken)) return;
781
+ return readBody(req).then((text) => {
782
+ const body = parseBody(text);
783
+ settings.setRules(Array.isArray(body.rules) ? body.rules : []);
784
+ sendJson(res, 200, { rules: settings.getRules() });
785
+ });
786
+ }
787
+ sendJson(res, 200, { rules: settings.getRules() });
788
+ } catch {
789
+ sendJson(res, 500, { error: "internal" });
790
+ }
791
+ }
792
+ });
793
+
794
+ // --- vector search config ---
795
+ register({
796
+ kind: "exact",
797
+ path: "/api/dsh-mneme/vector-config",
798
+ handler(req, res) {
799
+ try {
800
+ // Secret-bearing endpoint: fully protected when apiToken is set.
801
+ if (!requireAuth(req, res, apiToken)) return;
802
+ if (req.method === "PUT" || req.method === "POST") {
803
+ return readBody(req).then((text) => {
804
+ const body = parseBody(text);
805
+ // An empty apiKey, or one that already looks masked (round-trips
806
+ // through maskApiKey unchanged), means "keep the existing key".
807
+ // Only a fresh, unmasked key is treated as a real replacement.
808
+ const prev = settings.getVectorConfig();
809
+ const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
810
+ const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
811
+ const key = isMaskedOrEmpty
812
+ ? (prev?.apiKey ?? "")
813
+ : incoming;
814
+ const cfg = settings.setVectorConfig({
815
+ enabled: body.enabled,
816
+ baseUrl: body.baseUrl,
817
+ apiKey: key,
818
+ model: body.model
819
+ });
820
+ sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
821
+ });
822
+ }
823
+ const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
824
+ sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
825
+ } catch {
826
+ sendJson(res, 500, { error: "internal" });
827
+ }
828
+ }
829
+ });
830
+
831
+ // --- vector re-index (backfill embeddings for rows missing them) ---
832
+ register({
833
+ kind: "exact",
834
+ path: "/api/dsh-mneme/vector-reindex",
835
+ handler(req, res) {
836
+ try {
837
+ if (!requireAuth(req, res, apiToken)) return;
838
+ if (!embedder) {
839
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
840
+ return;
841
+ }
842
+ const url = new URL(req.url, "http://localhost");
843
+ const limit = Number(url.searchParams.get("limit") ?? 100);
844
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
845
+ // the new local/ollama backends (which have no reindexMissing method).
846
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
847
+ const task = viaIndex
848
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
849
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
850
+ // Return the chain so awaiting callers (tests/health checks) observe the
851
+ // finished response rather than racing the async backfill.
852
+ return task.then((result) => {
853
+ sendJson(res, 200, result);
854
+ }).catch(() => {
855
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
856
+ });
857
+ } catch {
858
+ sendJson(res, 500, { error: "internal" });
859
+ }
860
+ }
861
+ });
862
+
863
+ // --- semantic pipeline status (model, index, reranker) ---
864
+ register({
865
+ kind: "exact",
866
+ path: "/api/dsh-mneme/semantic",
867
+ handler(req, res) {
868
+ try {
869
+ const stats = semantic?.vectorIndex?.getStats?.() ?? null;
870
+ // #118: expose readiness so the status card can tell "initializing /
871
+ // unreachable" from "disabled". An embedder carrying no `ready` prop at
872
+ // all (test doubles, third-party adapters) is assumed usable.
873
+ // issue #135: 但「可用」和「配了」是两件事 —— legacy OpenAI embedder
874
+ // 此前恒报 ready,于是 baseUrl/apiKey/model 全空时状态卡依然是绿的
875
+ // ("绿色假阳性")。下面把两者分开,并附上索引覆盖度。
876
+ const ready = embedder ? ("ready" in embedder ? embedder.ready === true : true) : null;
877
+ // local/ollama 的配置来自 settings 兜底,没有「未配置」态:它们的
878
+ // ready=false 表示「还在初始化」,不是「没配」。
879
+ const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
880
+ const embedded = Number(stats?.embeddedCount ?? 0);
881
+ const total = Number(stats?.totalCount ?? 0);
882
+ sendJson(res, 200, {
883
+ ready,
884
+ configured,
885
+ // 机器可读的原因;null = 一切正常。面板按它选文案,不用猜布尔组合。
886
+ reason: !embedder ? "no-embedder"
887
+ : configured === false ? "not-configured"
888
+ : ready !== true ? "initializing"
889
+ : null,
890
+ // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
891
+ coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
892
+ // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
893
+ // 处于 ready:true + embeddedCount:0,面板却显示正常)。
894
+ degraded: configured === true && total > 0 && embedded === 0,
895
+ // Explicit display name first: the legacy OpenAI-compatible embedder
896
+ // is an object literal, so constructor.name would be "Object".
897
+ embedProvider: embedder ? (embedder.name ?? embedder.constructor?.name ?? "unknown") : null,
898
+ modelHash: embedder?.modelHash ?? null,
899
+ dimension: embedder?.dimension ?? null,
900
+ reranker: semantic?.reranker ? "ready" : null,
901
+ index: stats,
902
+ // issue #131 / PR-A:自管运行时的状态。无论当前 provider 是不是 local
903
+ // 都报 —— 面板需要区分「已收编但没启用」和「要用本地却没收编」。
904
+ // 这里只做结构检查(不跑推理),失败降级成状态对象,不会让本端点 500。
905
+ // 必须经 publicRuntimeStatus 投影:本端点是免鉴权的,绝对路径与原始错误
906
+ // 文本不能出去(CWE-200)。完整诊断走本机 CLI 的 status。
907
+ localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir: config?.runtimeDir ?? "" }))
908
+ });
909
+ } catch {
910
+ sendJson(res, 500, { error: "internal" });
911
+ }
912
+ }
913
+ });
914
+
915
+ // --- 一键取回自管运行时(issue #131 / PR-C:三档来源)-------------------------
916
+ // 写路径(requireAuth):它会往 ~/.dsh/mneme/runtime/ 落一份依赖闭包。
917
+ //
918
+ // 第一档源固定取本 profile 的 node_modules(由插件自身路径推出),不接受客户端传源目录 ——
919
+ // 「从任意目录往运行时目录里搬东西」没有必要成为对外能力。
920
+ //
921
+ // 一律回 200:请求本身合法,成不成看 body 里的 ok/status/reason。让 HTTP 码只表达
922
+ // 「有没有鉴权/路径对不对」,结论交给 ok —— 面板要能显示「收编了但闭包缺件」这种
923
+ // 中间状态,用一个 4xx 概括会把信息压掉。
924
+ register({
925
+ kind: "exact",
926
+ path: "/api/dsh-mneme/runtime/provision",
927
+ handler(req, res) {
928
+ try {
929
+ if (req.method !== "POST") {
930
+ sendJson(res, 405, { error: "method-not-allowed" });
931
+ return;
932
+ }
933
+ if (!requireAuth(req, res, apiToken)) return;
934
+ return readBody(req).then(async (text) => {
935
+ const body = parseBody(text);
936
+ const runtimeDir = config?.runtimeDir ?? "";
937
+ // 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
938
+ // 所以这个端点会**联网**:它可能拉一份几十到上百 MB 的闭包。
939
+ const result = await provisionRuntime({
940
+ hostModulesDir: hostModulesDir(import.meta.url),
941
+ runtimeDir,
942
+ localTarballDir: config?.runtimeTarballDir ?? "",
943
+ mirror: config?.runtimeMirror ?? "",
944
+ overwrite: body?.overwrite === true
945
+ });
946
+ // 顺手带回最新状态,面板不必再打一次 /semantic(且它走的是免鉴权投影)。
947
+ sendJson(res, 200, {
948
+ ...result,
949
+ localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir }))
950
+ });
951
+ });
952
+ } catch {
953
+ sendJson(res, 500, { error: "internal" });
954
+ }
955
+ }
956
+ });
957
+
958
+ // --- LLM audit trail (Bug8): paginated read + aggregate stats ---
959
+ // Read-only endpoints, so like list/search/semantic they stay open even when
960
+ // apiToken is set. The stats aggregate budget by source over the last N days.
961
+ register({
962
+ kind: "exact",
963
+ path: "/api/dsh-mneme/semantic/llm-audit",
964
+ handler(req, res) {
965
+ try {
966
+ const url = new URL(req.url, "http://localhost");
967
+ const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
968
+ const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
969
+ const source = url.searchParams.get("source") ?? undefined;
970
+ // 写入准入(#254)的 session_key 是内部计数键——session_id 不是对外的归属
971
+ // 标识(见 scope.js 文件头),而本端点按注释在 apiToken 之下也保持开放,所以
972
+ // 不回传它。按会话离线聚合直接读库。
973
+ const items = (service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [])
974
+ .map((row) => (row.session_key === undefined ? row : { ...row, session_key: undefined }));
975
+ const total = service.countLlmAudits?.({ source }) ?? items.length;
976
+ sendJson(res, 200, { items, total, page, pageSize });
977
+ } catch {
978
+ sendJson(res, 500, { error: "internal" });
979
+ }
980
+ }
981
+ });
982
+
983
+ register({
984
+ kind: "exact",
985
+ path: "/api/dsh-mneme/semantic/llm-audit/stats",
986
+ handler(req, res) {
987
+ try {
988
+ const url = new URL(req.url, "http://localhost");
989
+ const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 7) || 7));
990
+ const stats = service.getLlmAuditStats?.({ days }) ?? null;
991
+ sendJson(res, 200, stats ?? { error: "unavailable" });
992
+ } catch {
993
+ sendJson(res, 500, { error: "internal" });
994
+ }
995
+ }
996
+ });
997
+
998
+ // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
999
+ // Read-only like list/search/semantic, so it stays open when apiToken is set.
1000
+ // BFS from the root entity over entity_relations (both directions; the
1001
+ // idx_relations_from/to indexes keep a 2-hop walk in the tens of ms even
1002
+ // for a few thousand nodes). `distance` on each node is the hop count from
1003
+ // the root so the UI can shade the frontier. The API is graph-traversal
1004
+ // only — nodes carry no attr payload; hover summaries come from
1005
+ // /semantic/graph/entity-attrs.
1006
+ register({
1007
+ kind: "exact",
1008
+ path: "/api/dsh-mneme/semantic/graph/ego",
1009
+ handler(req, res) {
1010
+ try {
1011
+ const url = new URL(req.url, "http://localhost");
1012
+ const name = (url.searchParams.get("entity") ?? "").trim();
1013
+ if (!name) {
1014
+ sendJson(res, 400, { error: "missing-entity" });
1015
+ return;
1016
+ }
1017
+ const root = service.findEntityByName?.(name);
1018
+ if (!root) {
1019
+ sendJson(res, 404, { error: "entity-not-found" });
1020
+ return;
1021
+ }
1022
+ const depth = Math.max(1, Math.min(2, Number(url.searchParams.get("depth") ?? 1) || 1));
1023
+ const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 40) || 40));
1024
+
1025
+ const nodes = new Map([[root.id, { ...root, distance: 0 }]]);
1026
+ let frontier = [root.id];
1027
+ for (let d = 1; d <= depth && nodes.size < limit; d++) {
1028
+ const next = [];
1029
+ for (const id of frontier) {
1030
+ for (const rel of service.getRelations?.(id) ?? []) {
1031
+ const other = rel.from_entity === id ? rel.to_entity : rel.from_entity;
1032
+ if (nodes.has(other) || nodes.size >= limit) continue;
1033
+ const entity = service.findEntityById?.(other);
1034
+ if (!entity) continue;
1035
+ nodes.set(other, { ...entity, distance: d });
1036
+ next.push(other);
1037
+ }
1038
+ }
1039
+ frontier = next;
1040
+ }
1041
+
1042
+ // Collect every relation whose endpoints both survived the limit cut;
1043
+ // each edge is visited twice (once per endpoint) so dedupe by id.
1044
+ const edgeMap = new Map();
1045
+ for (const id of nodes.keys()) {
1046
+ for (const rel of service.getRelations?.(id) ?? []) {
1047
+ if (nodes.has(rel.from_entity) && nodes.has(rel.to_entity)) {
1048
+ edgeMap.set(rel.id, rel);
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ sendJson(res, 200, {
1054
+ root: { id: root.id, name: root.name, type: root.type ?? null, mention_count: root.mention_count ?? 1 },
1055
+ nodes: [...nodes.values()].map((n) => ({
1056
+ id: n.id,
1057
+ name: n.name,
1058
+ type: n.type ?? null,
1059
+ mention_count: n.mention_count ?? 1,
1060
+ distance: n.distance,
1061
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(max),前端据此
1062
+ // 缩放节点大小/明暗。heatEnabled=false 时 entityHeat 返回 null。
1063
+ heat: service.entityHeat?.(n.id) ?? null
1064
+ })),
1065
+ edges: [...edgeMap.values()].map((e) => ({
1066
+ id: e.id,
1067
+ from: e.from_entity,
1068
+ to: e.to_entity,
1069
+ relation_type: e.relation_type,
1070
+ memory_id: e.memory_id ?? null,
1071
+ created_at: e.created_at
1072
+ }))
1073
+ });
1074
+ } catch {
1075
+ sendJson(res, 500, { error: "internal" });
1076
+ }
1077
+ }
1078
+ });
1079
+
1080
+ // --- entity attrs: current valid attrs for one entity (graph hover panel) ---
1081
+ // Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
1082
+ // graph panel's fallback list when the ego graph is too sparse to draw.
1083
+ register({
1084
+ kind: "exact",
1085
+ path: "/api/dsh-mneme/semantic/graph/entity-attrs",
1086
+ handler(req, res) {
1087
+ try {
1088
+ const url = new URL(req.url, "http://localhost");
1089
+ const name = (url.searchParams.get("entity") ?? "").trim();
1090
+ if (!name) {
1091
+ sendJson(res, 400, { error: "missing-entity" });
1092
+ return;
1093
+ }
1094
+ const entity = service.findEntityByName?.(name);
1095
+ if (!entity) {
1096
+ sendJson(res, 404, { error: "entity-not-found" });
1097
+ return;
1098
+ }
1099
+ const attrs = service.getCurrentAttrs?.(entity.id) ?? [];
1100
+ sendJson(res, 200, {
1101
+ entity: { id: entity.id, name: entity.name, type: entity.type ?? null, mention_count: entity.mention_count ?? 1 },
1102
+ attrs: Array.isArray(attrs)
1103
+ ? attrs.map((a) => ({
1104
+ key: a.attr_key,
1105
+ value: a.attr_value,
1106
+ confidence: a.confidence ?? null,
1107
+ valid_from: a.valid_from ?? null
1108
+ }))
1109
+ : []
1110
+ });
1111
+ } catch {
1112
+ sendJson(res, 500, { error: "internal" });
1113
+ }
1114
+ }
1115
+ });
1116
+
1117
+ // --- entity directory (entity gene v0.3.0): read-only list for the UI ---
1118
+ // Flat entity list (newest-seen first). Attributes and relations stay on
1119
+ // their own per-entity endpoints, the directory only carries the columns
1120
+ // the rail needs: name, type, mention_count, first/last seen.
1121
+ register({
1122
+ kind: "exact",
1123
+ path: "/api/dsh-mneme/entities",
1124
+ handler(req, res) {
1125
+ try {
1126
+ const url = new URL(req.url, "http://localhost");
1127
+ const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
1128
+ const entities = service.listEntities?.({ limit }) ?? [];
1129
+ sendJson(res, 200, { entities, total: entities.length });
1130
+ } catch {
1131
+ sendJson(res, 500, { error: "internal" });
1132
+ }
1133
+ }
1134
+ });
1135
+
1136
+ // --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
1137
+ // 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
1138
+ // 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
1139
+ // 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
1140
+ register({
1141
+ kind: "exact",
1142
+ path: "/api/dsh-mneme/memories/entities",
1143
+ handler(req, res) {
1144
+ try {
1145
+ const url = new URL(req.url, "http://localhost");
1146
+ const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
1147
+ if (!memoryId) {
1148
+ sendJson(res, 400, { error: "missing-memory-id" });
1149
+ return;
1150
+ }
1151
+ sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
1152
+ } catch {
1153
+ sendJson(res, 500, { error: "internal" });
1154
+ }
1155
+ }
1156
+ });
1157
+
1158
+ // --- 导出(面板备份/迁移)---------------------------------------------------
1159
+ // 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC。
1160
+ // markdown:一个文档 = 文档级 frontmatter + 按类型分节。frontmatter 只放文档
1161
+ // 最前一份(分节不再各自带文件头,否则 9 个 `---` 块串在一个 .md 里,外部
1162
+ // frontmatter 解析器只认第一段、其余降级成正文),它的 coverage: all 就是一句
1163
+ // 看得见的声明:这份导出含 archived/forgotten(document 指针行除外——它两个
1164
+ // 落点都不进,见 mirror.js 的 MIRROR_EXCLUDED_TYPES)。条目块与磁盘镜像同构(共用
1165
+ // renderMirrorText 的条目渲染),所以导出文本能被 /import 原样吃回;「同构」
1166
+ // 止于条目——磁盘镜像只写活跃集,导出的行集更宽,两者的覆盖声明因此不同
1167
+ // (active-only vs all)。
1168
+ // 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
1169
+ // 不需要流式。
1170
+ register({
1171
+ kind: "exact",
1172
+ path: "/api/dsh-mneme/export",
1173
+ handler(req, res) {
1174
+ try {
1175
+ const url = new URL(req.url, "http://localhost");
1176
+ const format = url.searchParams.get("format") ?? "json";
1177
+ if (format !== "json" && format !== "markdown") {
1178
+ sendJson(res, 400, { error: "invalid-format" });
1179
+ return;
1180
+ }
1181
+ const rows = service.all?.() ?? [];
1182
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
1183
+ if (format === "json") {
1184
+ sendJson(res, 200, {
1185
+ exported_at: new Date().toISOString(),
1186
+ version: PACKAGE_VERSION,
1187
+ count: rows.length,
1188
+ memories: rows
1189
+ }, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
1190
+ return;
1191
+ }
1192
+ const byType = {};
1193
+ for (const row of rows) {
1194
+ // 没有 TYPE_FILE 条目的 type 不进 markdown 导出,只读 type(#296 的
1195
+ // document 指针行)同样不进:导出/导入是 round-trip 通道,只读视图没有
1196
+ // 可回填的正文。两个排除都在 mirror.js 那张表里(MIRROR_EXCLUDED_TYPES /
1197
+ // MIRROR_READONLY_TYPES)。json 分支是全字段导出,不受影响。
1198
+ if (TYPE_FILE[row.type] && !MIRROR_READONLY_TYPES.has(row.type)) (byType[row.type] ??= []).push(row);
1199
+ }
1200
+ const sections = [];
1201
+ for (const type of Object.keys(TYPE_FILE)) {
1202
+ if (byType[type]?.length) {
1203
+ sections.push(renderMirrorText(type, byType[type], langOf(config), { fileHeader: false }));
1204
+ }
1205
+ }
1206
+ const included = Object.values(byType).flat();
1207
+ const docHeader = renderFileHeader("memory-export", included, "all");
1208
+ sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, docHeader + sections.join("\n"));
1209
+ } catch {
1210
+ sendJson(res, 500, { error: "internal" });
1211
+ }
1212
+ }
1213
+ });
1214
+
1215
+ // --- 导入(Markdown 镜像回填)----------------------------------------------
1216
+ // 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键,
1217
+ // markdown 是与镜像文件同构的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
1218
+ // 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
1219
+ // 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
1220
+ register({
1221
+ kind: "exact",
1222
+ path: "/api/dsh-mneme/import",
1223
+ handler(req, res) {
1224
+ try {
1225
+ if (req.method !== "POST") {
1226
+ sendJson(res, 404, { error: "not-found" });
1227
+ return;
1228
+ }
1229
+ if (!requireAuth(req, res, apiToken)) return;
1230
+ return readBody(req).then((text) => {
1231
+ const body = parseBody(text);
1232
+ if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
1233
+ sendJson(res, 400, { error: "invalid-type" });
1234
+ return;
1235
+ }
1236
+ // 只读 type 是合法 type,只是没有可回填的正文(#296):单独的码,调用方
1237
+ // 不会被误导去改 type 名重试。
1238
+ if (MIRROR_READONLY_TYPES.has(body.type)) {
1239
+ sendJson(res, 400, { error: "readonly-type" });
1240
+ return;
1241
+ }
1242
+ if (typeof body.markdown !== "string" || !body.markdown.trim()) {
1243
+ sendJson(res, 400, { error: "invalid-markdown" });
1244
+ return;
1245
+ }
1246
+ const edits = parseHumanEdits(body.markdown);
1247
+ service.mergeHumanEdits(body.type, edits);
1248
+ sendJson(res, 200, { merged: edits.length, type: body.type });
1249
+ });
1250
+ } catch {
1251
+ sendJson(res, 500, { error: "internal" });
1252
+ }
1253
+ }
1254
+ });
1255
+
1256
+ // --- 巩固状态(dream 面板)--------------------------------------------------
1257
+ // 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
1258
+ // pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
1259
+ // 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
1260
+ // 只读通道(bookkeeping 语义,不触发写钩子)。
1261
+ register({
1262
+ kind: "exact",
1263
+ path: "/api/dsh-mneme/dream-status",
1264
+ handler(req, res) {
1265
+ try {
1266
+ const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
1267
+ created_at: r.created_at,
1268
+ status: r.status,
1269
+ provider: r.provider ?? null,
1270
+ model: r.model ?? null,
1271
+ error: r.error ?? null,
1272
+ run_type: r.run_type ?? "auto",
1273
+ // sleep 审计:heat/时间分层降级决策计数(工作动态可展示"降级 N 条")。
1274
+ // 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
1275
+ demotion: r.decisions?.demotion
1276
+ ? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
1277
+ : null
1278
+ }));
1279
+ const pendingConflicts = service.countConflictPending?.() ?? 0;
1280
+ const ids = new Set();
1281
+ for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
1282
+ if (ids.size >= 200) break;
1283
+ if (row.memory_a) ids.add(row.memory_a);
1284
+ if (ids.size >= 200) break;
1285
+ if (row.memory_b) ids.add(row.memory_b);
1286
+ }
1287
+ sendJson(res, 200, {
1288
+ lastRun: runs[0] ?? null,
1289
+ runs,
1290
+ pendingConflicts,
1291
+ pendingMemoryIds: [...ids].slice(0, 200)
1292
+ });
1293
+ } catch {
1294
+ sendJson(res, 500, { error: "internal" });
1295
+ }
1296
+ }
1297
+ });
1298
+
1299
+ // --- 冲突集中处理(v0.8.0):队列 + 人工确认 --------------------------------
1300
+ // GET /conflicts:未解决冲突 + 双方当前内容(此前面板只有计数与徽章,
1301
+ // resolveConflictPending 在存储层躺着没有任何调用方)。POST /conflicts/resolve:
1302
+ // {id, winner: "a"|"b"|null, apply}——盖章审计,apply 时按 dream 非冻结
1303
+ // conflict 路径同款处置(service.resolveConflictPending 内部复用
1304
+ // applyDecisions:CAS + 事务 + 注记 + 幂等)。认证与其他写路由一致。
1305
+ register({
1306
+ kind: "exact",
1307
+ path: "/api/dsh-mneme/conflicts",
1308
+ handler(req, res) {
1309
+ try {
1310
+ if (req.method !== "GET") {
1311
+ sendJson(res, 404, { error: "not-found" });
1312
+ return;
1313
+ }
1314
+ if (!requireAuth(req, res, apiToken)) return;
1315
+ const url = new URL(req.url, "http://localhost");
1316
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
1317
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
1318
+ const items = service.listConflictQueue({ limit, offset });
1319
+ sendJson(res, 200, { items, total: service.countConflictPending?.() ?? items.length });
1320
+ } catch {
1321
+ sendJson(res, 500, { error: "internal" });
1322
+ }
1323
+ }
1324
+ });
1325
+
1326
+ register({
1327
+ kind: "exact",
1328
+ path: "/api/dsh-mneme/conflicts/resolve",
1329
+ handler(req, res) {
1330
+ try {
1331
+ if (req.method !== "POST") {
1332
+ sendJson(res, 404, { error: "not-found" });
1333
+ return;
1334
+ }
1335
+ if (!requireAuth(req, res, apiToken)) return;
1336
+ return readBody(req).then((text) => {
1337
+ let body = {};
1338
+ try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
1339
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1340
+ if (!id) {
1341
+ sendJson(res, 400, { error: "missing-id" });
1342
+ return;
1343
+ }
1344
+ const winner = body.winner === "a" || body.winner === "b" ? body.winner : null;
1345
+ const apply = body.apply !== false;
1346
+ const result = service.resolveConflictPending(id, { winner, apply });
1347
+ if (!result) {
1348
+ sendJson(res, 404, { error: "not-found" });
1349
+ return;
1350
+ }
1351
+ sendJson(res, 200, { ok: true, conflict: result });
1352
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
1353
+ } catch {
1354
+ sendJson(res, 500, { error: "internal" });
1355
+ }
1356
+ }
1357
+ });
1358
+
1359
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
1360
+ // Auth-gated; only returns a sanitized error code (never raw last_error which
1361
+ // may leak paths/token-like strings/internal hosts). On state read failure it
1362
+ // reports unknown/degraded (fail-closed) instead of a false dirty=false.
1363
+ register({
1364
+ kind: "exact",
1365
+ path: "/api/dsh-mneme/health",
1366
+ handler(req, res) {
1367
+ if (!requireAuth(req, res, apiToken)) return;
1368
+ let state = null;
1369
+ try {
1370
+ state = service.getMirrorHealth?.() ?? null;
1371
+ } catch {
1372
+ // read failure is itself a health signal: do not report a false clean
1373
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
1374
+ return;
1375
+ }
1376
+ if (!state) {
1377
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
1378
+ return;
1379
+ }
1380
+ // Real read failure surfaces as dirty === null (peer blocker 5): report
1381
+ // unknown explicitly instead of collapsing into a false "ok"/"degraded".
1382
+ if (state.dirty === null) {
1383
+ sendJson(res, 200, {
1384
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
1385
+ });
1386
+ return;
1387
+ }
1388
+ // Sanitized: boolean dirty + coarse status only; error string is mapped to
1389
+ // a bounded code, never echoed verbatim.
1390
+ let code = null;
1391
+ if (state.last_error) {
1392
+ const e = String(state.last_error);
1393
+ code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
1394
+ }
1395
+ sendJson(res, 200, {
1396
+ mirror: {
1397
+ dirty: state.dirty === true,
1398
+ status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
1399
+ last_error: code,
1400
+ last_attempt: state.last_attempt ?? null,
1401
+ success_at: state.success_at ?? null
1402
+ }
1403
+ });
1404
+ }
1405
+ });
1406
+
1407
+ // --- panel mode (v0.7.12): light / standard -------------------------------
1408
+ // Persists the Web panel's feature preset into the settings kv
1409
+ // ("panel_mode"). PUT requires auth like every other settings write; the
1410
+ // value is validated against the enum. index.js applies the light preset on
1411
+ // the next boot (persisted mode wins over the bundle config).
1412
+ register({
1413
+ kind: "exact",
1414
+ path: "/api/dsh-mneme/mode",
1415
+ handler(req, res) {
1416
+ try {
1417
+ if (req.method === "PUT" || req.method === "POST") {
1418
+ if (!requireAuth(req, res, apiToken)) return;
1419
+ return readBody(req).then((text) => {
1420
+ const body = parseBody(text);
1421
+ if (body.mode !== "light" && body.mode !== "standard") {
1422
+ sendJson(res, 400, { error: "invalid-mode" });
1423
+ return;
1424
+ }
1425
+ settings.setPanelMode(body.mode);
1426
+ sendJson(res, 200, { mode: settings.getPanelMode() });
1427
+ });
1428
+ }
1429
+ sendJson(res, 200, { mode: settings.getPanelMode() });
1430
+ } catch {
1431
+ sendJson(res, 500, { error: "internal" });
1432
+ }
1433
+ }
1434
+ });
1435
+
1436
+ // --- external API settings (the standalone server's panel-facing config) ---
1437
+ register({
1438
+ kind: "exact",
1439
+ path: "/api/dsh-mneme/external-api",
1440
+ handler(req, res) {
1441
+ try {
1442
+ if (req.method === "PUT" || req.method === "POST") {
1443
+ if (!requireAuth(req, res, apiToken)) return;
1444
+ return readBody(req).then((text) => {
1445
+ const body = parseBody(text);
1446
+ const patch = {};
1447
+ if (body.enabled !== undefined) patch.enabled = body.enabled === true;
1448
+ if (body.port !== undefined) {
1449
+ const port = Number(body.port);
1450
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1451
+ sendJson(res, 400, { error: "invalid-port" });
1452
+ return;
1453
+ }
1454
+ patch.port = port;
1455
+ }
1456
+ if (body.host !== undefined) {
1457
+ const host = String(body.host).trim();
1458
+ if (!host || host.includes("://")) {
1459
+ sendJson(res, 400, { error: "invalid-host" });
1460
+ return;
1461
+ }
1462
+ patch.host = host;
1463
+ }
1464
+ sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
1465
+ });
1466
+ }
1467
+ // GET: always hand back a token — the standalone server generates its
1468
+ // own on first enabled boot, but the panel displays it before that,
1469
+ // so materialize and persist one here.
1470
+ const kv = settings.getExternalApi() ?? {};
1471
+ if (!kv.token) {
1472
+ kv.token = randomBytes(24).toString("base64url");
1473
+ settings.setExternalApi({ token: kv.token });
1474
+ }
1475
+ sendJson(res, 200, { config: fullExternalConfig(kv) });
1476
+ } catch {
1477
+ sendJson(res, 500, { error: "internal" });
1478
+ }
1479
+ }
1480
+ });
1481
+
1482
+ // --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
1483
+ // 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
1484
+ // 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
1485
+ // 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
1486
+ // 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
1487
+ // 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
1488
+ register({
1489
+ kind: "exact",
1490
+ path: "/api/dsh-mneme/features",
1491
+ handler(req, res) {
1492
+ try {
1493
+ if (req.method === "PUT" || req.method === "POST") {
1494
+ if (!requireAuth(req, res, apiToken)) return;
1495
+ return readBody(req).then((text) => {
1496
+ const body = parseBody(text);
1497
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
1498
+ sendJson(res, 400, { error: "invalid-patch" });
1499
+ return;
1500
+ }
1501
+ try {
1502
+ settings.setFeatureFlags(body);
1503
+ } catch (error) {
1504
+ sendJson(res, 400, { error: error.message });
1505
+ return;
1506
+ }
1507
+ sendJson(res, 200, featureSnapshot());
1508
+ });
1509
+ }
1510
+ sendJson(res, 200, featureSnapshot());
1511
+ } catch {
1512
+ sendJson(res, 500, { error: "internal" });
1513
+ }
1514
+ }
1515
+ });
1516
+
1517
+ // 注入状态(#182,只读):面板状态页据此决定是否渲染「极简模式下注入按宿主
1518
+ // 设计关闭」提示卡。suppressed=true 需同时满足:autoInject 生效值开着(否则
1519
+ // 关注入是用户自己的选择,不是黑盒)、会话头 preset 为 minimal、且 preset 确
1520
+ // 实观测到了(null=未知会话/宿主不支持,面板静默不猜)。preset 值按字符串
1521
+ // 精确匹配 "minimal"——host bundle 的 preset id(#175 逐字证据),不做模糊。
1522
+ register({
1523
+ kind: "exact",
1524
+ path: "/api/dsh-mneme/inject-status",
1525
+ handler(req, res) {
1526
+ try {
1527
+ if (req.method !== "GET") {
1528
+ sendJson(res, 404, { error: "not-found" });
1529
+ return;
1530
+ }
1531
+ const effective = featureSnapshot().effective;
1532
+ const autoInject = effective.autoInject === true;
1533
+ const agentPreset = lastAgentPreset;
1534
+ const suppressed = autoInject && agentPreset === "minimal";
1535
+ sendJson(res, 200, { autoInject, agentPreset, suppressed });
1536
+ } catch {
1537
+ sendJson(res, 500, { error: "internal" });
1538
+ }
1539
+ }
1540
+ });
1541
+
1542
+ // 注入预览(#179,只读):最近一帧 systemPrompt 组装的构成与体积——快照由
1543
+ // inject.js 在真实渲染路径旁路缓存(同一份 candidates,不二次检索)。null =
1544
+ // 尚未发生过渲染(新宿主/新会话)或 autoInject 关闭(注入器未注册),面板对
1545
+ // 两者都以「暂无预览」呈现,不区分也不猜。
1546
+ register({
1547
+ kind: "exact",
1548
+ path: "/api/dsh-mneme/inject-preview",
1549
+ handler(req, res) {
1550
+ try {
1551
+ if (req.method !== "GET") {
1552
+ sendJson(res, 404, { error: "not-found" });
1553
+ return;
1554
+ }
1555
+ sendJson(res, 200, { snapshot: getInjectionSnapshot() });
1556
+ } catch {
1557
+ sendJson(res, 500, { error: "internal" });
1558
+ }
1559
+ }
1560
+ });
1561
+
1562
+ // --- custom commands ---
1563
+ register({
1564
+ kind: "exact",
1565
+ path: "/api/dsh-mneme/commands",
1566
+ handler(req, res) {
1567
+ try {
1568
+ if (req.method === "POST") {
1569
+ if (!requireAuth(req, res, apiToken)) return;
1570
+ return readBody(req).then((text) => {
1571
+ const body = parseBody(text);
1572
+ try {
1573
+ const command = commands.add({
1574
+ name: body.name,
1575
+ description: body.description,
1576
+ instruction: body.instruction
1577
+ });
1578
+ sendJson(res, 200, { command });
1579
+ } catch (error) {
1580
+ sendJson(res, 400, { error: error.message });
1581
+ }
1582
+ });
1583
+ }
1584
+ if (req.method === "DELETE") {
1585
+ if (!requireAuth(req, res, apiToken)) return;
1586
+ const url = new URL(req.url, "http://localhost");
1587
+ const id = url.searchParams.get("id");
1588
+ const removed = id ? commands.remove(id) : false;
1589
+ sendJson(res, 200, { removed });
1590
+ return;
1591
+ }
1592
+ sendJson(res, 200, { commands: commands.list() });
1593
+ } catch {
1594
+ sendJson(res, 500, { error: "internal" });
1595
+ }
1596
+ }
1597
+ });
1598
+
1599
+ return {
1600
+ routes: 34,
1601
+ dispose: () => {
1602
+ for (const dispose of disposers) dispose();
1603
+ }
1604
+ };
1605
+ }