@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
package/src/api.js CHANGED
@@ -7,6 +7,9 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
7
7
  import { TYPES } from "./store.js";
8
8
  import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
9
9
  import { langOf } from "./lang.js";
10
+ // issue #607:面板空态要显示「能直接粘贴就跑」的命令,而面板 bundle 拿不到
11
+ // CLI 入口路径(独立 bundle 不能 import src)——所以由这条路由把命令算好发给它。
12
+ import { choroCliCommandFrom } from "./wiki/cli-hint.js";
10
13
  // op 形状谓词单一份(评审 P3:两份皮会让形状修洞漏一边)。
11
14
  import { isValidFactOps } from "./profile-facts.js";
12
15
  import { computeHeat } from "./heat.js";
@@ -15,6 +18,7 @@ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallba
15
18
  // decisions.js,与产生这些 error 的校验器同一文件,避免措辞漂移)。
16
19
  import { summarizeSkipped } from "./dream/decisions.js";
17
20
  import { getInjectionSnapshot } from "./inject.js";
21
+ import { invalidPeakSpecs } from "./peak-hours.js";
18
22
  import { renderUserSettingsBlock } from "./user-settings.js";
19
23
  import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
20
24
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
@@ -30,10 +34,23 @@ import { bootstrapFromDirectory } from "./bootstrap.js";
30
34
  // 这里引的是同一份(不是照抄),谁改白名单都对得上。
31
35
  import { describeConfigKeys, flagKeysFromSpec } from "./config-keys.js";
32
36
  import { parseShareBody, shareReply, shareFilename, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
37
+ // 知识库第二层(契约 §3.6):路径表与 handler 全在 kb/routes.js 里,本文件只负责
38
+ // 把 kbGate 与基础设施递进去。**不在这里登记任何 /kb/* 路径**——registrar 对重复的
39
+ // kind+path 直接抛,插件会起不来。
40
+ import { registerKbRoutes } from "./kb/routes.js";
33
41
  import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
34
42
  import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
35
43
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
36
44
  import { projectKey } from "./candidates.js";
45
+ // 日记本(v0.13):读侧三件(列表/正文/账本)在 diary/index.js 里都是纯函数级的小实现,
46
+ // 路由只做参数校验与闸门——把口径写在这里会让「面板与 CLI 读的是同一份」破功。
47
+ import {
48
+ diaryDir, listDiaries, readDiary, exportDiary,
49
+ // 跨桶清单(左侧「ta 的日记」面板):它拿不到当前会话的项目,只能整体读。
50
+ // isBucketName 也走同一处:清单与单篇读取必须用同一道判据,否则会出现
51
+ // 「列得出来、点进去 400」。
52
+ listDiaryLibrary, readLibraryEntry, isBucketName
53
+ } from "./diary/index.js";
37
54
  import { readCandidateDoc } from "./candidates.js";
38
55
  // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
39
56
  // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
@@ -44,6 +61,10 @@ import {
44
61
  NUDGE_AUTO_BUDGET_MS
45
62
  } from "./pipeline-nudge.js";
46
63
 
64
+ // 面板空态与 409 hint 共用的 CLI 命令串(#607):本文件在 src/ 与 lib/ 同一层,
65
+ // 模块加载时推导一次。推导不出时为 null,两处各自退回通用说法。
66
+ const CHORO_CLI = choroCliCommandFrom(import.meta.url, "<repo>");
67
+
47
68
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
48
69
  function sendJson(res, status, payload, headers = {}) {
49
70
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
@@ -202,7 +223,13 @@ function parseBody(text) {
202
223
  // choro(M1):chorography 读侧查询对象(createChoroQueries 产物)。缺省 null =
203
224
  // 宿主内 API 在 headless/测试环境没有接 queries,choro 路由回 503。它与 service
204
225
  // 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3),不塞进 service。
205
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null, facts = null) {
226
+ // kb(第二层):知识库装配对象(createKb 产物)。缺省 null = 这个宿主没装它,
227
+ // /kb/* 一律 503(装配缺件),与开关关着的 409(用户没开)是两件事。
228
+ // diary(v0.13)是第 14 个位置参数,缺省 null:旧调用点(测试里 14 处)继续工作,
229
+ // 不传时日记路由一律 503 diary-unavailable(装配缺件),而不是伪装成「用户没开」。
230
+ // 与 choro 同一套 503/409 分叉:#497 定下「503 = 环境缺件,409 = 用户没开」。
231
+ // 顺序是刻意的:kb 先占第 13 位(它先合进 main),diary 追加在其后,谁先合谁靠前。
232
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null, facts = null, kb = null, diary = null) {
206
233
  const disposers = [];
207
234
 
208
235
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -213,12 +240,28 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
213
240
  // 的 SessionHeader,降级 requestHeader().config;agentPreset 取不到留 null,
214
241
  // 面板对 null 静默)。任何失败静默:纯可观测性,不新增故障面。
215
242
  let lastAgentPreset = null;
243
+ // 会话 → 身份名的账本(人格页与手动写日记用)。lastAgentPreset 只记「最近一次」,
244
+ // 而手动写日记必须知道「这个会话是谁」,否则只能落 default 卡——那正是
245
+ // 「设定好人格的 agent 只以这个人格的口吻写日记」这条需求的缺口。
246
+ // 只存 id → 身份名,容量封顶:会话对象不进这里,避免把大对象挂住。
247
+ const agentBySession = new Map();
248
+ const AGENT_BY_SESSION_MAX = 200;
216
249
  try {
217
250
  const unsubscribe = ctx.on?.("session/event", (session) => {
218
251
  try {
219
252
  const header = session?.header ?? session?.requestHeader?.()?.config ?? null;
220
253
  const preset = header?.agentPreset;
221
- lastAgentPreset = typeof preset === "string" && preset.trim() ? preset.trim() : null;
254
+ const name = typeof preset === "string" && preset.trim() ? preset.trim() : null;
255
+ lastAgentPreset = name;
256
+ const id = typeof session?.id === "string" ? session.id.trim() : "";
257
+ if (id) {
258
+ // 先删再插:Map 的迭代顺序即「最近活跃」,满了就淘汰最旧的键。
259
+ agentBySession.delete(id);
260
+ agentBySession.set(id, name);
261
+ while (agentBySession.size > AGENT_BY_SESSION_MAX) {
262
+ agentBySession.delete(agentBySession.keys().next().value);
263
+ }
264
+ }
222
265
  } catch { /* 观测失败不影响会话流 */ }
223
266
  });
224
267
  if (typeof unsubscribe === "function") disposers.push(unsubscribe);
@@ -269,7 +312,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
269
312
  // Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
270
313
  // 同上(effective 静默缺键、面板读不到也 PUT 不进)。
271
314
  "scopeState.enabled": ["scopeState", "enabled"],
272
- "scopeState.enforce": ["scopeState", "enforce"]
315
+ "scopeState.enforce": ["scopeState", "enforce"],
316
+ // #275 容量预警:三个阈值都是嵌套子字段(两个整数走 INT_RANGES、一个浮点走
317
+ // NUMBER_RANGES),漏登记的后果同上——effective 静默缺键、面板读不到也 PUT 不进。
318
+ "capacityWarning.dbSizeWarnMb": ["capacityWarning", "dbSizeWarnMb"],
319
+ "capacityWarning.archivedRowsWarn": ["capacityWarning", "archivedRowsWarn"],
320
+ "capacityWarning.archivedRatioWarn": ["capacityWarning", "archivedRatioWarn"]
273
321
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
274
322
  // 的默认分支即可。
275
323
  };
@@ -301,7 +349,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
301
349
  if (value !== undefined) effective[key] = value;
302
350
  }
303
351
  }
304
- return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
352
+ // #622 可观测性:错峰时段串解析失败的面板可见化。校验在这里(服务端)做而不在
353
+ // 面板做——parsePeakSpec 只许有一份,面板是独立 bundle 复制一份解析器必然漂移;
354
+ // 与 config-keys 卡「分类在服务端算好、面板只渲染」同一纪律。校验对象是
355
+ // effective(overrides 压过 cfg 后的最终值):输入框显示的就是它,提示行跟它
356
+ // 对齐——改完保存(PUT 也走 featureSnapshot)提示立即跟着换。GET 与 PUT 共用
357
+ // 本函数,两个字段自动同源。
358
+ const invalidPeakHours = Object.keys(invalidPeakSpecs(effective));
359
+ return { overrides, effective, invalidPeakHours, pendingRestart: pendingRestartKeys(overrides) };
305
360
  }
