@modusensus/dsh-mneme 0.9.0 → 0.9.2

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 (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
package/src/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { URL } from "node:url";
2
2
  import { isAbsolute } from "node:path";
3
+ import { existsSync, readdirSync } from "node:fs";
3
4
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
5
  import { FEATURE_FLAG_SPEC } from "./settings.js";
5
6
  import { TYPES } from "./store.js";
@@ -12,7 +13,10 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
12
13
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
13
14
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
14
15
  import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
15
- import { readPipelineDoc } from "./pipeline.js";
16
+ import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
17
+ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
+ import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
+ import { projectKey } from "./candidates.js";
16
20
  import { readCandidateDoc } from "./candidates.js";
17
21
 
18
22
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
@@ -37,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
37
41
  // defaults in config.js (externalApiPort / externalApiHost).
38
42
  const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
39
43
 
44
+ // 请求体上限(防御,不是功能):readBody 只累加字符串,不设上限时一个超大 body
45
+ // 就能把进程内存拉到 Content-Length 那个量级。4 MiB 远高于任何真实写入(记忆
46
+ // 正文、导入的 markdown、面板 patch 都远小于它)。
47
+ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
48
+ // 分页上界(防御):list/search 的参数直接进 SQL LIMIT,无上界时
49
+ // ?limit=100000 会让一次免鉴权 GET 把全库序列化出来(2026-10 审计:免鉴权读面
50
+ // + 浏览器简单请求 = 廉价 DoS)。1000/200 远高于面板与 CLI 的任何真实用法。
51
+ const MAX_PAGE_LIMIT = 1000;
52
+ const MAX_SEARCH_TOP_K = 200;
53
+
40
54
  /** Merge persisted external-api kv over the defaults for client display. */
41
55
  function fullExternalConfig(kv = {}) {
42
56
  return {
@@ -70,22 +84,82 @@ function isAuthorized(req, apiToken) {
70
84
  }
71
85
 
72
86
  /**
73
- * Reject a request with 401 when auth is enabled and the token is missing or
74
- * wrong. Returns true when the request may proceed.
87
+ * 路由鉴权策略(2026-10 审计的结构性修法)。三条:
88
+ * open 永不鉴权——「公开读面」,必须写进 SECURITY.md 的安全模型表
89
+ * token 配置了 apiToken 就整条要带(写路由 + 敏感读)
90
+ * token-write 只有非 GET/HEAD 才要 token(面板要读、只有写才有副作用)
91
+ *
92
+ * 少声明不是「默认开放」:register 直接抛错,插件起不来。这是本方案的要点——
93
+ * 「新增路由忘了加鉴权」从运行期静默漏洞变成装配期硬失败,不依赖谁记得写、也
94
+ * 不依赖测试有没有覆盖到(/export 就是这么漏的,见 2026-10 审计 P0)。
75
95
  */
76
- function requireAuth(req, res, apiToken) {
77
- if (isAuthorized(req, apiToken)) return true;
78
- sendJson(res, 401, { error: "unauthorized" });
79
- return false;
96
+ export const ROUTE_POLICIES = Object.freeze(["open", "token", "token-write"]);
97
+
98
+ /** True when this request must present the apiToken under the route's policy. */
99
+ function needsToken(req, policy, apiToken) {
100
+ if (policy === "open") return false;
101
+ const method = String(req.method || "GET").toUpperCase();
102
+ if (policy === "token-write" && (method === "GET" || method === "HEAD")) return false;
103
+ return !isAuthorized(req, apiToken);
104
+ }
105
+
106
+ /**
107
+ * 唯一的登记入口:校验 policy、拒绝重复路径、把 handler 包上鉴权。
108
+ * 返回的 count() 供 createApi 汇报真实路由数(此前的字面量 36 已经和现实脱节)。
109
+ */
110
+ export function createRouteRegistrar(webServer, apiToken, disposers) {
111
+ const seen = new Set();
112
+ const register = (route) => {
113
+ if (!ROUTE_POLICIES.includes(route.policy)) {
114
+ throw new Error(
115
+ `[dsh-mneme] 路由 ${route.path} 必须声明 policy(${ROUTE_POLICIES.join(" / ")}),` +
116
+ `实际是 ${JSON.stringify(route.policy)}。先想清楚它属于哪一类,再同步 SECURITY.md 的安全模型表。`
117
+ );
118
+ }
119
+ const key = `${route.kind}:${route.path}`;
120
+ if (seen.has(key)) throw new Error(`[dsh-mneme] 路由 ${key} 被重复登记`);
121
+ seen.add(key);
122
+ const inner = route.handler;
123
+ const handler = (req, res) => {
124
+ if (!needsToken(req, route.policy, apiToken)) return inner(req, res);
125
+ sendJson(res, 401, { error: "unauthorized" });
126
+ return undefined;
127
+ };
128
+ disposers.push(webServer.register({ ...route, handler }));
129
+ };
130
+ return { register, count: () => seen.size };
80
131
  }
81
132
 
82
- /** Collect the request body as text (tolerant of empty/invalid bodies). */
83
- function readBody(req) {
133
+
134
+ /**
135
+ * Collect the request body as text, or null when it exceeds the cap.
136
+ *
137
+ * 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
138
+ * /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
139
+ * 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
140
+ * 直接 413(2026-10 评审指出的数据损坏路径)。
141
+ * 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
142
+ */
143
+ function readBody(req, res) {
84
144
  return new Promise((resolve) => {
85
145
  let body = "";
86
- req.on("data", (chunk) => { body += chunk; });
87
- req.on("end", () => resolve(body));
88
- req.on("error", () => resolve(""));
146
+ let bytes = 0;
147
+ let overflow = false;
148
+ req.on("data", (chunk) => {
149
+ if (overflow) return;
150
+ bytes += chunk.length;
151
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
152
+ overflow = true;
153
+ body = "";
154
+ sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
155
+ return;
156
+ }
157
+ body += chunk;
158
+ });
159
+ req.on("end", () => resolve(overflow ? null : body));
160
+ // 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
161
+ // 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
162
+ req.on("error", () => resolve(overflow ? null : ""));
89
163
  });
90
164
  }
91
165
 
@@ -133,6 +207,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
133
207
  // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
134
208
  // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
135
209
  // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
210
+ //
211
+ // pendingRestart 回答的是第三个问题:「我改的开关,现在真的生效了吗?」
212
+ // 功能开关的生效节奏与 panel_mode 相同——启动时由 index.js 合并进 cfg,运行中
213
+ // 那份 cfg 是冻结的(见 index.js 装配处的注释)。于是面板显示「已改」不等于
214
+ // 运行时已改:#481 实测人格总闸正是这样,面板开着、人格段却不注入,用户只能
215
+ // 靠反复试错发现。effective 表达的是「运行时取什么值」(overrides 优先),
216
+ // **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
217
+ // 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
218
+ // config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
136
219
  const flagKeys = [
137
220
  ...FEATURE_FLAG_SPEC.booleans,
138
221
  ...Object.keys(FEATURE_FLAG_SPEC.ints),
@@ -158,7 +241,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
158
241
  // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
159
242
  // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
160
243
  "distillRecords.enabled": ["distillRecords", "enabled"],
161
- "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
244
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"],
245
+ // Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
246
+ // 同上(effective 静默缺键、面板读不到也 PUT 不进)。
247
+ "scopeState.enabled": ["scopeState", "enabled"],
248
+ "scopeState.enforce": ["scopeState", "enforce"]
162
249
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
163
250
  // 的默认分支即可。
164
251
  };
@@ -167,6 +254,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
167
254
  if (path) return config?.[path[0]]?.[path[1]];
168
255
  return config?.[key];
169
256
  }
257
+ // 「显式覆盖 ≠ 冻结值」的键。用 Object.is 比而不是 !==:NaN 与 ±0 的边角上
258
+ // 两者会给出相反的结论(Object.is(NaN, NaN) 为真、Object.is(0, -0) 为假),
259
+ // 开关值里虽然不该出现它们,但比较语义与「值是否相同」对齐更省心。
260
+ // 未被覆盖的键不看:它的运行时值本来就取自 cfg,不存在落差。
261
+ function pendingRestartKeys(overrides) {
262
+ if (!config) return [];
263
+ const pending = [];
264
+ for (const key of flagKeys) {
265
+ if (overrides[key] === undefined) continue;
266
+ if (!Object.is(overrides[key], configFlagValue(key))) pending.push(key);
267
+ }
268
+ return pending;
269
+ }
170
270
  function featureSnapshot() {
171
271
  const overrides = settings.getFeatureFlags();
172
272
  const effective = {};
@@ -177,7 +277,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
177
277
  if (value !== undefined) effective[key] = value;
178
278
  }
179
279
  }
180
- return { overrides, effective };
280
+ return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
181
281
  }
182
282
 
183
283
  // Ensure the service has an embedder when the API layer was handed one
@@ -187,14 +287,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
187
287
  service.setEmbedder(embedder);
188
288
  }
