@modusensus/dsh-mneme 0.8.14 → 0.8.15

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