306
361
 
307
362
  // Ensure the service has an embedder when the API layer was handed one
@@ -392,7 +447,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
392
447
  // merge/update verdict ∪ source=dream 直写)——状态页「查看全部」
393
448
  // 与记忆库的「沉淀」筛选 chip 共用这个视图。
394
449
  const depositedOnly = url.searchParams.get("deposited") === "only";
395
- const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
450
+ // id: 按主键取单条(含归档行)。面板打开/保存前要拿「这一条」的最新值,
451
+ // 分页扫列表最多只能看到前 1000 条,已归档的更不在默认视图里;扫不到就
452
+ // 只能拿陈旧缓存去比对 updated_at,等于没有版本检查。
453
+ const idFilter = (url.searchParams.get("id") ?? "").trim();
454
+ const rows = idFilter === ""
455
+ ? service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly })
456
+ // getById 是裸 SELECT,没有 forgotten 谓词;这条路由是 open 的,
457
+ // 不过滤等于让本机任意进程按 id 读到已遗忘记忆的全文(评审 #1)。
458
+ // 归档行仍要放行:归档 ≠ 遗忘,面板保存前要用它比对 updated_at。
459
+ : [service.getById(idFilter)].filter((memo) => memo && !memo.forgotten);
396
460
  // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
397
461
  // schema 严格复用 toApiList,扩展只发生在 HTTP 层。
398
462
  // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
@@ -415,8 +479,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
415
479
  }));
416
480
  // Total honors the same filters as the rows, or the pager's
417
481
  // has-more math breaks whenever minImportance/source/updated-at
418
- // bounds are active.
419
- sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) });
482
+ // bounds are active. 按 id 取单条时总数就是命中的行数。
483
+ const listCountFilters = { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly };
484
+ const total = idFilter === ""
485
+ ? service.count(type, listCountFilters)
486
+ : rows.length;
487
+ // #597:全库分布(同一份筛选、**不带 type**——带 type 的页数不出其它类)。
488
+ // 面板分类栏的数字源;按 id 取单条时 total 就是行数、分布无意义,这一格
489
+ // 缺省(客户端据此降级回退到页内数)。
490
+ const typeCounts = idFilter === "" ? service.countByType(listCountFilters) : undefined;
491
+ sendJson(res, 200, { items, total, ...(typeCounts ? { typeCounts } : {}) });
420
492
  } catch {
421
493
  sendJson(res, 500, { error: "internal" });
422
494
  }
@@ -591,7 +663,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
591
663
  // 预览(面板「Agent 眼中的画像」用):由注入器同一个渲染函数产出,前端只显示
592
664
  // 返回的文本——以前端自己拼列表冒充「生效文本」是这个面板最容易漂的地方。
593
665
  // ?agent=<身份> 让面板能逐张卡预览:传了就按那个身份寻址,没传按「解析不到身份」
594
- // 走(也就是 default 卡的受众)。
666
+ // 走(也就是 default 卡的受众)。?session=<会话 id>(#606)同理作用于绑会话卡,
667
+ // 优先级高于 ?agent——与 resolvePersonaCard 的寻址次序一致,预览不许比注入更乐观。
595
668
  // 这里不加鉴权是有意的:画像、规则、人格三处在同一条开放读面上本来就能各读一次
596
669
  // (GET /profile 与 GET /persona 都无 token 可读),本字段只是把同样的内容拼成
597
670
  // 模型实际看到的形状,没有新增可达面;真要锁,配 apiToken(写面与它同源)。
@@ -599,6 +672,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
599
672
  settings,
600
673
  config,
601
674
  agentId: url.searchParams.get("agent"),
675
+ sessionId: url.searchParams.get("session"),
676
+ // 父会话 id(子 agent 继承人格卡用):面板若要复现子代理看到的那一帧,
677
+ // 传父会话 id 即可,与注入器同一寻址次序——预览不许比注入更乐观。
678
+ parentSessionId: url.searchParams.get("parent"),
602
679
  // 事实段与注入共用同一渲染函数(user-settings.js 的同源纪律):装配了
603
680
  // profile-facts 就传读口,开关与有无的判定在渲染函数内部(关=零读数)。
604
681
  factsProvider: typeof facts?.factsProvider === "function" ? facts.factsProvider : null
@@ -1237,7 +1314,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1237
1314
  handler(req, res) {
1238
1315
  try {
1239
1316
  if (!embedder) {
1240
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
1317
+ sendJson(res, 200, { indexed: 0, failed: 0, skipped: 0, error: "vector-unavailable" });
1241
1318
  return;
1242
1319
  }
1243
1320
  const url = new URL(req.url, "http://localhost");
@@ -1247,13 +1324,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1247
1324
  const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
1248
1325
  const task = viaIndex
1249
1326
  ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
1250
- : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
1327
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, failed: 0, skipped: 0, error: "vector-unavailable" });
1251
1328
  // Return the chain so awaiting callers (tests/health checks) observe the
1252
1329
  // finished response rather than racing the async backfill.
1253
1330
  return task.then((result) => {
1254
1331
  sendJson(res, 200, result);
1255
1332
  }).catch(() => {
1256
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
1333
+ sendJson(res, 200, { indexed: 0, failed: 0, skipped: 0, error: "vector-failed" });
1257
1334
  });
1258
1335
  } catch {
1259
1336
  sendJson(res, 500, { error: "internal" });
@@ -1330,6 +1407,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1330
1407
  dimension: embedder?.dimension ?? null,
1331
1408
  reranker: semantic?.reranker ? "ready" : null,
1332
1409
  index: stats,
1410
+ // #604:回填与写入两条嵌入路径的诊断。此前 backfillStatus() 与
1411
+ // embedReceipts() 都已经算好了,但没有任何出口 —— 面板能看到的只有
1412
+ // 「已索引 N 条」,看不出「上一轮撞了 cap」「最近一次嵌入为什么失败」。
1413
+ // 两个字段都是只读快照,不查库、不触发推理。
1414
+ embeddings: typeof semantic?.embeddingDiagnostics === "function"
1415
+ ? semantic.embeddingDiagnostics()
1416
+ : null,
1333
1417
  // issue #131 / PR-A:自管运行时的状态。无论当前 provider 是不是 local
1334
1418
  // 都报 —— 面板需要区分「已收编但没启用」和「要用本地却没收编」。
1335
1419
  // 这里只做结构检查(不跑推理),失败降级成状态对象,不会让本端点 500。
@@ -1586,7 +1670,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1586
1670
  // 少了它们,「这条边被抬过」在面板上根本看不出来。取不到时给
1587
1671
  // 初值形状而不是 undefined,前端不必到处判空。
1588
1672
  weight: e.weight ?? 1.0,
1589
- source: e.source ?? "manual"
1673
+ source: e.source ?? "manual",
1674
+ // #619 可见化 B:边上的 metadata 必须原样透传,否则情感边写进去的
1675
+ // emotion / attribution / sessionCount / evidence 在面板上永远取不到
1676
+ // ——数据在库里、在 store.toRelation 里(src/store.js 已 JSON.parse),
1677
+ // 只在最后一跳被这个映射丢掉,读起来就像「这个功能不存在」。
1678
+ // 不裁剪字段:情感边的 metadata 由 sleep 相位写入,形状本来就会随版本
1679
+ // 演进,在 API 层挑字段等于给未来的字段再挖一个同样的坑。
1680
+ // 无 metadata 时给 null,不伪造空对象——前端要靠 null 区分「老边(抽取器
1681
+ // 写的,本来就没有情绪)」和「有 metadata 但没有 emotion」。
1682
+ metadata: e.metadata ?? null
1590
1683
  }))