189
289
 
190
- const register = (route) => {
191
- disposers.push(webServer.register(route));
192
- };
290
+ // 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
291
+ const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
193
292
 
194
293
  // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
195
294
  register({
196
295
  kind: "prefix",
197
296
  path: "/api/dsh-mneme",
297
+ policy: "open",
198
298
  handler(req, res) {
199
299
  sendJson(res, 404, { error: "not-found" });
200
300
  }
@@ -205,6 +305,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
205
305
  register({
206
306
  kind: "exact",
207
307
  path: "/api/dsh-mneme/info",
308
+ policy: "open",
208
309
  handler(req, res) {
209
310
  sendJson(res, 200, { version: PACKAGE_VERSION });
210
311
  }
@@ -217,6 +318,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
217
318
  register({
218
319
  kind: "exact",
219
320
  path: "/api/dsh-mneme/version-check",
321
+ policy: "open",
220
322
  handler(req, res) {
221
323
  if (req.method !== "GET") {
222
324
  sendJson(res, 404, { error: "not-found" });
@@ -236,11 +338,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
236
338
  register({
237
339
  kind: "exact",
238
340
  path: "/api/dsh-mneme/list",
341
+ policy: "open",
239
342
  handler(req, res) {
240
343
  try {
241
344
  const url = new URL(req.url, "http://localhost");
242
345
  const type = url.searchParams.get("type") ?? undefined;
243
- const limit = Number(url.searchParams.get("limit") ?? 50);
346
+ // 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
347
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
244
348
  const offset = Number(url.searchParams.get("offset") ?? 0);
245
349
  // order=chrono: pure newest-first for paged browsing; default keeps
246
350
  // the importance-ranked order other callers rely on.
@@ -292,6 +396,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
292
396
  register({
293
397
  kind: "exact",
294
398
  path: "/api/dsh-mneme/recall-stats",
399
+ policy: "open",
295
400
  handler(req, res) {
296
401
  if (req.method !== "GET") {
297
402
  sendJson(res, 404, { error: "not-found" });
@@ -311,11 +416,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
311
416
  register({
312
417
  kind: "exact",
313
418
  path: "/api/dsh-mneme/search",
419
+ policy: "open",
314
420
  handler(req, res) {
315
421
  try {
316
422
  const url = new URL(req.url, "http://localhost");
317
423
  const q = url.searchParams.get("q") ?? "";
318
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
424
+ // 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
425
+ const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
319
426
  // mode selects the recall strategy (defaults to auto):
320
427
  // auto (default) keyword first, vector fills remaining slots
321
428
  // hybrid vector first, keyword fills remaining slots; scores of
@@ -355,11 +462,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
355
462
  register({
356
463
  kind: "exact",
357
464
  path: "/api/dsh-mneme/profile",
465
+ policy: "token-write",
358
466
  handler(req, res) {
359
467
  try {
360
468
  if (req.method === "PUT" || req.method === "POST") {
361
- if (!requireAuth(req, res, apiToken)) return;
362
- return readBody(req).then((text) => {
469
+ return readBody(req, res).then((text) => {
470
+ if (text === null) return;
363
471
  const body = parseBody(text);
364
472
  settings.setProfile(typeof body.profile === "string" ? body.profile : "");
365
473
  sendJson(res, 200, { profile: settings.getProfile() });
@@ -372,6 +480,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
372
480
  }
373
481
  });
374
482
 
483
+ // --- agent persona(人格定义页,beta) -------------------------------------
484
+ // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
485
+ // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
486
+ // default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
487
+ // 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
488
+ register({
489
+ kind: "exact",
490
+ path: "/api/dsh-mneme/persona",
491
+ policy: "token-write",
492
+ handler(req, res) {
493
+ try {
494
+ if (req.method === "PUT" || req.method === "POST") {
495
+ return readBody(req, res).then((text) => {
496
+ if (text === null) return;
497
+ // parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
498
+ // 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
499
+ // 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
500
+ let body;
501
+ let jsonOk = true;
502
+ try {
503
+ body = JSON.parse(text || "{}");
504
+ } catch {
505
+ body = {};
506
+ jsonOk = false;
507
+ }
508
+ if (jsonOk && body && typeof body === "object") {
509
+ settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
510
+ }
511
+ sendJson(res, 200, { persona: settings.getPersona() });
512
+ });
513
+ }
514
+ sendJson(res, 200, { persona: settings.getPersona() });
515
+ } catch {
516
+ sendJson(res, 500, { error: "internal" });
517
+ }
518
+ }
519
+ });
520
+
375
521
  // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
376
522
  // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
377
523
  // through the same harness path the consolidation uses and reports success /
@@ -471,6 +617,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
471
617
  register({
472
618
  kind: "exact",
473
619
  path: "/api/dsh-mneme/llm-providers",
620
+ policy: "open",
474
621
  handler(req, res) {
475
622
  try {
476
623
  const llm = ctx.llm;
@@ -499,11 +646,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
499
646
  register({
500
647
  kind: "exact",
501
648
  path: "/api/dsh-mneme/test-model",
649
+ policy: "token",
502
650
  handler(req, res) {
503
651
  try {
504
652
  if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
505
- if (!requireAuth(req, res, apiToken)) return;
506
- return readBody(req).then(async (text) => {
653
+ return readBody(req, res).then(async (text) => {
654
+ if (text === null) return;
507
655
  const body = parseBody(text);
508
656
  let provider = typeof body.provider === "string" ? body.provider.trim() : "";
509
657
  let model = typeof body.model === "string" ? body.model.trim() : "";
@@ -534,14 +682,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
534
682
  register({
535
683
  kind: "exact",
536
684
  path: "/api/dsh-mneme/delete",
685
+ policy: "token",
537
686
  handler(req, res) {
538
687
  try {
539
688
  if (req.method !== "POST") {
540
689
  sendJson(res, 404, { error: "not-found" });
541
690
  return;
542
691
  }
543
- if (!requireAuth(req, res, apiToken)) return;
544
- return readBody(req).then((text) => {
692
+ return readBody(req, res).then((text) => {
693
+ if (text === null) return;
545
694
  const body = parseBody(text);
546
695
  const id = typeof body.id === "string" ? body.id.trim() : "";
547
696
  if (!id) {
@@ -571,14 +720,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
571
720
  register({
572
721
  kind: "exact",
573
722
  path: "/api/dsh-mneme/update",
723
+ policy: "token",
574
724
  handler(req, res) {
575
725
  try {
576
726
  if (req.method !== "POST") {
577
727
  sendJson(res, 404, { error: "not-found" });
578
728
  return;
579
729
  }
580
- if (!requireAuth(req, res, apiToken)) return;
581
- return readBody(req).then((text) => {
730
+ return readBody(req, res).then((text) => {
731
+ if (text === null) return;
582
732
  const body = parseBody(text);
583
733
  const id = typeof body.id === "string" ? body.id.trim() : "";
584
734
  if (!id) {
@@ -677,14 +827,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
677
827
  register({
678
828
  kind: "exact",
679
829
  path: "/api/dsh-mneme/create",
830
+ policy: "token",
680
831
  handler(req, res) {
681
832
  try {
682
833
  if (req.method !== "POST") {
683
834
  sendJson(res, 404, { error: "not-found" });
684
835
  return;
685
836
  }
686
- if (!requireAuth(req, res, apiToken)) return;
687
- return readBody(req).then((text) => {
837
+ return readBody(req, res).then((text) => {
838
+ if (text === null) return;
688
839
  const body = parseBody(text);
689
840
  if (!TYPES.has(body.type)) {
690
841
  sendJson(res, 400, { error: "invalid-type" });
@@ -759,10 +910,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
759
910
  register({
760
911
  kind: "exact",
761
912
  path: "/api/dsh-mneme/maintenance/reclaim",
913
+ policy: "token",
762
914
  handler(req, res) {
763
915
  try {
764
916
  if (req.method === "GET") {
765
- if (!requireAuth(req, res, apiToken)) return;
766
917
  if (!maintenance) {
767
918
  sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
768
919
  return;
@@ -785,8 +936,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
785
936
  sendJson(res, 404, { error: "not-found" });
786
937
  return;
787
938
  }
788
- if (!requireAuth(req, res, apiToken)) return;
789
- return readBody(req).then((text) => {
939
+ return readBody(req, res).then((text) => {
940
+ if (text === null) return;
790
941
  const parsed = parseReclaimJson(text);
791
942
  if (!parsed.ok) {
792
943
  sendJson(res, 400, { error: parsed.error });
@@ -821,11 +972,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
821
972
  register({
822
973
  kind: "exact",
823
974
  path: "/api/dsh-mneme/rules",
975
+ policy: "token-write",
824
976
  handler(req, res) {
825
977
  try {
826
978
  if (req.method === "PUT" || req.method === "POST") {
827
- if (!requireAuth(req, res, apiToken)) return;
828
- return readBody(req).then((text) => {
979
+ return readBody(req, res).then((text) => {
980
+ if (text === null) return;
829
981
  const body = parseBody(text);
830
982
  settings.setRules(Array.isArray(body.rules) ? body.rules : []);
831
983
  sendJson(res, 200, { rules: settings.getRules() });
@@ -842,12 +994,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
842
994
  register({
843
995
  kind: "exact",
844
996
  path: "/api/dsh-mneme/vector-config",
997
+ policy: "token",
845
998
  handler(req, res) {
846
999
  try {
847
1000
  // Secret-bearing endpoint: fully protected when apiToken is set.
848
- if (!requireAuth(req, res, apiToken)) return;
849
1001
  if (req.method === "PUT" || req.method === "POST") {
850
- return readBody(req).then((text) => {
1002
+ return readBody(req, res).then((text) => {
1003
+ if (text === null) return;
851
1004
  const body = parseBody(text);
852
1005
  // An empty apiKey, or one that already looks masked (round-trips
853
1006
  // through maskApiKey unchanged), means "keep the existing key".
@@ -879,9 +1032,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
879
1032
  register({
880
1033
  kind: "exact",
881
1034
  path: "/api/dsh-mneme/vector-reindex",
1035
+ policy: "token",
882
1036
  handler(req, res) {
883
1037
  try {
884
- if (!requireAuth(req, res, apiToken)) return;
885
1038
  if (!embedder) {
886
1039
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
887
1040
  return;
@@ -911,6 +1064,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
911
1064
  register({
912
1065
  kind: "exact",
913
1066
  path: "/api/dsh-mneme/semantic",
1067
+ policy: "open",
914
1068
  handler(req, res) {
915
1069
  try {
916
1070
  const stats = semantic?.vectorIndex?.getStats?.() ?? null;
@@ -926,6 +1080,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
926
1080
  const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
927
1081
  const embedded = Number(stats?.embeddedCount ?? 0);
928
1082
  const total = Number(stats?.totalCount ?? 0);
1083
+ // Per-model / per-dimension provenance of stored vectors. "embedded"
1084
+ // counts rows with any vector; rows whose dimension differs from the
1085
+ // current embedder output are skipped by searchVector, so the honest
1086
+ // retrievable number is the current-dimension slice. modelBreakdown
1087
+ // groups by row-level embedding_model ('' = unknown origin).
1088
+ const dimensionBreakdown = typeof service.embeddingDimensionBreakdown === "function"
1089
+ ? service.embeddingDimensionBreakdown()
1090
+ : null;
1091
+ const currentDim = embedder?.dimension != null ? String(embedder.dimension) : null;
1092
+ const byDimension = dimensionBreakdown
1093
+ ? [...dimensionBreakdown.entries()].map(([dim, c]) => ({ dimension: dim === "" ? null : Number(dim), count: c }))
1094
+ : [];
1095
+ const unsearchable = (currentDim && dimensionBreakdown)
1096
+ ? [...dimensionBreakdown.entries()]
1097
+ .filter(([dim]) => dim !== currentDim)
1098
+ .reduce((s, [, c]) => s + c, 0)
1099
+ : 0; const modelBreakdown = typeof service.embeddingModelBreakdown === "function"
1100
+ ? service.embeddingModelBreakdown().map((r) => ({ model: r.model === "" ? null : r.model, count: r.c }))
1101
+ : [];
929
1102
  sendJson(res, 200, {
930
1103
  ready,
931
1104
  configured,
@@ -936,6 +1109,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
936
1109
  : null,
937
1110
  // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
938
1111
  coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
1112
+ // distill-tx-embed-gap 后续:向量「存在」≠「可检索」。unsearchable =
1113
+ // 维度与当前 embedder 输出不同的行数(searchVector 会跳过它们),
1114
+ // retrievable = 与当前维度同维、真正参与召回的行数。两者差值非零时
1115
+ // 面板必须能说出「有 N 条向量不可检索」,而不是只给一个虚高的 coverage。
1116
+ vectors: {
1117
+ byDimension,
1118
+ byModel: modelBreakdown,
1119
+ unsearchable,
1120
+ retrievable: currentDim ? (dimensionBreakdown.get(currentDim) ?? 0) : null,
1121
+ },
939
1122
  // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
940
1123
  // 处于 ready:true + embeddedCount:0,面板却显示正常)。
941
1124
  degraded: configured === true && total > 0 && embedded === 0,
@@ -971,14 +1154,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
971
1154
  register({
972
1155
  kind: "exact",
973
1156
  path: "/api/dsh-mneme/runtime/provision",
1157
+ policy: "token",
974
1158
  handler(req, res) {
975
1159
  try {
976
1160
  if (req.method !== "POST") {
977
1161
  sendJson(res, 405, { error: "method-not-allowed" });
978
1162
  return;
979
1163
  }
980
- if (!requireAuth(req, res, apiToken)) return;
981
- return readBody(req).then(async (text) => {
1164
+ return readBody(req, res).then(async (text) => {
1165
+ if (text === null) return;
982
1166
  const body = parseBody(text);
983
1167
  const runtimeDir = config?.runtimeDir ?? "";
984
1168
  // 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
@@ -1008,6 +1192,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1008
1192
  register({
1009
1193
  kind: "exact",
1010
1194
  path: "/api/dsh-mneme/semantic/llm-audit",
1195
+ policy: "open",
1011
1196
  handler(req, res) {
1012
1197
  try {
1013
1198
  const url = new URL(req.url, "http://localhost");
@@ -1030,6 +1215,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1030
1215
  register({
1031
1216
  kind: "exact",
1032
1217
  path: "/api/dsh-mneme/semantic/llm-audit/stats",
1218
+ policy: "open",
1033
1219
  handler(req, res) {
1034
1220
  try {
1035
1221
  const url = new URL(req.url, "http://localhost");
@@ -1053,6 +1239,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1053
1239
  register({
1054
1240
  kind: "exact",
1055
1241
  path: "/api/dsh-mneme/semantic/graph/ego",
1242
+ policy: "open",
1056
1243
  handler(req, res) {
1057
1244
  try {
1058
1245
  const url = new URL(req.url, "http://localhost");
@@ -1130,6 +1317,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1130
1317
  register({
1131
1318
  kind: "exact",
1132
1319
  path: "/api/dsh-mneme/semantic/graph/entity-attrs",
1320
+ policy: "open",
1133
1321
  handler(req, res) {
1134
1322
  try {
1135
1323
  const url = new URL(req.url, "http://localhost");
@@ -1168,6 +1356,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1168
1356
  register({
1169
1357
  kind: "exact",
1170
1358
  path: "/api/dsh-mneme/entities",
1359
+ policy: "open",
1171
1360
  handler(req, res) {
1172
1361
  try {
1173
1362
  const url = new URL(req.url, "http://localhost");
@@ -1187,6 +1376,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1187
1376
  register({
1188
1377
  kind: "exact",
1189
1378
  path: "/api/dsh-mneme/memories/entities",
1379
+ policy: "open",
1190
1380
  handler(req, res) {
1191
1381
  try {
1192
1382
  const url = new URL(req.url, "http://localhost");
@@ -1217,8 +1407,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1217
1407
  register({
1218
1408
  kind: "exact",
1219
1409
  path: "/api/dsh-mneme/export",
1410
+ policy: "token",
1220
1411
  handler(req, res) {
1221
1412
  try {
1413
+ // 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
1414
+ // 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
1415
+ // apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
1222
1416
  const url = new URL(req.url, "http://localhost");
1223
1417
  const format = url.searchParams.get("format") ?? "json";
1224
1418
  if (format !== "json" && format !== "markdown") {
@@ -1267,14 +1461,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1267
1461
  register({
1268
1462
  kind: "exact",
1269
1463
  path: "/api/dsh-mneme/import",
1464
+ policy: "token",
1270
1465
  handler(req, res) {
1271
1466
  try {
1272
1467
  if (req.method !== "POST") {
1273
1468
  sendJson(res, 404, { error: "not-found" });
1274
1469
  return;
1275
1470
  }
1276
- if (!requireAuth(req, res, apiToken)) return;
1277
- return readBody(req).then((text) => {
1471
+ return readBody(req, res).then((text) => {
1472
+ if (text === null) return;
1278
1473
  const body = parseBody(text);
1279
1474
  if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
1280
1475
  sendJson(res, 400, { error: "invalid-type" });
@@ -1308,6 +1503,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1308
1503
  register({
1309
1504
  kind: "exact",
1310
1505
  path: "/api/dsh-mneme/dream-status",
1506
+ policy: "open",
1311
1507
  handler(req, res) {
1312
1508
  try {
1313
1509
  const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
@@ -1352,13 +1548,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1352
1548
  register({
1353
1549
  kind: "exact",
1354
1550
  path: "/api/dsh-mneme/conflicts",
1551
+ policy: "token",
1355
1552
  handler(req, res) {
1356
1553
  try {
1357
1554
  if (req.method !== "GET") {
1358
1555
  sendJson(res, 404, { error: "not-found" });
1359
1556
  return;
1360
1557
  }
1361
- if (!requireAuth(req, res, apiToken)) return;
1362
1558
  const url = new URL(req.url, "http://localhost");
1363
1559
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
1364
1560
  const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
@@ -1373,14 +1569,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1373
1569
  register({
1374
1570
  kind: "exact",
1375
1571
  path: "/api/dsh-mneme/conflicts/resolve",
1572
+ policy: "token",
1376
1573
  handler(req, res) {
1377
1574
  try {
1378
1575
  if (req.method !== "POST") {
1379
1576
  sendJson(res, 404, { error: "not-found" });
1380
1577
  return;
1381
1578
  }
1382
- if (!requireAuth(req, res, apiToken)) return;
1383
- return readBody(req).then((text) => {
1579
+ return readBody(req, res).then((text) => {
1580
+ if (text === null) return;
1384
1581
  let body = {};
1385
1582
  try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
1386
1583
  const id = typeof body.id === "string" ? body.id.trim() : "";
@@ -1410,8 +1607,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1410
1607
  register({
1411
1608
  kind: "exact",
1412
1609
  path: "/api/dsh-mneme/health",
1610
+ policy: "token",
1413
1611
  handler(req, res) {
1414
- if (!requireAuth(req, res, apiToken)) return;
1415
1612
  let state = null;
1416
1613
  try {
1417
1614
  state = service.getMirrorHealth?.() ?? null;
@@ -1459,11 +1656,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1459
1656
  register({
1460
1657
  kind: "exact",
1461
1658
  path: "/api/dsh-mneme/mode",
1659
+ policy: "token-write",
1462
1660
  handler(req, res) {
1463
1661
  try {
1464
1662
  if (req.method === "PUT" || req.method === "POST") {
1465
- if (!requireAuth(req, res, apiToken)) return;
1466
- return readBody(req).then((text) => {
1663
+ return readBody(req, res).then((text) => {
1664
+ if (text === null) return;
1467
1665
  const body = parseBody(text);
1468
1666
  if (body.mode !== "light" && body.mode !== "standard") {
1469
1667
  sendJson(res, 400, { error: "invalid-mode" });
@@ -1484,11 +1682,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1484
1682
  register({
1485
1683
  kind: "exact",
1486
1684
  path: "/api/dsh-mneme/external-api",
1685
+ policy: "token",
1487
1686
  handler(req, res) {
1488
1687
  try {
1489
1688
  if (req.method === "PUT" || req.method === "POST") {
1490
- if (!requireAuth(req, res, apiToken)) return;
1491
- return readBody(req).then((text) => {
1689
+ return readBody(req, res).then((text) => {
1690
+ if (text === null) return;
1492
1691
  const body = parseBody(text);
1493
1692
  const patch = {};
1494
1693
  if (body.enabled !== undefined) patch.enabled = body.enabled === true;
@@ -1511,9 +1710,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1511
1710
  sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
1512
1711
  });
1513
1712
  }
1514
- // GET: always hand back a token — the standalone server generates its
1515
- // own on first enabled boot, but the panel displays it before that,
1516
- // so materialize and persist one here.
1713
+ // GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
1714
+ // PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
1715
+ // 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
1716
+ // GET: hand back a token — the standalone server generates its own on
1717
+ // first enabled boot, but the panel displays it before that, so
1718
+ // materialize and persist one here. 只有通过上面那道门才会走到这里,
1719
+ // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
1517
1720
  const kv = settings.getExternalApi() ?? {};
1518
1721
  if (!kv.token) {
1519
1722
  kv.token = randomBytes(24).toString("base64url");
@@ -1535,11 +1738,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1535
1738
  register({
1536
1739
  kind: "exact",
1537
1740
  path: "/api/dsh-mneme/features",
1741
+ policy: "token-write",
1538
1742
  handler(req, res) {
1539
1743
  try {
1540
1744
  if (req.method === "PUT" || req.method === "POST") {
1541
- if (!requireAuth(req, res, apiToken)) return;
1542
- return readBody(req).then((text) => {
1745
+ return readBody(req, res).then((text) => {
1746
+ if (text === null) return;
1543
1747
  const body = parseBody(text);
1544
1748
  if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
1545
1749
  sendJson(res, 400, { error: "invalid-patch" });
@@ -1569,6 +1773,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1569
1773
  register({
1570
1774
  kind: "exact",
1571
1775
  path: "/api/dsh-mneme/inject-status",
1776
+ policy: "open",
1572
1777
  handler(req, res) {
1573
1778
  try {
1574
1779
  if (req.method !== "GET") {
@@ -1593,6 +1798,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1593
1798
  register({
1594
1799
  kind: "exact",
1595
1800
  path: "/api/dsh-mneme/inject-preview",
1801
+ policy: "open",
1596
1802
  handler(req, res) {
1597
1803
  try {
1598
1804
  if (req.method !== "GET") {
@@ -1610,11 +1816,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1610
1816
  register({
1611
1817
  kind: "exact",
1612
1818
  path: "/api/dsh-mneme/commands",
1819
+ policy: "token-write",
1613
1820
  handler(req, res) {
1614
1821
  try {
1615
1822
  if (req.method === "POST") {
1616
- if (!requireAuth(req, res, apiToken)) return;
1617
- return readBody(req).then((text) => {
1823
+ return readBody(req, res).then((text) => {
1824
+ if (text === null) return;
1618
1825
  const body = parseBody(text);
1619
1826
  try {
1620
1827
  const command = commands.add({
@@ -1629,7 +1836,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1629
1836
  });
1630
1837
  }
1631
1838
  if (req.method === "DELETE") {
1632
- if (!requireAuth(req, res, apiToken)) return;
1633
1839
  const url = new URL(req.url, "http://localhost");
1634
1840
  const id = url.searchParams.get("id");
1635
1841
  const removed = id ? commands.remove(id) : false;
@@ -1647,11 +1853,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1647
1853
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1648
1854
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1649
1855
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
1856
+ // 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
1857
+ // 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
1858
+ // runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
1650
1859
  // 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
1651
1860
  // 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
1652
1861
  register({
1653
1862
  kind: "exact",
1654
1863
  path: "/api/dsh-mneme/pipeline",
1864
+ policy: "open",
1655
1865
  handler(req, res) {
1656
1866
  try {
1657
1867
  const url = new URL(req.url, "http://localhost");
@@ -1665,7 +1875,76 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1665
1875
  sendJson(res, 400, { error: "project-not-absolute", detail: project });
1666
1876
  return;
1667
1877
  }
1668
- sendJson(res, 200, readPipelineDoc({ projectRoot: project, id }));
1878
+
1879
+ // 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
1880
+ // 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
1881
+ // runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
1882
+ // 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
1883
+ // ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
1884
+ // 复盘注释见下方旁挂块)。
1885
+ let runIds = [];
1886
+ let runsResult = { runsById: {}, errors: [], statesById: {} };
1887
+ if (project !== null) {
1888
+ const where = runsDir(project);
1889
+ runIds = existsSync(where)
1890
+ ? readdirSync(where)
1891
+ .filter((f) => f.endsWith(RUNS_SUFFIX))
1892
+ .sort()
1893
+ .map((f) => f.slice(0, -RUNS_SUFFIX.length))
1894
+ : [];
1895
+ if (runIds.length) {
1896
+ // step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
1897
+ // 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
1898
+ // 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
1899
+ let stepIdsById = null;
1900
+ try {
1901
+ stepIdsById = Object.create(null);
1902
+ for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
1903
+ stepIdsById[p.id] = p.steps.map((s) => s.id);
1904
+ }
1905
+ } catch {
1906
+ // 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
1907
+ // 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
1908
+ stepIdsById = null;
1909
+ }
1910
+ runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
1911
+ }
1912
+ }
1913
+ const { runsById, errors: runsErrors, statesById } = runsResult;
1914
+
1915
+ // P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
1916
+ // 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
1917
+ let observationsById = {};
1918
+ if (project !== null) {
1919
+ try {
1920
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
1921
+ ? config.memoryDir
1922
+ : "~/.dsh/memory";
1923
+ const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
1924
+ if (odir) {
1925
+ observationsById = loadObservationsById(odir, runIds.filter(Boolean));
1926
+ }
1927
+ } catch {
1928
+ // 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
1929
+ }
1930
+ }
1931
+
1932
+ const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
1933
+
1934
+ // 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
1935
+ // **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
1936
+ // pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
1937
+ // 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
1938
+ // 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
1939
+ // 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
1940
+ const chosenId = doc.pipeline ? doc.pipeline.id : null;
1941
+ const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
1942
+ const available =
1943
+ project === null
1944
+ ? doc.available
1945
+ : doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
1946
+
1947
+ sendJson(res, 200, { ...doc, available, runsState, runsErrors });
1669
1948
  } catch (error) {
1670
1949
  // 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
1671
1950
  // 而不是回一句 internal 让人去翻日志。
@@ -1690,6 +1969,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1690
1969
  register({
1691
1970
  kind: "exact",
1692
1971
  path: "/api/dsh-mneme/candidates",
1972
+ policy: "open",
1693
1973
  handler(req, res) {
1694
1974
  try {
1695
1975
  const url = new URL(req.url, "http://localhost");
@@ -1713,7 +1993,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1713
1993
  });
1714
1994
 
1715
1995
  return {
1716
- routes: 36,
1996
+ routes: countRoutes(),
1717
1997
  dispose: () => {
1718
1998
  for (const dispose of disposers) dispose();
1719
1999
  }