@modusensus/dsh-mneme 0.8.3 → 0.8.5

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