1591
1684
  });
1592
1685
  } catch {
@@ -1639,6 +1732,92 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1639
1732
  }
1640
1733
  });
1641
1734
 
1735
+ // --- affective: 情绪归因一览(#619 可见化 C) ---
1736
+ // `ego` 端点只能从某个实体往外看,回答不了「这库里一共记了哪些情绪」。
1737
+ // 所以单开一条:按 person/target/emotion 聚合,occasion 与 trait 分开。
1738
+ //
1739
+ // 三处刻意的选择,都是为了让面板不撒谎:
1740
+ // - 聚键含 attribution(occasion / trait),两类不混在一条里。同一个「焦虑」
1741
+ // 在一件事上是 occasion,跨三件事才是 trait,合成一条会把「这次」说成「一贯」。
1742
+ // - trait 组带 independentSessions(跨几个独立会话)。trait 的判据本来就是会话数,
1743
+ // 面板只显示「稳定倾向」而不给数字,用户没法判断它是攒够了还是被猜测出来的。
1744
+ // - 完全读不到时返回 500 + error,不返回空列表:空列表在面板上长得跟
1745
+ // 「你还没记过任何情绪」一样,而这两件事对用户的含义完全相反。
1746
+ // 前端靠 httpStatusCode 区分,不靠 items.length。
1747
+ register({
1748
+ kind: "exact",
1749
+ path: "/api/dsh-mneme/semantic/graph/affective",
1750
+ policy: "open",
1751
+ handler(req, res) {
1752
+ try {
1753
+ const url = new URL(req.url, "http://localhost");
1754
+ const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") ?? 200) || 200));
1755
+ if (typeof service.relationsByType !== "function") {
1756
+ sendJson(res, 500, { error: "unavailable" });
1757
+ return;
1758
+ }
1759
+ const edges = service.relationsByType("affective_context", limit) ?? [];
1760
+ const groups = new Map();
1761
+ for (const e of edges) {
1762
+ const meta = e.metadata && typeof e.metadata === "object" ? e.metadata : {};
1763
+ const emotion = String(meta.emotion ?? "").trim();
1764
+ // 没有情绪名的边不进聚合:它是老版本或手工写的空壳,凑进「情绪一览」
1765
+ // 会造出一个名字为空的条目。但也不能假装它不存在——计数单列。
1766
+ const attribution = String(meta.attribution ?? "").trim() || "occasion";
1767
+ if (!emotion) continue;
1768
+ const key = `${e.from_entity}\u0000${e.to_entity}\u0000${emotion}\u0000${attribution}`;
1769
+ if (!groups.has(key)) {
1770
+ groups.set(key, {
1771
+ person: e.from_name ?? null,
1772
+ personId: e.from_entity,
1773
+ target: e.to_name ?? null,
1774
+ targetId: e.to_entity,
1775
+ emotion,
1776
+ attribution,
1777
+ promotedToTrait: meta.promotedToTrait === true,
1778
+ sessionCount: Number.isFinite(meta.sessionCount) ? meta.sessionCount : null,
1779
+ traitSessionCount: Number.isFinite(meta.traitSessionCount) ? meta.traitSessionCount : null,
1780
+ evidenceCount: Array.isArray(meta.evidence) ? meta.evidence.length : (e.memory_id ? 1 : 0),
1781
+ firstSeen: e.created_at ?? null,
1782
+ lastSeen: e.created_at ?? null,
1783
+ edgeIds: []
1784
+ });
1785
+ }
1786
+ const g = groups.get(key);
1787
+ g.edgeIds.push(e.id);
1788
+ if (e.created_at && (!g.lastSeen || e.created_at > g.lastSeen)) g.lastSeen = e.created_at;
1789
+ if (e.created_at && (!g.firstSeen || e.created_at < g.firstSeen)) g.firstSeen = e.created_at;
1790
+ if (e.from_name) g.person = e.from_name;
1791
+ if (e.to_name) g.target = e.to_name;
1792
+ if (Number.isFinite(meta.sessionCount)) {
1793
+ g.sessionCount = Math.max(g.sessionCount ?? 0, meta.sessionCount);
1794
+ }
1795
+ if (Number.isFinite(meta.traitSessionCount)) {
1796
+ g.traitSessionCount = Math.max(g.traitSessionCount ?? 0, meta.traitSessionCount);
1797
+ }
1798
+ if (meta.promotedToTrait === true) g.promotedToTrait = true;
1799
+ }
1800
+ const items = [...groups.values()].sort((a, b) => {
1801
+ // trait 在前(更值得看),同类按最近一次出现倒序。
1802
+ if ((b.attribution === "trait") !== (a.attribution === "trait")) {
1803
+ return a.attribution === "trait" ? -1 : 1;
1804
+ }
1805
+ return String(b.lastSeen ?? "").localeCompare(String(a.lastSeen ?? ""));
1806
+ });
1807
+ sendJson(res, 200, {
1808
+ items,
1809
+ // 读到了多少条原始边、其中多少条因为没情绪名没进聚合。给出来是为了
1810
+ // 让「只有 0 条」能自我解释:是库里真没有,还是有边但都不带情绪。
1811
+ totalEdges: edges.length,
1812
+ skippedEdges: edges.length - items.reduce((n, g) => n + g.edgeIds.length, 0),
1813
+ truncated: edges.length >= limit
1814
+ });
1815
+ } catch {
1816
+ sendJson(res, 500, { error: "internal" });
1817
+ }
1818
+ }
1819
+ });
1820
+
1642
1821
  // --- entity attrs: current valid attrs for one entity (graph hover panel) ---
1643
1822
  // Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
1644
1823
  // graph panel's fallback list when the ego graph is too sparse to draw.
@@ -1689,7 +1868,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1689
1868
  try {
1690
1869
  const url = new URL(req.url, "http://localhost");
1691
1870
  const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
1692
- const entities = service.listEntities?.({ limit }) ?? [];
1871
+ // q = 名字过滤。必须下推到 store:面板此前取回 limit 条再本地过滤,
1872
+ // 排在 limit 之后的实体永远搜不到(评审 #9)。老宿主没有这个形参时
1873
+ // 多传一个键不会报错,只是过滤失效——与 limit 的处理一致。
1874
+ const q = url.searchParams.get("q") ?? "";
1875
+ const entities = service.listEntities?.({ limit, q }) ?? [];
1693
1876
  // total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当
1694
1877
  // 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。
1695
1878
  // 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也
@@ -2135,6 +2318,38 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2135
2318
  }
2136
2319
  });
2137
2320
 
