@modusensus/dsh-mneme 0.8.16 → 0.9.1

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 (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
package/src/api.js CHANGED
@@ -1,16 +1,23 @@
1
1
  import { URL } from "node:url";
2
+ import { isAbsolute } from "node:path";
3
+ import { existsSync, readdirSync } from "node:fs";
2
4
  import { timingSafeEqual, randomBytes } from "node:crypto";
3
5
  import { FEATURE_FLAG_SPEC } from "./settings.js";
4
6
  import { TYPES } from "./store.js";
5
7
  import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
6
8
  import { langOf } from "./lang.js";
7
9
  import { computeHeat } from "./heat.js";
8
- import { describeStreamFailure, resolveRoute } from "./dream.js";
10
+ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
9
11
  import { getInjectionSnapshot } from "./inject.js";
10
12
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
11
13
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
12
14
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
13
15
  import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.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";
20
+ import { readCandidateDoc } from "./candidates.js";
14
21
 
15
22
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
16
23
  function sendJson(res, status, payload, headers = {}) {
@@ -34,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
34
41
  // defaults in config.js (externalApiPort / externalApiHost).
35
42
  const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
36
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
+
37
54
  /** Merge persisted external-api kv over the defaults for client display. */
38
55
  function fullExternalConfig(kv = {}) {
39
56
  return {
@@ -67,22 +84,82 @@ function isAuthorized(req, apiToken) {
67
84
  }
68
85
 
69
86
  /**
70
- * Reject a request with 401 when auth is enabled and the token is missing or
71
- * wrong. Returns true when the request may proceed.
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)。
95
+ */
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 已经和现实脱节)。
72
109
  */
73
- function requireAuth(req, res, apiToken) {
74
- if (isAuthorized(req, apiToken)) return true;
75
- sendJson(res, 401, { error: "unauthorized" });
76
- return false;
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 };
77
131
  }
78
132
 
79
- /** Collect the request body as text (tolerant of empty/invalid bodies). */
80
- 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) {
81
144
  return new Promise((resolve) => {
82
145
  let body = "";
83
- req.on("data", (chunk) => { body += chunk; });
84
- req.on("end", () => resolve(body));
85
- 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 : ""));
86
163
  });
87
164
  }
88
165
 
@@ -184,14 +261,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
184
261
  service.setEmbedder(embedder);
185
262
  }
186
263
 
187
- const register = (route) => {
188
- disposers.push(webServer.register(route));
189
- };
264
+ // 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
265
+ const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
190
266
 
191
267
  // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
