@modusensus/dsh-mneme 0.8.3 → 0.8.4

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