2321
+ // GET /conflicts/ledger(issue #641,beta 第三阶段):矛盾分诊账本的只读面——
2322
+ // 分诊分布(按 tier 计数)+ 最近账本行。conflict_ledger 是 bookkeeping 表,
2323
+ // 这里的行不是待办(处置由 enforce 档自动完成),面板只回答「账本里有什么、
2324
+ // 分布如何」。policy 与 /conflicts 同级(token):行带标题级信息与检出时间,
2325
+ // 不该对未持 token 的本地进程开放。
2326
+ register({
2327
+ kind: "exact",
2328
+ path: "/api/dsh-mneme/conflicts/ledger",
2329
+ policy: "token",
2330
+ handler(req, res) {
2331
+ try {
2332
+ if (req.method !== "GET") {
2333
+ sendJson(res, 404, { error: "not-found" });
2334
+ return;
2335
+ }
2336
+ const url = new URL(req.url, "http://localhost");
2337
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
2338
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
2339
+ const items = service.listConflictLedger({ limit, offset });
2340
+ const counts = {
2341
+ dup: service.countConflictLedger({ tier: "dup" }),
2342
+ "cross-scope-dup": service.countConflictLedger({ tier: "cross-scope-dup" }),
2343
+ evolution: service.countConflictLedger({ tier: "evolution" }),
2344
+ conflict: service.countConflictLedger({ tier: "conflict" })
2345
+ };
2346
+ sendJson(res, 200, { items, counts, total: service.countConflictLedger() });
2347
+ } catch {
2348
+ sendJson(res, 500, { error: "internal" });
2349
+ }
2350
+ }
2351
+ });
2352
+
2138
2353
  register({
2139
2354
  kind: "exact",
2140
2355
  path: "/api/dsh-mneme/conflicts/resolve",
@@ -2169,7 +2384,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2169
2384
  }
2170
2385
  });
2171
2386
 
2172
- // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
2387
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) + capacity (#275) ---
2173
2388
  // Auth-gated; only returns a sanitized error code (never raw last_error which
2174
2389
  // may leak paths/token-like strings/internal hosts). On state read failure it
2175
2390
  // reports unknown/degraded (fail-closed) instead of a false dirty=false.
@@ -2178,23 +2393,34 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2178
2393
  path: "/api/dsh-mneme/health",
2179
2394
  policy: "token",
2180
2395
  handler(req, res) {
2396
+ // 容量预警(#275 空间维度):与镜像状态同面但**互相独立**——镜像读失败不该
2397
+ // 把容量数字一起藏掉,所以每个分支都带上。getCapacityHealth 自身 fail-safe
2398
+ // (读失败回 status:"unknown");?.() 兜住没接线的老 mock service(此时显式
2399
+ // capacity:null,面板渲染成「未知」,不冒充正常)。
2400
+ let capacity = null;
2401
+ try {
2402
+ capacity = service.getCapacityHealth?.() ?? null;
2403
+ } catch {
2404
+ capacity = null;
2405
+ }
2181
2406
  let state = null;
2182
2407
  try {
2183
2408
  state = service.getMirrorHealth?.() ?? null;
2184
2409
  } catch {
2185
2410
  // read failure is itself a health signal: do not report a false clean
2186
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
2411
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }, capacity });
2187
2412
  return;
2188
2413
  }
2189
2414
  if (!state) {
2190
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
2415
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }, capacity });
2191
2416
  return;
2192
2417
  }
2193
2418
  // Real read failure surfaces as dirty === null (peer blocker 5): report
2194
2419
  // unknown explicitly instead of collapsing into a false "ok"/"degraded".
2195
2420
  if (state.dirty === null) {
2196
2421
  sendJson(res, 200, {
2197
- mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
2422
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null },
2423
+ capacity
2198
2424
  });
2199
2425
  return;
2200
2426
  }
@@ -2212,7 +2438,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2212
2438
  last_error: code,
2213
2439
  last_attempt: state.last_attempt ?? null,
2214
2440
  success_at: state.success_at ?? null
2215
- }
2441
+ },
2442
+ capacity
2216
2443
  });
2217
2444
  }
2218
2445
  });
@@ -2287,11 +2514,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2287
2514
  // materialize and persist one here. 只有通过上面那道门才会走到这里,
2288
2515
  // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
2289
2516
  const kv = settings.getExternalApi() ?? {};
2290
- if (!kv.token) {
2517
+ // ?token=omit:只读配置、不生成也不落库 Bearer。
2518
+ // 手机端面板(src/dsh-go)只需要 enabled/port/host 来渲染表单,而默认的
2519
+ // GET 会顺手 materialize 一个 token 写进 user_settings —— 一次纯读请求
2520
+ // 变成写路径,既污染审计也违反「面板不显示明文令牌」的承诺。带上这个
2521
+ // 参数时 token 字段一律留空;不传的老客户端行为完全不变。
2522
+ const omitToken = new URL(req.url, "http://localhost").searchParams.get("token") === "omit";
2523
+ if (!omitToken && !kv.token) {
2291
2524
  kv.token = randomBytes(24).toString("base64url");
2292
2525
  settings.setExternalApi({ token: kv.token });
2293
2526
  }
2294
- sendJson(res, 200, { config: fullExternalConfig(kv) });
2527
+ sendJson(res, 200, { config: fullExternalConfig(omitToken ? { ...kv, token: "" } : kv) });
2295
2528
  } catch {
2296
2529
  sendJson(res, 500, { error: "internal" });
2297
2530
  }
@@ -2671,6 +2904,232 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2671
2904
  }
2672
2905
  });
2673
2906
 