192
268
  register({
193
269
  kind: "prefix",
194
270
  path: "/api/dsh-mneme",
271
+ policy: "open",
195
272
  handler(req, res) {
196
273
  sendJson(res, 404, { error: "not-found" });
197
274
  }
@@ -202,6 +279,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
202
279
  register({
203
280
  kind: "exact",
204
281
  path: "/api/dsh-mneme/info",
282
+ policy: "open",
205
283
  handler(req, res) {
206
284
  sendJson(res, 200, { version: PACKAGE_VERSION });
207
285
  }
@@ -214,6 +292,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
214
292
  register({
215
293
  kind: "exact",
216
294
  path: "/api/dsh-mneme/version-check",
295
+ policy: "open",
217
296
  handler(req, res) {
218
297
  if (req.method !== "GET") {
219
298
  sendJson(res, 404, { error: "not-found" });
@@ -233,11 +312,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
233
312
  register({
234
313
  kind: "exact",
235
314
  path: "/api/dsh-mneme/list",
315
+ policy: "open",
236
316
  handler(req, res) {
237
317
  try {
238
318
  const url = new URL(req.url, "http://localhost");
239
319
  const type = url.searchParams.get("type") ?? undefined;
240
- const limit = Number(url.searchParams.get("limit") ?? 50);
320
+ // 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
321
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
241
322
  const offset = Number(url.searchParams.get("offset") ?? 0);
242
323
  // order=chrono: pure newest-first for paged browsing; default keeps
243
324
  // the importance-ranked order other callers rely on.
@@ -289,6 +370,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
289
370
  register({
290
371
  kind: "exact",
291
372
  path: "/api/dsh-mneme/recall-stats",
373
+ policy: "open",
292
374
  handler(req, res) {
293
375
  if (req.method !== "GET") {
294
376
  sendJson(res, 404, { error: "not-found" });
@@ -308,11 +390,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
308
390
  register({
309
391
  kind: "exact",
310
392
  path: "/api/dsh-mneme/search",
393
+ policy: "open",
311
394
  handler(req, res) {
312
395
  try {
313
396
  const url = new URL(req.url, "http://localhost");
314
397
  const q = url.searchParams.get("q") ?? "";
315
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
398
+ // 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
399
+ const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
316
400
  // mode selects the recall strategy (defaults to auto):
317
401
  // auto (default) keyword first, vector fills remaining slots
318
402
  // hybrid vector first, keyword fills remaining slots; scores of
@@ -352,11 +436,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
352
436
  register({
353
437
  kind: "exact",
354
438
  path: "/api/dsh-mneme/profile",
439
+ policy: "token-write",
355
440
  handler(req, res) {
356
441
  try {
357
442
  if (req.method === "PUT" || req.method === "POST") {
358
- if (!requireAuth(req, res, apiToken)) return;
359
- return readBody(req).then((text) => {
443
+ return readBody(req, res).then((text) => {
444
+ if (text === null) return;
360
445
  const body = parseBody(text);
361
446
  settings.setProfile(typeof body.profile === "string" ? body.profile : "");
362
447
  sendJson(res, 200, { profile: settings.getProfile() });
@@ -369,6 +454,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
369
454
  }
370
455
  });
371
456
 
457
+ // --- agent persona(人格定义页,beta) -------------------------------------
458
+ // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
459
+ // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
460
+ // default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
461
+ // 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
462
+ register({
463
+ kind: "exact",
464
+ path: "/api/dsh-mneme/persona",
465
+ policy: "token-write",
466
+ handler(req, res) {
467
+ try {
468
+ if (req.method === "PUT" || req.method === "POST") {
469
+ return readBody(req, res).then((text) => {
470
+ if (text === null) return;
471
+ // parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
472
+ // 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
473
+ // 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
474
+ let body;
475
+ let jsonOk = true;
476
+ try {
477
+ body = JSON.parse(text || "{}");
478
+ } catch {
479
+ body = {};
480
+ jsonOk = false;
481
+ }
482
+ if (jsonOk && body && typeof body === "object") {
483
+ settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
484
+ }
485
+ sendJson(res, 200, { persona: settings.getPersona() });
486
+ });
487
+ }
488
+ sendJson(res, 200, { persona: settings.getPersona() });
489
+ } catch {
490
+ sendJson(res, 500, { error: "internal" });
491
+ }
492
+ }
493
+ });
494
+
372
495
  // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
373
496
  // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
374
497
  // through the same harness path the consolidation uses and reports success /
@@ -376,13 +499,30 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
376
499
  // the same error wording the audit rows carry. Optional reasoningEffort
377
500
  // mirrors what the consolidation would send — the panel can verify a model
378
501
  // actually accepts the configured effort without tripping a full run.
502
+ //
503
+ // 档位被拒时探针必须和真实 run 一样降级:这里是全仓唯一「接受并转发调用方档位、
504
+ // 却此前没有兜底」的调用面——巩固/睡眠/蒸馏/总览/实体抽取都包在 withEffortFallback
505
+ // 里,档位被拒就丢掉字段重试一次(issue #9/#109/#135)。直调 llm.stream 的其余各面
506
+ // 要么本来就在兜底之内(dream.js/summarize.js/sleep.js 的共用辅助),要么是
507
+ // src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样根本不转发档位。
508
+ // 探针原先没有这层,于是同一份配置「后台真的跑得通、点一下测试就报红」,把用户
509
+ // 引向「换哪家 Provider 都失败」的错误诊断(issue #215 的后续)。降级跑通后响应带
510
+ // effortDropped:true,面板据此说明「档位不被接受、已按无档位连通」,而不是与「真的
511
+ // 连不上」同色。
379
512
  async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
380
513
  const startedAt = Date.now();
381
514
  const elapsed = () => Date.now() - startedAt;
382
515
  const modelId = `${provider}:${model}`;
383
- try {
516
+ // 流级失败原因:withEffortFallback 只用它做拒收甄别,回给面板的仍是同一条
517
+ // describeStreamFailure 文案(与审计行同口径,不制造第二套措辞)。
518
+ let streamError = "";
519
+ // 降级标记刻意放在 attempt 之外:fallback 里置位、attempt 里读出,结果对象
520
+ // 据此带上 effortDropped——降级是「换个姿势跑通了」,不是失败。
521
+ let droppedEffort = false;
522
+
523
+ const attempt = async (effort) => {
524
+ streamError = "";
384
525
  let reply = "";
385
- let error = "";
386
526
  for await (const chunk of llm.stream({
387
527
  provider,
388
528
  model,
@@ -391,7 +531,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
391
531
  // reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
392
532
  // 按钮无放大风险。
393
533
  maxTokens: 1024,
394
- ...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
534
+ ...(effort && effort !== "none" ? { reasoningEffort: effort } : {}),
395
535
  // role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
396
536
  // 抽成顶层参数,system-only 对话会被官方 API 以
397
537
  // 「messages: at least one message is required」整单拒绝——探测按钮 502。
@@ -406,12 +546,39 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
406
546
  })) {
407
547
  if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
408
548
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
409
- error = describeStreamFailure(chunk.reason);
549
+ streamError = describeStreamFailure(chunk.reason);
410
550
  break;
411
551
  }
412
552
  }
413
- if (error) return { ok: false, durationMs: elapsed(), error, modelId };
414
- return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
553
+ const dropped = droppedEffort ? { effortDropped: true } : {};
554
+ if (streamError) {
555
+ // 只在「确实带了档位」时把流级拒收交回给 withEffortFallback 的 undefined
556
+ // 分支——rc.1 的适配器不抛错,把 provider 的拒收折叠成终止 finish chunk,
557
+ // 不看这里那次重试就是死代码。不带档位时原样上报:探针不该因为一句文案
558
+ // 没匹配上正则就退化成 500。
559
+ if (effort && effort !== "none" && EFFORT_REJECT_RE.test(streamError)) return undefined;
560
+ // 失败行不带 effortDropped:该字段只有一个含义——「连通了,但不是用你配的
561
+ // 档位连通的」(见下方成功分支的注释),失败时它只是噪音。
562
+ return { ok: false, durationMs: elapsed(), error: streamError, modelId };
563
+ }
564
+ return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100), ...dropped };
565
+ };
566
+
567
+ try {
568
+ const result = await withEffortFallback(
569
+ ctx,
570
+ reasoningEffort,
571
+ () => attempt(reasoningEffort),
572
+ () => { droppedEffort = true; return attempt(""); },
573
+ () => streamError
574
+ );
575
+ // result === undefined 只可能来自上面那次「判据一致」的主动交还(两边用同
576
+ // 一份 EFFORT_REJECT_RE,走到这里等于假设被推翻):退回已捕获的流失败原
577
+ // 因,绝不让探针 500。
578
+ if (result === undefined) {
579
+ return { ok: false, durationMs: elapsed(), error: streamError || "no stream result", modelId };
580
+ }
581
+ return result;
415
582
  } catch (error) {
416
583
  return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
417
584
  }
@@ -424,6 +591,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
424
591
  register({
425
592
  kind: "exact",
426
593
  path: "/api/dsh-mneme/llm-providers",
594
+ policy: "open",
427
595
  handler(req, res) {
428
596
  try {
429
597
  const llm = ctx.llm;
@@ -452,11 +620,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
452
620
  register({
453
621
  kind: "exact",
454
622
  path: "/api/dsh-mneme/test-model",
623
+ policy: "token",
455
624
  handler(req, res) {
456
625
  try {
457
626
  if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
458
- if (!requireAuth(req, res, apiToken)) return;
459
- return readBody(req).then(async (text) => {
627
+ return readBody(req, res).then(async (text) => {
628
+ if (text === null) return;
460
629
  const body = parseBody(text);
461
630
  let provider = typeof body.provider === "string" ? body.provider.trim() : "";
462
631
  let model = typeof body.model === "string" ? body.model.trim() : "";
@@ -487,14 +656,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
487
656
  register({
488
657
  kind: "exact",
489
658
  path: "/api/dsh-mneme/delete",
659
+ policy: "token",
490
660
  handler(req, res) {
491
661
  try {
492
662
  if (req.method !== "POST") {
493
663
  sendJson(res, 404, { error: "not-found" });
494
664
  return;
495
665
  }
496
- if (!requireAuth(req, res, apiToken)) return;
497
- return readBody(req).then((text) => {
666
+ return readBody(req, res).then((text) => {
667
+ if (text === null) return;
498
668
  const body = parseBody(text);
499
669
  const id = typeof body.id === "string" ? body.id.trim() : "";
500
670
  if (!id) {
@@ -524,14 +694,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
524
694
  register({
525
695
  kind: "exact",
526
696
  path: "/api/dsh-mneme/update",
697
+ policy: "token",
527
698
  handler(req, res) {
528
699
  try {
529
700
  if (req.method !== "POST") {
530
701
  sendJson(res, 404, { error: "not-found" });
531
702
  return;
532
703
  }
533
- if (!requireAuth(req, res, apiToken)) return;
534
- return readBody(req).then((text) => {
704
+ return readBody(req, res).then((text) => {
705
+ if (text === null) return;
535
706
  const body = parseBody(text);
536
707
  const id = typeof body.id === "string" ? body.id.trim() : "";
537
708
  if (!id) {
@@ -630,14 +801,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
630
801
  register({
631
802
  kind: "exact",
632
803
  path: "/api/dsh-mneme/create",
804
+ policy: "token",
633
805
  handler(req, res) {
634
806
  try {
635
807
  if (req.method !== "POST") {
636
808
  sendJson(res, 404, { error: "not-found" });
637
809
  return;
638
810
  }
639
- if (!requireAuth(req, res, apiToken)) return;
640
- return readBody(req).then((text) => {
811
+ return readBody(req, res).then((text) => {
812
+ if (text === null) return;
641
813
  const body = parseBody(text);
642
814
  if (!TYPES.has(body.type)) {
643
815
  sendJson(res, 400, { error: "invalid-type" });
@@ -712,10 +884,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
712
884
  register({
713
885
  kind: "exact",
714
886
  path: "/api/dsh-mneme/maintenance/reclaim",
887
+ policy: "token",
715
888
  handler(req, res) {
716
889
  try {
717
890
  if (req.method === "GET") {
718
- if (!requireAuth(req, res, apiToken)) return;
719
891
  if (!maintenance) {
720
892
  sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
721
893
  return;
@@ -738,8 +910,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
738
910
  sendJson(res, 404, { error: "not-found" });
739
911
  return;
740
912
  }
741
- if (!requireAuth(req, res, apiToken)) return;
742
- return readBody(req).then((text) => {
913
+ return readBody(req, res).then((text) => {
914
+ if (text === null) return;
743
915
  const parsed = parseReclaimJson(text);
744
916
  if (!parsed.ok) {
745
917
  sendJson(res, 400, { error: parsed.error });
@@ -774,11 +946,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
774
946
  register({
775
947
  kind: "exact",
776
948
  path: "/api/dsh-mneme/rules",
949
+ policy: "token-write",
777
950
  handler(req, res) {
778
951
  try {
779
952
  if (req.method === "PUT" || req.method === "POST") {
780
- if (!requireAuth(req, res, apiToken)) return;
781
- return readBody(req).then((text) => {
953
+ return readBody(req, res).then((text) => {
954
+ if (text === null) return;
782
955
  const body = parseBody(text);
783
956
  settings.setRules(Array.isArray(body.rules) ? body.rules : []);
784
957
  sendJson(res, 200, { rules: settings.getRules() });
@@ -795,12 +968,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
795
968
  register({
796
969
  kind: "exact",
797
970
  path: "/api/dsh-mneme/vector-config",
971
+ policy: "token",
798
972
  handler(req, res) {
799
973
  try {
800
974
  // Secret-bearing endpoint: fully protected when apiToken is set.
801
- if (!requireAuth(req, res, apiToken)) return;
802
975
  if (req.method === "PUT" || req.method === "POST") {
803
- return readBody(req).then((text) => {
976
+ return readBody(req, res).then((text) => {
977
+ if (text === null) return;
804
978
  const body = parseBody(text);
805
979
  // An empty apiKey, or one that already looks masked (round-trips
806
980
  // through maskApiKey unchanged), means "keep the existing key".
@@ -832,9 +1006,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
832
1006
  register({
833
1007
  kind: "exact",
834
1008
  path: "/api/dsh-mneme/vector-reindex",
1009
+ policy: "token",
835
1010
  handler(req, res) {
836
1011
  try {
837
- if (!requireAuth(req, res, apiToken)) return;
838
1012
  if (!embedder) {
839
1013
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
840
1014
  return;
@@ -864,6 +1038,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
864
1038
  register({
865
1039
  kind: "exact",
866
1040
  path: "/api/dsh-mneme/semantic",
1041
+ policy: "open",
867
1042
  handler(req, res) {
868
1043
  try {
869
1044
  const stats = semantic?.vectorIndex?.getStats?.() ?? null;
@@ -924,14 +1099,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
924
1099
  register({
925
1100
  kind: "exact",
926
1101
  path: "/api/dsh-mneme/runtime/provision",
1102
+ policy: "token",
927
1103
  handler(req, res) {
928
1104
  try {
929
1105
  if (req.method !== "POST") {
930
1106
  sendJson(res, 405, { error: "method-not-allowed" });
931
1107
  return;
932
1108
  }
933
- if (!requireAuth(req, res, apiToken)) return;
934
- return readBody(req).then(async (text) => {
1109
+ return readBody(req, res).then(async (text) => {
1110
+ if (text === null) return;
935
1111
  const body = parseBody(text);
936
1112
  const runtimeDir = config?.runtimeDir ?? "";
937
1113
  // 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
@@ -961,6 +1137,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
961
1137
  register({
962
1138
  kind: "exact",
963
1139
  path: "/api/dsh-mneme/semantic/llm-audit",
1140
+ policy: "open",
964
1141
  handler(req, res) {
965
1142
  try {
966
1143
  const url = new URL(req.url, "http://localhost");
@@ -983,6 +1160,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
983
1160
  register({
984
1161
  kind: "exact",
985
1162
  path: "/api/dsh-mneme/semantic/llm-audit/stats",
1163
+ policy: "open",
986
1164
  handler(req, res) {
987
1165
  try {
988
1166
  const url = new URL(req.url, "http://localhost");
@@ -1006,6 +1184,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1006
1184
  register({
1007
1185
  kind: "exact",
1008
1186
  path: "/api/dsh-mneme/semantic/graph/ego",
1187
+ policy: "open",
1009
1188
  handler(req, res) {
1010
1189
  try {
1011
1190
  const url = new URL(req.url, "http://localhost");
@@ -1083,6 +1262,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1083
1262
  register({
1084
1263
  kind: "exact",
1085
1264
  path: "/api/dsh-mneme/semantic/graph/entity-attrs",
1265
+ policy: "open",
1086
1266
  handler(req, res) {
1087
1267
  try {
1088
1268
  const url = new URL(req.url, "http://localhost");
@@ -1121,6 +1301,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1121
1301
  register({
1122
1302
  kind: "exact",
1123
1303
  path: "/api/dsh-mneme/entities",
1304
+ policy: "open",
1124
1305
  handler(req, res) {
1125
1306
  try {
1126
1307
  const url = new URL(req.url, "http://localhost");
@@ -1140,6 +1321,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1140
1321
  register({
1141
1322
  kind: "exact",
1142
1323
  path: "/api/dsh-mneme/memories/entities",
1324
+ policy: "open",
1143
1325
  handler(req, res) {
1144
1326
  try {
1145
1327
  const url = new URL(req.url, "http://localhost");
@@ -1170,8 +1352,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1170
1352
  register({
1171
1353
  kind: "exact",
1172
1354
  path: "/api/dsh-mneme/export",
1355
+ policy: "token",
1173
1356
  handler(req, res) {
1174
1357
  try {
1358
+ // 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
1359
+ // 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
1360
+ // apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
1175
1361
  const url = new URL(req.url, "http://localhost");
1176
1362
  const format = url.searchParams.get("format") ?? "json";
1177
1363
  if (format !== "json" && format !== "markdown") {
@@ -1220,14 +1406,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1220
1406
  register({
1221
1407
  kind: "exact",
1222
1408
  path: "/api/dsh-mneme/import",
1409
+ policy: "token",
1223
1410
  handler(req, res) {
1224
1411
  try {
1225
1412
  if (req.method !== "POST") {
1226
1413
  sendJson(res, 404, { error: "not-found" });
1227
1414
  return;
1228
1415
  }
1229
- if (!requireAuth(req, res, apiToken)) return;
1230
- return readBody(req).then((text) => {
1416
+ return readBody(req, res).then((text) => {
1417
+ if (text === null) return;
1231
1418
  const body = parseBody(text);
1232
1419
  if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
1233
1420
  sendJson(res, 400, { error: "invalid-type" });
@@ -1261,6 +1448,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1261
1448
  register({
1262
1449
  kind: "exact",
1263
1450
  path: "/api/dsh-mneme/dream-status",
1451
+ policy: "open",
1264
1452
  handler(req, res) {
1265
1453
  try {
1266
1454
  const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
@@ -1305,13 +1493,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1305
1493
  register({
1306
1494
  kind: "exact",
1307
1495
  path: "/api/dsh-mneme/conflicts",
1496
+ policy: "token",
1308
1497
  handler(req, res) {
1309
1498
  try {
1310
1499
  if (req.method !== "GET") {
1311
1500
  sendJson(res, 404, { error: "not-found" });
1312
1501
  return;
1313
1502
  }
1314
- if (!requireAuth(req, res, apiToken)) return;
1315
1503
  const url = new URL(req.url, "http://localhost");
1316
1504
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
1317
1505
  const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
@@ -1326,14 +1514,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1326
1514
  register({
1327
1515
  kind: "exact",
1328
1516
  path: "/api/dsh-mneme/conflicts/resolve",
1517
+ policy: "token",
1329
1518
  handler(req, res) {
1330
1519
  try {
1331
1520
  if (req.method !== "POST") {
1332
1521
  sendJson(res, 404, { error: "not-found" });
1333
1522
  return;
1334
1523
  }
1335
- if (!requireAuth(req, res, apiToken)) return;
1336
- return readBody(req).then((text) => {
1524
+ return readBody(req, res).then((text) => {
1525
+ if (text === null) return;
1337
1526
  let body = {};
1338
1527
  try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
1339
1528
  const id = typeof body.id === "string" ? body.id.trim() : "";
@@ -1363,8 +1552,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1363
1552
  register({
1364
1553
  kind: "exact",
1365
1554
  path: "/api/dsh-mneme/health",
1555
+ policy: "token",
1366
1556
  handler(req, res) {
1367
- if (!requireAuth(req, res, apiToken)) return;
1368
1557
  let state = null;
1369
1558
  try {
1370
1559
  state = service.getMirrorHealth?.() ?? null;
@@ -1412,11 +1601,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1412
1601
  register({
1413
1602
  kind: "exact",
1414
1603
  path: "/api/dsh-mneme/mode",
1604
+ policy: "token-write",
1415
1605
  handler(req, res) {
1416
1606
  try {
1417
1607
  if (req.method === "PUT" || req.method === "POST") {
1418
- if (!requireAuth(req, res, apiToken)) return;
1419
- return readBody(req).then((text) => {
1608
+ return readBody(req, res).then((text) => {
1609
+ if (text === null) return;
1420
1610
  const body = parseBody(text);
1421
1611
  if (body.mode !== "light" && body.mode !== "standard") {
1422
1612
  sendJson(res, 400, { error: "invalid-mode" });
@@ -1437,11 +1627,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1437
1627
  register({
1438
1628
  kind: "exact",
1439
1629
  path: "/api/dsh-mneme/external-api",
1630
+ policy: "token",
1440
1631
  handler(req, res) {
1441
1632
  try {
1442
1633
  if (req.method === "PUT" || req.method === "POST") {
1443
- if (!requireAuth(req, res, apiToken)) return;
1444
- return readBody(req).then((text) => {
1634
+ return readBody(req, res).then((text) => {
1635
+ if (text === null) return;
1445
1636
  const body = parseBody(text);
1446
1637
  const patch = {};
1447
1638
  if (body.enabled !== undefined) patch.enabled = body.enabled === true;
@@ -1464,9 +1655,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1464
1655
  sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
1465
1656
  });
1466
1657
  }
1467
- // GET: always hand back a token — the standalone server generates its
1468
- // own on first enabled boot, but the panel displays it before that,
1469
- // so materialize and persist one here.
1658
+ // GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
1659
+ // PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
1660
+ // 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
1661
+ // GET: hand back a token — the standalone server generates its own on
1662
+ // first enabled boot, but the panel displays it before that, so
1663
+ // materialize and persist one here. 只有通过上面那道门才会走到这里,
1664
+ // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
1470
1665
  const kv = settings.getExternalApi() ?? {};
1471
1666
  if (!kv.token) {
1472
1667
  kv.token = randomBytes(24).toString("base64url");
@@ -1488,11 +1683,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1488
1683
  register({
1489
1684
  kind: "exact",
1490
1685
  path: "/api/dsh-mneme/features",
1686
+ policy: "token-write",
1491
1687
  handler(req, res) {
1492
1688
  try {
1493
1689
  if (req.method === "PUT" || req.method === "POST") {
1494
- if (!requireAuth(req, res, apiToken)) return;
1495
- return readBody(req).then((text) => {
1690
+ return readBody(req, res).then((text) => {
1691
+ if (text === null) return;
1496
1692
  const body = parseBody(text);
1497
1693
  if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
1498
1694
  sendJson(res, 400, { error: "invalid-patch" });
@@ -1522,6 +1718,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1522
1718
  register({
1523
1719
  kind: "exact",
1524
1720
  path: "/api/dsh-mneme/inject-status",
1721
+ policy: "open",
1525
1722
  handler(req, res) {
1526
1723
  try {
1527
1724
  if (req.method !== "GET") {
@@ -1546,6 +1743,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1546
1743
  register({
1547
1744
  kind: "exact",
1548
1745
  path: "/api/dsh-mneme/inject-preview",
1746
+ policy: "open",
1549
1747
  handler(req, res) {
1550
1748
  try {
1551
1749
  if (req.method !== "GET") {
@@ -1563,11 +1761,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1563
1761
  register({
1564
1762
  kind: "exact",
1565
1763
  path: "/api/dsh-mneme/commands",
1764
+ policy: "token-write",
1566
1765
  handler(req, res) {
1567
1766
  try {
1568
1767
  if (req.method === "POST") {
1569
- if (!requireAuth(req, res, apiToken)) return;
1570
- return readBody(req).then((text) => {
1768
+ return readBody(req, res).then((text) => {
1769
+ if (text === null) return;
1571
1770
  const body = parseBody(text);
1572
1771
  try {
1573
1772
  const command = commands.add({
@@ -1582,7 +1781,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1582
1781
  });
1583
1782
  }
1584
1783
  if (req.method === "DELETE") {
1585
- if (!requireAuth(req, res, apiToken)) return;
1586
1784
  const url = new URL(req.url, "http://localhost");
1587
1785
  const id = url.searchParams.get("id");
1588
1786
  const removed = id ? commands.remove(id) : false;
@@ -1596,8 +1794,151 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1596
1794
  }
1597
1795
  });
1598
1796
 
1797
+ // --- M.O.(Modus Operandi · 手法)工作流 ---
1798
+ // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1799
+ // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1800
+ // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
1801
+ // 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
1802
+ // 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
1803
+ // runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
1804
+ // 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
1805
+ // 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
1806
+ register({
1807
+ kind: "exact",
1808
+ path: "/api/dsh-mneme/pipeline",
1809
+ policy: "open",
1810
+ handler(req, res) {
1811
+ try {
1812
+ const url = new URL(req.url, "http://localhost");
1813
+ const id = url.searchParams.get("id") || null;
1814
+ const project = url.searchParams.get("project");
1815
+ // project 是**会话工作目录**,由面板侧解析后带过来——工作目录只有客户端拿得到
1816
+ // (宿主会话头里的 header.cwd),服务端没有「当前项目」这个概念。
1817
+ // 不是绝对路径就**拒掉**,不能忽略:忽略了会静默退成「全局」,而全局看起来
1818
+ // 一切正常——那正是这版要修掉的那类错。
1819
+ if (project !== null && !isAbsolute(project)) {
1820
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
1821
+ return;
1822
+ }
1823
+
1824
+ // 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
1825
+ // 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
1826
+ // runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
1827
+ // 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
1828
+ // ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
1829
+ // 复盘注释见下方旁挂块)。
1830
+ let runIds = [];
1831
+ let runsResult = { runsById: {}, errors: [], statesById: {} };
1832
+ if (project !== null) {
1833
+ const where = runsDir(project);
1834
+ runIds = existsSync(where)
1835
+ ? readdirSync(where)
1836
+ .filter((f) => f.endsWith(RUNS_SUFFIX))
1837
+ .sort()
1838
+ .map((f) => f.slice(0, -RUNS_SUFFIX.length))
1839
+ : [];
1840
+ if (runIds.length) {
1841
+ // step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
1842
+ // 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
1843
+ // 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
1844
+ let stepIdsById = null;
1845
+ try {
1846
+ stepIdsById = Object.create(null);
1847
+ for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
1848
+ stepIdsById[p.id] = p.steps.map((s) => s.id);
1849
+ }
1850
+ } catch {
1851
+ // 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
1852
+ // 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
1853
+ stepIdsById = null;
1854
+ }
1855
+ runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
1856
+ }
1857
+ }
1858
+ const { runsById, errors: runsErrors, statesById } = runsResult;
1859
+
1860
+ // P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
1861
+ // 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
1862
+ let observationsById = {};
1863
+ if (project !== null) {
1864
+ try {
1865
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
1866
+ ? config.memoryDir
1867
+ : "~/.dsh/memory";
1868
+ const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
1869
+ if (odir) {
1870
+ observationsById = loadObservationsById(odir, runIds.filter(Boolean));
1871
+ }
1872
+ } catch {
1873
+ // 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
1874
+ }
1875
+ }
1876
+
1877
+ const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
1878
+
1879
+ // 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
1880
+ // **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
1881
+ // pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
1882
+ // 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
1883
+ // 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
1884
+ // 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
1885
+ const chosenId = doc.pipeline ? doc.pipeline.id : null;
1886
+ const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
1887
+ const available =
1888
+ project === null
1889
+ ? doc.available
1890
+ : doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
1891
+
1892
+ sendJson(res, 200, { ...doc, available, runsState, runsErrors });
1893
+ } catch (error) {
1894
+ // 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
1895
+ // 而不是回一句 internal 让人去翻日志。
1896
+ sendJson(res, 500, {
1897
+ error: "pipeline-unreadable",
1898
+ detail: String((error && error.message) || error)
1899
+ });
1900
+ }
1901
+ }
1902
+ });
1903
+
1904
+ // --- M.O. 候选层(绕行走出来、还没进仓库的那些路) ---
1905
+ // 和 /pipeline 读的是两处不同的东西,别混:
1906
+ // /pipeline 定稿,住**仓库**(<项目>/.mneme/pipelines/),可提交、可 review
1907
+ // /candidates 候选,住**本地**(<memoryDir>/candidates/<项目键>/),随时长、随时扔
1908
+ // 分开是刻意的:候选是草稿,一天可能长十条死九条,进仓库就是垃圾;
1909
+ // 而定稿只放人亲手批过的东西——候选是定稿的唯一入口。
1910
+ //
1911
+ // 这一刀只给**读**:提议与批准的动作还没有落点(那要等执行侧)。
1912
+ // 契约里显式写着 boundaries.autoPromote/autoApprove/injectIntoMemory 三个 false,
1913
+ // 免得以后有人以为忘了做。
1914
+ register({
1915
+ kind: "exact",
1916
+ path: "/api/dsh-mneme/candidates",
1917
+ policy: "open",
1918
+ handler(req, res) {
1919
+ try {
1920
+ const url = new URL(req.url, "http://localhost");
1921
+ const project = url.searchParams.get("project");
1922
+ if (project !== null && !isAbsolute(project)) {
1923
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
1924
+ return;
1925
+ }
1926
+ // memoryDir 从 config 拿(与 store 同源);取不到就退回默认,与 config.js 的默认值一致。
1927
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
1928
+ ? config.memoryDir
1929
+ : "~/.dsh/memory";
1930
+ sendJson(res, 200, readCandidateDoc({ memoryDir, projectRoot: project }));
1931
+ } catch (error) {
1932
+ sendJson(res, 500, {
1933
+ error: "candidates-unreadable",
1934
+ detail: String((error && error.message) || error)
1935
+ });
1936
+ }
1937
+ }
1938
+ });
1939
+
1599
1940
  return {
1600
- routes: 34,
1941
+ routes: countRoutes(),
1601
1942
  dispose: () => {
1602
1943
  for (const dispose of disposers) dispose();
1603
1944
  }