2907
+ // --- QQ 日记本(v0.13)-----------------------------------------------
2908
+ // 五条路由,闸门分两层:
2909
+ // - **装配缺件**(diary 参数没传 / headless 宿主)→ 503 diary-unavailable;
2910
+ // - **用户没开**(diaryEnabled=false)→ 409 disabled(#497 定下的 503/409 分叉)。
2911
+ // 读侧三条 policy "open"(与 /pipeline、/candidates 同级:只读、不写盘、宿主围栏统一
2912
+ // 鉴权);写侧两条 "token-write"——它们会落盘:/diary/compose 调模型写正文,
2913
+ // /diary/export 往**用户项目目录**复制文件。写盘面一律受门控。
2914
+ //
2915
+ // project 参数照 /pipeline 的规矩:不是绝对路径就 400,**不能忽略**——服务端没有
2916
+ // 「当前项目」这个概念,忽略它就静默退成「全局」,而全局看起来一切正常。
2917
+ function diaryGate(req, res) {
2918
+ if (!diary) {
2919
+ sendJson(res, 503, { error: "unavailable", reason: "diary-unavailable" });
2920
+ return false;
2921
+ }
2922
+ if (!config || config.diaryEnabled !== true) {
2923
+ sendJson(res, 409, { error: "disabled", reason: "diary-not-enabled" });
2924
+ return false;
2925
+ }
2926
+ return true;
2927
+ }
2928
+ function diaryProject(req, res) {
2929
+ const project = new URL(req.url, "http://localhost").searchParams.get("project");
2930
+ if (project === null || project === "") {
2931
+ // 读侧不给 project 会退成「合并所有项目」——那正是要避免的静默全局。
2932
+ sendJson(res, 400, { error: "project-required" });
2933
+ return null;
2934
+ }
2935
+ if (!isAbsolute(project)) {
2936
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2937
+ return null;
2938
+ }
2939
+ return project;
2940
+ }
2941
+
2942
+ register({
2943
+ kind: "exact",
2944
+ path: "/api/dsh-mneme/diary",
2945
+ policy: "open",
2946
+ handler(req, res) {
2947
+ if (!diaryGate(req, res)) return;
2948
+ const project = diaryProject(req, res);
2949
+ if (!project) return;
2950
+ try {
2951
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
2952
+ // 账本一起带走:面板要回答「今天为什么没有日记」时必须读它,不能靠猜。
2953
+ const audit = diary.readAudit({ projectRoot: project, limit: 20 });
2954
+ sendJson(res, 200, { contract: "mneme.diary/v1", project, dir, diaries: listDiaries(dir), audit });
2955
+ } catch (error) {
2956
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
2957
+ }
2958
+ }
2959
+ });
2960
+
2961
+ register({
2962
+ kind: "exact",
2963
+ path: "/api/dsh-mneme/diary/entry",
2964
+ policy: "open",
2965
+ handler(req, res) {
2966
+ if (!diaryGate(req, res)) return;
2967
+ const project = diaryProject(req, res);
2968
+ if (!project) return;
2969
+ const date = new URL(req.url, "http://localhost").searchParams.get("date");
2970
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date ?? ""))) {
2971
+ sendJson(res, 400, { error: "date-required" });
2972
+ return;
2973
+ }
2974
+ try {
2975
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
2976
+ const text = readDiary(dir, date);
2977
+ if (text === null) {
2978
+ // 404 而不是空串:面板据此区分「这一篇还没写」与「写了但是空的」。
2979
+ sendJson(res, 404, { error: "diary-not-found", date });
2980
+ return;
2981
+ }
2982
+ sendJson(res, 200, { contract: "mneme.diary/v1", date, path: `${dir}/${date}.md`, text });
2983
+ } catch (error) {
2984
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
2985
+ }
2986
+ }
2987
+ });
2988
+
2989
+ register({
2990
+ kind: "exact",
2991
+ path: "/api/dsh-mneme/diary/compose",
2992
+ policy: "token-write",
2993
+ handler(req, res) {
2994
+ if (!diaryGate(req, res)) return;
2995
+ const method = String(req.method || "GET").toUpperCase();
2996
+ if (method === "GET" || method === "HEAD") {
2997
+ // 手动「写一篇」是会调模型的写动作,必须 POST——GET 会被浏览器预取、
2998
+ // 被面板刷新反复调用,等于每刷一次烧一次模型(/nudge 同款教训)。
2999
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
3000
+ return;
3001
+ }
3002
+ const project = diaryProject(req, res);
3003
+ if (!project) return;
3004
+ // 会话身份(「设定好人格的 agent 只以这个人格的口吻写日记」):手动「写一篇」
3005
+ // 以前不传 agentId,于是永远落 default 卡——自动轮传了、手动轮没传,同一天
3006
+ // 从两个入口写出来的日记会用两张人格卡,这是最难发现的一类不一致。
3007
+ //
3008
+ // 身份来源只认两处,且**不带身份就不猜**:查询参数 ?agent= 是前端从会话行
3009
+ // 上读到的 agentPreset(与它算 project 用的是同一条快照),?session= 则让
3010
+ // 服务端按自己那份「会话 → 身份」账本反查。两处都空就照旧落 default 卡,
3011
+ // 但那是「不知道是谁」,不是「以为是 default」——frontmatter 的 persona 字段
3012
+ // 会如实写成 default 卡的名字,面板读得出来。
3013
+ const q = new URL(req.url, "http://localhost").searchParams;
3014
+ const rawAgent = typeof q.get("agent") === "string" ? q.get("agent").trim() : "";
3015
+ const rawSession = typeof q.get("session") === "string" ? q.get("session").trim() : "";
3016
+ const agentId = rawAgent || (rawSession ? (agentBySession.get(rawSession) || "") : "");
3017
+ Promise.resolve(diary.compose({
3018
+ trigger: "manual",
3019
+ projectRoot: project,
3020
+ agentId: agentId || null
3021
+ }))
3022
+ .then((result) => {
3023
+ // skip 不是错:already-written / no-material 都是关于这个项目的答案,回 200。
3024
+ const status = result && result.ok === false ? 500 : 200;
3025
+ sendJson(res, status, { contract: "mneme.diary/v1", ...result });
3026
+ })
3027
+ .catch((error) => {
3028
+ sendJson(res, 500, { error: "diary-compose-failed", detail: String((error && error.message) || error) });
3029
+ });
3030
+ }
3031
+ });
3032
+
3033
+ register({
3034
+ kind: "exact",
3035
+ path: "/api/dsh-mneme/diary/export",
3036
+ policy: "token-write",
3037
+ handler(req, res) {
3038
+ if (!diaryGate(req, res)) return;
3039
+ const method = String(req.method || "GET").toUpperCase();
3040
+ if (method === "GET" || method === "HEAD") {
3041
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
3042
+ return;
3043
+ }
3044
+ const project = diaryProject(req, res);
3045
+ if (!project) return;
3046
+ readBody(req, res).then((text) => {
3047
+ if (text === null) return;
3048
+ const body = parseBody(text);
3049
+ const date = String(body.date ?? "");
3050
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
3051
+ sendJson(res, 400, { error: "date-required" });
3052
+ return;
3053
+ }
3054
+ try {
3055
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
3056
+ const result = exportDiary({ sourceDir: dir, projectRoot: project, date, overwrite: body.overwrite === true });
3057
+ const status = result.ok ? 200 : result.error === "target-exists" ? 409 : result.error === "no-diary" ? 404 : 500;
3058
+ sendJson(res, status, { contract: "mneme.diary/v1", ...result });
3059
+ } catch (error) {
3060
+ sendJson(res, 500, { error: "diary-export-failed", detail: String((error && error.message) || error) });
3061
+ }
3062
+ });
3063
+ }
3064
+ });
3065
+
3066
+ // --- 日记库(左侧「ta 的日记」面板)-----------------------------------
3067
+ // 与上面五条**按项目定位**的日记路由是两件事:
3068
+ // 左侧面板长在 `main` 座位上,宿主不给它 sessionId,所以它算不出
3069
+ // 「当前会话的项目根」,也没法用 projectKey 定位单个桶。它要的是
3070
+ // 「有哪些桶、各是谁写的、各有多少篇」——那只能整体扫 diaries 根目录。
3071
+ //
3072
+ // 两条都 policy "open":只读、不写盘、不调模型,鉴权交宿主围栏。
3073
+ // 闸门与上面同源(503 装配缺件 / 409 用户没开),否则会出现「右栏能读、
3074
+ // 左栏 404」这种看起来像坏了的分叉。
3075
+ register({
3076
+ kind: "exact",
3077
+ path: "/api/dsh-mneme/diary/library",
3078
+ policy: "open",
3079
+ handler(req, res) {
3080
+ if (!diaryGate(req, res)) return;
3081
+ try {
3082
+ const { root, buckets } = listDiaryLibrary({
3083
+ memoryDir: config.memoryDir,
3084
+ configured: config.diaryDir
3085
+ });
3086
+ sendJson(res, 200, { contract: "mneme.diary/v1", root, buckets });
3087
+ } catch (error) {
3088
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
3089
+ }
3090
+ }
3091
+ });
3092
+
3093
+ register({
3094
+ kind: "exact",
3095
+ path: "/api/dsh-mneme/diary/library/entry",
3096
+ policy: "open",
3097
+ handler(req, res) {
3098
+ if (!diaryGate(req, res)) return;
3099
+ const params = new URL(req.url, "http://localhost").searchParams;
3100
+ const bucket = params.get("bucket");
3101
+ const date = params.get("date");
3102
+ // 桶名与日期都要校验:这条路径直接拼文件系统路径,`..` 必须在这里挡住。
3103
+ // 用的就是 library.js 那道 `isBucketName`——两层不能各写一份规则,否则
3104
+ // 会出现「清单里列得出来、点进去 400」:历史上真有保留中文的桶
3105
+ // (`QQ对话-4f061790`),ASCII 白名单会把它列出来却读不到,用户看到的是
3106
+ // 点了没反应。真正的越界由 `readLibraryEntry` 里的真实路径解析兜底。
3107
+ if (!bucket || !isBucketName(bucket)) {
3108
+ sendJson(res, 400, { error: "bucket-required" });
3109
+ return;
3110
+ }
3111
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
3112
+ sendJson(res, 400, { error: "date-required" });
3113
+ return;
3114
+ }
3115
+ try {
3116
+ const text = readLibraryEntry({
3117
+ memoryDir: config.memoryDir,
3118
+ configured: config.diaryDir,
3119
+ bucket,
3120
+ date
3121
+ });
3122
+ if (text === null) {
3123
+ sendJson(res, 404, { error: "diary-not-found", bucket, date });
3124
+ return;
3125
+ }
3126
+ sendJson(res, 200, { contract: "mneme.diary/v1", bucket, date, text });
3127
+ } catch (error) {
3128
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
3129
+ }
3130
+ }
3131
+ });
3132
+
2674
3133
  // --- chorography(仓库地志)M1 读侧路由 -------------------------------
2675
3134
  // spec §7 消费面的面板数据源。四条全 policy "open":只读、宿主围栏统一鉴权
2676
3135
  // (与 /list、/version-check 同级);**chorographyEnabled 关闭时一律 409
@@ -2704,13 +3163,96 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2704
3163
  return repo;
2705
3164
  }
2706
3165
 
3166
+ // 读侧自动重跑(chorographyAutoRefresh)。面板的语义是「读地志页时发现仓库代码
3167
+ // 变了就顺手重跑一次 T0」——T0 是零模型的确定性扫描,成本只在磁盘;但它会重写
3168
+ // 仓库里的 .chorography/**,所以默认关,且一次请求最多刷一个仓库。
3169
+ //
3170
+ // 为什么失败不 500:读是主诉求、刷新是附赠。git rev-parse 偶发失败或磁盘只读时,
3171
+ // 把整张仓库列表打成 500 等于「因为顺手的事没办成,连正式的事都不给办了」。
3172
+ // 错误塞进 autoRefreshError 让面板能说清,数据照发。
3173
+ //
3174
+ // 为什么要 caller 指定 repo:一次面板加载若把 5 个 stale 仓库全扫一遍,用户只是
3175
+ // 打开一个页面却付了 5 次全仓扫描;列表路径只在 ?repo= 明确点名时才刷。
3176
+ function autoRefreshOne(repoId, facts) {
3177
+ const result = {
3178
+ autoRefreshed: false,
3179
+ autoRefreshError: undefined,
3180
+ // autoRefreshSkipped 与 autoRefreshError 是两件事:前者是「我们主动没做」,
3181
+ // 后者是「试了但失败了」。混成一个字段会让面板把一次正确的拒绝说成故障。
3182
+ autoRefreshSkipped: undefined,
3183
+ autoRefreshSkipNote: undefined,
3184
+ repo: facts
3185
+ };
3186
+ if (!config || config.chorographyAutoRefresh !== true) return result;
3187
+ if (!facts || facts.staleReason !== "git") return result;
3188
+ if (typeof choro.runT0Apply !== "function") return result;
3189
+ // 有富化页时先别动手:读侧刷新只该刷新「确定性骨架」,不该在用户点开一页时
3190
+ // 顺手把语义段(花模型调用换来的)连同 claims 一起清掉。T0 自己也会拒绝
3191
+ // (guardT1Loss),这里提前拦一道是为了给面板一句能直接显示的人话,
3192
+ // 并且不进 warn 日志——这不是异常。
3193
+ if (Number(facts.enrichedPages ?? 0) > 0) {
3194
+ result.autoRefreshSkipped = "t1-loss";
3195
+ result.autoRefreshSkipNote =
3196
+ `这个仓库有 ${facts.enrichedPages} 页已经富化过:自动刷新会退回骨架、清空语义段与结论,`
3197
+ + "所以这次没有动。代码已变这件事仍然有效,需要新快照请在面板里手动点「重建骨架」。";
3198
+ return result;
3199
+ }
3200
+ try {
3201
+ // guardT1Loss 必须显式传:库里的 enrichedPages 只是第一道提示(快、能生成人话),
3202
+ // 真正读盘判断「会丢哪几页」的是 T0 自己。不传给 T0 等于把唯一的硬守卫关掉。
3203
+ choro.runT0Apply({ repoRoot: facts.rootPath, guardT1Loss: true });
3204
+ result.autoRefreshed = true;
3205
+ // 重跑之后基线推进了,回执里的 stale 必须按新基线说话:沿用旧行的判定会让
3206
+ // 面板刚刷完就显示「代码已变」,也就是谎报一次失败。
3207
+ const fresh = typeof choro.repoFactsById === "function" ? choro.repoFactsById(repoId) : null;
3208
+ if (fresh) result.repo = fresh;
3209
+ } catch (err) {
3210
+ // would-drop-t1 是 T0 侧的同一条判断(它读盘,比这里的库计数更准):
3211
+ // 到这一层说明库里的 enrichedPages 和磁盘上的实际情况对不上,仍按「主动跳过」
3212
+ // 处理并把人话透出去,绝不报成刷新失败。
3213
+ if (err?.code === "would-drop-t1") {
3214
+ result.autoRefreshSkipped = "t1-loss";
3215
+ result.autoRefreshSkipNote = String(err?.message ?? err);
3216
+ return result;
3217
+ }
3218
+ result.autoRefreshError = String(err?.message ?? err);
3219
+ ctx.logger?.warn?.(`dsh-mneme: choro auto refresh failed for repo ${repoId}: ${result.autoRefreshError}`);
3220
+ }
3221
+ return result;
3222
+ }
3223
+
2707
3224
  register({
2708
3225
  kind: "exact",
2709
3226
  path: "/api/dsh-mneme/choro/repos",
2710
3227
  policy: "open",
2711
3228
  handler(req, res) {
2712
3229
  if (!choroGate(req, res)) return;
2713
- sendJson(res, 200, { repos: choro.listRepos() });
3230
+ // 契约 §3.2 的字段(stale / staleReason / lastRunAt / legacy / claims)由
3231
+ // queries 层的 listRepoFacts 出;这里留 fallback 是因为既有测试与工具层会
3232
+ // 用裸对象冒充 choro(只实现 runT0Apply),缺一个方法就 500 是没必要的脆。
3233
+ const repos = typeof choro.listRepoFacts === "function" ? choro.listRepoFacts() : choro.listRepos();
3234
+ const repoParam = new URL(req.url, "http://localhost").searchParams.get("repo");
3235
+ let autoRefreshed = false;
3236
+ let autoRefreshError;
3237
+ let autoRefreshSkipped;
3238
+ let autoRefreshSkipNote;
3239
+ if (repoParam !== null && repoParam !== "") {
3240
+ const target = choro.resolveRepo(repoParam);
3241
+ if (target && !target.ambiguous) {
3242
+ const idx = repos.findIndex((r) => Number(r.id) === Number(target.id));
3243
+ if (idx >= 0) {
3244
+ const refreshed = autoRefreshOne(target.id, repos[idx]);
3245
+ autoRefreshed = refreshed.autoRefreshed;
3246
+ autoRefreshError = refreshed.autoRefreshError;
3247
+ autoRefreshSkipped = refreshed.autoRefreshSkipped;
3248
+ autoRefreshSkipNote = refreshed.autoRefreshSkipNote;
3249
+ if (refreshed.repo) repos[idx] = refreshed.repo;
3250
+ }
3251
+ }
3252
+ }
3253
+ // cliCommand(#607):面板空态直接把它贴出来,用户复制即用。推导不出
3254
+ // 入口时为 null,面板据此退回通用说法(不硬编一个假路径)。
3255
+ sendJson(res, 200, { repos, autoRefreshed, autoRefreshError, autoRefreshSkipped, autoRefreshSkipNote, cliCommand: CHORO_CLI });
2714
3256
  }
2715
3257
  });
2716
3258
 
@@ -2744,7 +3286,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2744
3286
  sendJson(res, 400, { error: "slug-required" });
2745
3287
  return;
2746
3288
  }
3289
+ // 读页是面板的主路径,也正是「读地志页时发现代码变了就重跑」这句文案对应的
3290
+ // 那一刻——自动刷新优先挂在这里(/choro/repos 只在 ?repo= 点名时才刷)。
3291
+ // 刷新必须在 readPage 之前:T0 会重写页文件,先读后刷会把刚被替换掉的旧正文
3292
+ // 回给面板,用户下一次刷新才看到新内容,看起来就像「刷了没用」。
3293
+ const before = typeof choro.repoFactsById === "function" ? choro.repoFactsById(repo.id) : null;
3294
+ const refreshed = autoRefreshOne(repo.id, before);
2747
3295
  const page = choro.readPage(repo.id, slug);
3296
+ // 顶栏「代码已变」徽章读 state.repo.stale:这一份 repo 必须和 page 同源同刻,
3297
+ // 否则刚刷完还会亮着旧徽章。
3298
+ const repoNow = refreshed.repo ?? before;
2748
3299
  if (!page) {
2749
3300
  sendJson(res, 404, { error: "page-not-found", slug });
2750
3301
  return;
@@ -2756,12 +3307,30 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2756
3307
  slug: page.slug,
2757
3308
  relPath: page.relPath,
2758
3309
  hint: page.error === "page-missing"
2759
- ? "run `dsh-mneme-choro <repo> --apply` to rebuild"
3310
+ ? `run ${CHORO_CLI ? `\`${CHORO_CLI}\`` : "`dsh-mneme-choro <repo> --apply`"} to rebuild`
2760
3311
  : "check the local file and its permissions"
2761
3312
  });
2762
3313
  return;
2763
3314
  }
2764
- sendJson(res, 200, { page });
3315
+ sendJson(res, 200, {
3316
+ page,
3317
+ repo: repoNow ? {
3318
+ id: repoNow.id,
3319
+ name: repoNow.name,
3320
+ rootPath: repoNow.rootPath,
3321
+ stale: repoNow.stale,
3322
+ staleReason: repoNow.staleReason,
3323
+ legacy: repoNow.legacy,
3324
+ // 面板要靠它解释「为什么这次自动刷新没动」——它是主动拒绝的依据,
3325
+ // 不是内部账本,所以随读页一起回。
3326
+ enrichedPages: repoNow.enrichedPages ?? 0,
3327
+ lastRunAt: repoNow.lastRunAt
3328
+ } : { id: repo.id, name: repo.name },
3329
+ autoRefreshed: refreshed.autoRefreshed,
3330
+ autoRefreshError: refreshed.autoRefreshError,
3331
+ autoRefreshSkipped: refreshed.autoRefreshSkipped,
3332
+ autoRefreshSkipNote: refreshed.autoRefreshSkipNote
3333
+ });
2765
3334
  }
2766
3335
  });
2767
3336
 
@@ -2797,9 +3366,54 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2797
3366
  }
2798
3367
  });
2799
3368
 
2800
- // 面板重建入口(收尾档 P34):T0 apply 秒级同步回执;**T1 不上 HTTP**——单模块
2801
- // 30–240s 起步的长任务不适合挂死面板请求,CLI(--t1)仍是 T1 唯一入口。
3369
+ // Claim 粒度失效清单(借 openwiki 的 Grounded Claims)。只读、policy open,
3370
+ // 与其余 choro 读路由同级。
3371
+ //
3372
+ // 为什么不落库:判据是 git diff × claims 三列证据的纯机械比对,一次几十毫秒。
3373
+ // 落库就多一份要失效的缓存,而它唯一的消费者(面板)本来就是打开时现读。
3374
+ // 写路径一条不加,spec §4 的红线(生成只走 CLI)不动。
3375
+ //
3376
+ // 响应三态刻意分开,别让面板把「算不出来」说成「没事」:
3377
+ // 200 clean:true 基线 == HEAD,确实没变
3378
+ // 200 anchorStale:true 基线认不出 / git 不可读(附 reason)
3379
+ // 200 clean:false 且有 pages 正常算出来了,逐页看 atRisk
3380
+ // 仓库不存在 → 404;chorography 关闭 → 409;queries 未装配 → 503(由 choroGate 给)。
3381
+ register({
3382
+ kind: "exact",
3383
+ path: "/api/dsh-mneme/choro/claims",
3384
+ policy: "open",
3385
+ handler(req, res) {
3386
+ if (!choroGate(req, res)) return;
3387
+ const repo = choroResolveOr4xx(req, res);
3388
+ if (!repo) return;
3389
+ if (typeof choro.claimDrift !== "function") {
3390
+ sendJson(res, 503, { error: "unavailable", reason: "choro-claims-unavailable" });
3391
+ return;
3392
+ }
3393
+ let drift;
3394
+ try {
3395
+ drift = choro.claimDrift(repo.id);
3396
+ } catch (err) {
3397
+ // 读盘(页面正文判 hasT1)可能撞上权限/占用:说清是哪一步炸的,
3398
+ // 不要假装「没有 claim」。
3399
+ sendJson(res, 500, { error: "claims-failed", detail: String(err?.message ?? err) });
3400
+ return;
3401
+ }
3402
+ if (!drift) {
3403
+ sendJson(res, 404, { error: "repo-not-found" });
3404
+ return;
3405
+ }
3406
+ sendJson(res, 200, drift);
3407
+ }
3408
+ });
3409
+
3410
+ // 面板重建入口(收尾档 P34):T0 apply 秒级同步回执。
2802
3411
  // policy token-write:写的是仓库磁盘的 .chorography/**,属数据面写。
3412
+ //
3413
+ // 扩容(契约 §3.2,607 修复):body 支持 { repo?, path? | rootPath?, refresh? }。
3414
+ // 必须支持按**绝对路径**登记新仓库,否则「一个仓库都没登记」的空态面板永远出不来
3415
+ // ——面板没有 id 可传,只能给路径。已登记路径则复用既有 id(T0 的 upsertChoroRepo
3416
+ // 按 root_path 冲突更新,本来就不会重号)。
2803
3417
  register({
2804
3418
  kind: "exact",
2805
3419
  path: "/api/dsh-mneme/choro/run",
@@ -2815,6 +3429,100 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2815
3429
  if (text === null) return; // readBody 已回 413
2816
3430
  const body = parseBody(text);
2817
3431
  const repoRef = body && typeof body === "object" ? body.repo : null;
3432
+ const rootInput = body && typeof body === "object"
3433
+ ? (typeof body.path === "string" ? body.path : (typeof body.rootPath === "string" ? body.rootPath : null))
3434
+ : null;
3435
+ // refresh 是「我要求重建」的意图标记,不是「需要额外再刷一次」的开关:
3436
+ // 这条路由本身就是显式重建,T0 会在自己那次 apply 里把 git 基线推进到当前
3437
+ // HEAD,之后没有第二件事可做。保留字段是为了让面板把「用户点了重建」与
3438
+ // 「读侧顺手刷新」两种来源区分开——回执里 autoRefreshed 因此恒为 false,
3439
+ // 它不是失败信号,而是「这次没人替你偷偷刷」。
3440
+ const refresh = body && typeof body === "object" ? body.refresh === true : false;
3441
+
3442
+ let repoRoot;
3443
+ if (typeof rootInput === "string" && rootInput.trim() !== "") {
3444
+ // 路径必须绝对:相对路径的语义取决于宿主进程的 cwd,同一个请求在 CLI 与
3445
+ // 面板里会落到不同目录——那种「有时行有时不行」最难查。
3446
+ if (!isAbsolute(rootInput.trim())) {
3447
+ sendJson(res, 400, { error: "path-not-absolute", path: rootInput });
3448
+ return;
3449
+ }
3450
+ repoRoot = rootInput.trim();
3451
+ } else {
3452
+ const repo = choro.resolveRepo(repoRef);
3453
+ if (!repo || repo.ambiguous) {
3454
+ sendJson(res, repo && repo.ambiguous ? 400 : 404, {
3455
+ error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
3456
+ repos: repo && repo.ambiguous ? repo.ambiguous : undefined
3457
+ });
3458
+ return;
3459
+ }
3460
+ repoRoot = repo.root_path;
3461
+ }
3462
+
3463
+ try {
3464
+ const receipt = choro.runT0Apply({ repoRoot });
3465
+ // autoRefreshed 恒 false:显式重建已经是最强的刷新,没有「额外自动刷新」
3466
+ // 这回事。写成常量而不是省略字段,是为了让面板的两种来源(显式 / 顺手)
3467
+ // 共用同一个回执形状,不必按路由分支判 undefined。
3468
+ sendJson(res, 200, { ok: true, autoRefreshed: false, refresh, receipt });
3469
+ } catch (err) {
3470
+ // 结构化分叉而不是 500 一锅烩:路径不存在 / 不是 git 仓库都是「用户给错了
3471
+ // 东西」,面板要能据此说人话并让他改路径,而不是显示「服务端错误,重试」。
3472
+ // 判据走 err.code 字符串(ChoroRefusal.code / PlanInvalidError.name),
3473
+ // 因为 api.js 刻意不 import src/wiki/*(不让 HTTP 面依赖编排层)。
3474
+ const code = err && typeof err.code === "string" ? err.code : null;
3475
+ if (code === "no-such-dir") {
3476
+ sendJson(res, 400, { error: "path-not-found", path: repoRoot, detail: String((err && err.message) || err) });
3477
+ return;
3478
+ }
3479
+ if (code === "not-git") {
3480
+ sendJson(res, 409, { error: "not-a-git-repo", path: repoRoot, detail: String((err && err.message) || err) });
3481
+ return;
3482
+ }
3483
+ if (err && err.name === "PlanInvalidError") {
3484
+ sendJson(res, 409, {
3485
+ error: "plan-invalid",
3486
+ path: repoRoot,
3487
+ detail: String((err && err.message) || err),
3488
+ issues: Array.isArray(err.issues) ? err.issues : undefined
3489
+ });
3490
+ return;
3491
+ }
3492
+ sendJson(res, 500, { error: "t0-failed", detail: String((err && err.message) || err) });
3493
+ }
3494
+ });
3495
+ }
3496
+ });
3497
+
3498
+ // T1 后台任务(契约 §3.2 / 面板 §3.5)。**不在这里 spawn**:进程账住在
3499
+ // wiki/t1-jobs.js(api.js 不 import child_process),这里只做参数解析、闸门与
3500
+ // 409 分叉。choro.startT1Job 缺席(CLI / 测试装配)→ 503,与读侧同一条语义:
3501
+ // 503 = 环境缺件,409 = 用户没开。
3502
+ register({
3503
+ kind: "exact",
3504
+ path: "/api/dsh-mneme/choro/t1",
3505
+ policy: "token-write",
3506
+ handler(req, res) {
3507
+ if (req.method !== "POST") {
3508
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
3509
+ return;
3510
+ }
3511
+ if (!choroGate(req, res)) return;
3512
+ if (typeof choro.startT1Job !== "function") {
3513
+ sendJson(res, 503, { error: "unavailable", reason: "t1-jobs-unavailable" });
3514
+ return;
3515
+ }
3516
+ // T1 花模型额度,所以除了总闸之外还有一道单独的用户闸(契约 §3.1 的理由:
3517
+ // 关总闸等于整个子系统不可见,而只想关「面板能不能花钱」的人需要更细的闸)。
3518
+ if (!config || config.chorographyT1FromPanel !== true) {
3519
+ sendJson(res, 409, { error: "disabled", reason: "t1-from-panel-disabled" });
3520
+ return;
3521
+ }
3522
+ return readBody(req, res).then((text) => {
3523
+ if (text === null) return;
3524
+ const body = parseBody(text);
3525
+ const repoRef = body && typeof body === "object" ? body.repo : null;
2818
3526
  const repo = choro.resolveRepo(repoRef);
2819
3527
  if (!repo || repo.ambiguous) {
2820
3528
  sendJson(res, repo && repo.ambiguous ? 400 : 404, {
@@ -2823,16 +3531,66 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2823
3531
  });
2824
3532
  return;
2825
3533
  }
2826
- try {
2827
- const receipt = choro.runT0Apply({ repoRoot: repo.root_path });
2828
- sendJson(res, 200, { ok: true, receipt });
2829
- } catch (err) {
2830
- sendJson(res, 500, { error: "t0-failed", detail: String((err && err.message) || err) });
3534
+ const started = choro.startT1Job({
3535
+ repo,
3536
+ module: body && typeof body.module === "string" && body.module.trim() !== "" ? body.module.trim() : null,
3537
+ retryFailed: body && typeof body === "object" ? body.retryFailed === true : false
3538
+ });
3539
+ if (!started || started.started !== true) {
3540
+ // 同一仓库并发跑 T1 会把两个进程写进同一批页面(claims 交错),必须挡。
3541
+ const errCode = started && started.code === "job-running" ? "job-running" : "t1-start-failed";
3542
+ sendJson(res, errCode === "job-running" ? 409 : 500, {
3543
+ error: errCode,
3544
+ jobId: started && started.jobId ? started.jobId : undefined
3545
+ });
3546
+ return;
2831
3547
  }
3548
+ sendJson(res, 200, { jobId: started.jobId, started: true });
2832
3549
  });
2833
3550
  }
2834
3551
  });
2835
3552
 
3553
+ // 任务状态轮询(面板 2s 一次)。jobId 未知 → 404:任务记录只在内存里,宿主重启
3554
+ // 后旧 id 必然查不到,回 404 让面板回到「没有正在跑的任务」而不是假装 running。
3555
+ register({
3556
+ kind: "exact",
3557
+ path: "/api/dsh-mneme/choro/t1/status",
3558
+ policy: "token-write",
3559
+ handler(req, res) {
3560
+ if (!choroGate(req, res)) return;
3561
+ if (typeof choro.t1JobStatus !== "function") {
3562
+ sendJson(res, 503, { error: "unavailable", reason: "t1-jobs-unavailable" });
3563
+ return;
3564
+ }
3565
+ const jobId = new URL(req.url, "http://localhost").searchParams.get("jobId");
3566
+ if (!jobId) {
3567
+ sendJson(res, 400, { error: "jobId-required" });
3568
+ return;
3569
+ }
3570
+ const job = choro.t1JobStatus(jobId);
3571
+ if (!job) {
3572
+ sendJson(res, 404, { error: "job-not-found", jobId });
3573
+ return;
3574
+ }
3575
+ sendJson(res, 200, job);
3576
+ }
3577
+ });
3578
+
3579
+ // 知识库第二层(契约 §3.6)。闸与 choro 同款分叉:装配缺件 503 / 用户没开 409。
3580
+ // 这里只判开关,不判「有没有集合」——空知识库是合法状态(面板要能显示空态)。
3581
+ function kbGate(req, res) {
3582
+ if (!kb) {
3583
+ sendJson(res, 503, { error: "unavailable", reason: "kb-unavailable" });
3584
+ return false;
3585
+ }
3586
+ if (!config || config.kbEnabled !== true) {
3587
+ sendJson(res, 409, { error: "disabled", reason: "kb-not-enabled" });
3588
+ return false;
3589
+ }
3590
+ return true;
3591
+ }
3592
+ registerKbRoutes(register, { kb, cfg: config ?? {}, sendJson, readBody, parseBody, gate: kbGate });
3593
+
2836
3594
  return {
2837
3595
  routes: countRoutes(),
2838
3596
  dispose: () => {