@modusensus/dsh-mneme 0.12.0 → 0.13.0

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 (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
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。
@@ -1689,7 +1773,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1689
1773
  try {
1690
1774
  const url = new URL(req.url, "http://localhost");
1691
1775
  const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
1692
- const entities = service.listEntities?.({ limit }) ?? [];
1776
+ // q = 名字过滤。必须下推到 store:面板此前取回 limit 条再本地过滤,
1777
+ // 排在 limit 之后的实体永远搜不到(评审 #9)。老宿主没有这个形参时
1778
+ // 多传一个键不会报错,只是过滤失效——与 limit 的处理一致。
1779
+ const q = url.searchParams.get("q") ?? "";
1780
+ const entities = service.listEntities?.({ limit, q }) ?? [];
1693
1781
  // total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当
1694
1782
  // 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。
1695
1783
  // 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也
@@ -2169,7 +2257,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2169
2257
  }
2170
2258
  });
2171
2259
 
2172
- // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
2260
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) + capacity (#275) ---
2173
2261
  // Auth-gated; only returns a sanitized error code (never raw last_error which
2174
2262
  // may leak paths/token-like strings/internal hosts). On state read failure it
2175
2263
  // reports unknown/degraded (fail-closed) instead of a false dirty=false.
@@ -2178,23 +2266,34 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2178
2266
  path: "/api/dsh-mneme/health",
2179
2267
  policy: "token",
2180
2268
  handler(req, res) {
2269
+ // 容量预警(#275 空间维度):与镜像状态同面但**互相独立**——镜像读失败不该
2270
+ // 把容量数字一起藏掉,所以每个分支都带上。getCapacityHealth 自身 fail-safe
2271
+ // (读失败回 status:"unknown");?.() 兜住没接线的老 mock service(此时显式
2272
+ // capacity:null,面板渲染成「未知」,不冒充正常)。
2273
+ let capacity = null;
2274
+ try {
2275
+ capacity = service.getCapacityHealth?.() ?? null;
2276
+ } catch {
2277
+ capacity = null;
2278
+ }
2181
2279
  let state = null;
2182
2280
  try {
2183
2281
  state = service.getMirrorHealth?.() ?? null;
2184
2282
  } catch {
2185
2283
  // 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 } });
2284
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }, capacity });
2187
2285
  return;
2188
2286
  }
2189
2287
  if (!state) {
2190
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
2288
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }, capacity });
2191
2289
  return;
2192
2290
  }
2193
2291
  // Real read failure surfaces as dirty === null (peer blocker 5): report
2194
2292
  // unknown explicitly instead of collapsing into a false "ok"/"degraded".
2195
2293
  if (state.dirty === null) {
2196
2294
  sendJson(res, 200, {
2197
- mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
2295
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null },
2296
+ capacity
2198
2297
  });
2199
2298
  return;
2200
2299
  }
@@ -2212,7 +2311,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2212
2311
  last_error: code,
2213
2312
  last_attempt: state.last_attempt ?? null,
2214
2313
  success_at: state.success_at ?? null
2215
- }
2314
+ },
2315
+ capacity
2216
2316
  });
2217
2317
  }
2218
2318
  });
@@ -2287,11 +2387,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2287
2387
  // materialize and persist one here. 只有通过上面那道门才会走到这里,
2288
2388
  // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
2289
2389
  const kv = settings.getExternalApi() ?? {};
2290
- if (!kv.token) {
2390
+ // ?token=omit:只读配置、不生成也不落库 Bearer。
2391
+ // 手机端面板(src/dsh-go)只需要 enabled/port/host 来渲染表单,而默认的
2392
+ // GET 会顺手 materialize 一个 token 写进 user_settings —— 一次纯读请求
2393
+ // 变成写路径,既污染审计也违反「面板不显示明文令牌」的承诺。带上这个
2394
+ // 参数时 token 字段一律留空;不传的老客户端行为完全不变。
2395
+ const omitToken = new URL(req.url, "http://localhost").searchParams.get("token") === "omit";
2396
+ if (!omitToken && !kv.token) {
2291
2397
  kv.token = randomBytes(24).toString("base64url");
2292
2398
  settings.setExternalApi({ token: kv.token });
2293
2399
  }
2294
- sendJson(res, 200, { config: fullExternalConfig(kv) });
2400
+ sendJson(res, 200, { config: fullExternalConfig(omitToken ? { ...kv, token: "" } : kv) });
2295
2401
  } catch {
2296
2402
  sendJson(res, 500, { error: "internal" });
2297
2403
  }
@@ -2671,6 +2777,232 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2671
2777
  }
2672
2778
  });
2673
2779
 
2780
+ // --- QQ 日记本(v0.13)-----------------------------------------------
2781
+ // 五条路由,闸门分两层:
2782
+ // - **装配缺件**(diary 参数没传 / headless 宿主)→ 503 diary-unavailable;
2783
+ // - **用户没开**(diaryEnabled=false)→ 409 disabled(#497 定下的 503/409 分叉)。
2784
+ // 读侧三条 policy "open"(与 /pipeline、/candidates 同级:只读、不写盘、宿主围栏统一
2785
+ // 鉴权);写侧两条 "token-write"——它们会落盘:/diary/compose 调模型写正文,
2786
+ // /diary/export 往**用户项目目录**复制文件。写盘面一律受门控。
2787
+ //
2788
+ // project 参数照 /pipeline 的规矩:不是绝对路径就 400,**不能忽略**——服务端没有
2789
+ // 「当前项目」这个概念,忽略它就静默退成「全局」,而全局看起来一切正常。
2790
+ function diaryGate(req, res) {
2791
+ if (!diary) {
2792
+ sendJson(res, 503, { error: "unavailable", reason: "diary-unavailable" });
2793
+ return false;
2794
+ }
2795
+ if (!config || config.diaryEnabled !== true) {
2796
+ sendJson(res, 409, { error: "disabled", reason: "diary-not-enabled" });
2797
+ return false;
2798
+ }
2799
+ return true;
2800
+ }
2801
+ function diaryProject(req, res) {
2802
+ const project = new URL(req.url, "http://localhost").searchParams.get("project");
2803
+ if (project === null || project === "") {
2804
+ // 读侧不给 project 会退成「合并所有项目」——那正是要避免的静默全局。
2805
+ sendJson(res, 400, { error: "project-required" });
2806
+ return null;
2807
+ }
2808
+ if (!isAbsolute(project)) {
2809
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2810
+ return null;
2811
+ }
2812
+ return project;
2813
+ }
2814
+
2815
+ register({
2816
+ kind: "exact",
2817
+ path: "/api/dsh-mneme/diary",
2818
+ policy: "open",
2819
+ handler(req, res) {
2820
+ if (!diaryGate(req, res)) return;
2821
+ const project = diaryProject(req, res);
2822
+ if (!project) return;
2823
+ try {
2824
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
2825
+ // 账本一起带走:面板要回答「今天为什么没有日记」时必须读它,不能靠猜。
2826
+ const audit = diary.readAudit({ projectRoot: project, limit: 20 });
2827
+ sendJson(res, 200, { contract: "mneme.diary/v1", project, dir, diaries: listDiaries(dir), audit });
2828
+ } catch (error) {
2829
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
2830
+ }
2831
+ }
2832
+ });
2833
+
2834
+ register({
2835
+ kind: "exact",
2836
+ path: "/api/dsh-mneme/diary/entry",
2837
+ policy: "open",
2838
+ handler(req, res) {
2839
+ if (!diaryGate(req, res)) return;
2840
+ const project = diaryProject(req, res);
2841
+ if (!project) return;
2842
+ const date = new URL(req.url, "http://localhost").searchParams.get("date");
2843
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date ?? ""))) {
2844
+ sendJson(res, 400, { error: "date-required" });
2845
+ return;
2846
+ }
2847
+ try {
2848
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
2849
+ const text = readDiary(dir, date);
2850
+ if (text === null) {
2851
+ // 404 而不是空串:面板据此区分「这一篇还没写」与「写了但是空的」。
2852
+ sendJson(res, 404, { error: "diary-not-found", date });
2853
+ return;
2854
+ }
2855
+ sendJson(res, 200, { contract: "mneme.diary/v1", date, path: `${dir}/${date}.md`, text });
2856
+ } catch (error) {
2857
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
2858
+ }
2859
+ }
2860
+ });
2861
+
2862
+ register({
2863
+ kind: "exact",
2864
+ path: "/api/dsh-mneme/diary/compose",
2865
+ policy: "token-write",
2866
+ handler(req, res) {
2867
+ if (!diaryGate(req, res)) return;
2868
+ const method = String(req.method || "GET").toUpperCase();
2869
+ if (method === "GET" || method === "HEAD") {
2870
+ // 手动「写一篇」是会调模型的写动作,必须 POST——GET 会被浏览器预取、
2871
+ // 被面板刷新反复调用,等于每刷一次烧一次模型(/nudge 同款教训)。
2872
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
2873
+ return;
2874
+ }
2875
+ const project = diaryProject(req, res);
2876
+ if (!project) return;
2877
+ // 会话身份(「设定好人格的 agent 只以这个人格的口吻写日记」):手动「写一篇」
2878
+ // 以前不传 agentId,于是永远落 default 卡——自动轮传了、手动轮没传,同一天
2879
+ // 从两个入口写出来的日记会用两张人格卡,这是最难发现的一类不一致。
2880
+ //
2881
+ // 身份来源只认两处,且**不带身份就不猜**:查询参数 ?agent= 是前端从会话行
2882
+ // 上读到的 agentPreset(与它算 project 用的是同一条快照),?session= 则让
2883
+ // 服务端按自己那份「会话 → 身份」账本反查。两处都空就照旧落 default 卡,
2884
+ // 但那是「不知道是谁」,不是「以为是 default」——frontmatter 的 persona 字段
2885
+ // 会如实写成 default 卡的名字,面板读得出来。
2886
+ const q = new URL(req.url, "http://localhost").searchParams;
2887
+ const rawAgent = typeof q.get("agent") === "string" ? q.get("agent").trim() : "";
2888
+ const rawSession = typeof q.get("session") === "string" ? q.get("session").trim() : "";
2889
+ const agentId = rawAgent || (rawSession ? (agentBySession.get(rawSession) || "") : "");
2890
+ Promise.resolve(diary.compose({
2891
+ trigger: "manual",
2892
+ projectRoot: project,
2893
+ agentId: agentId || null
2894
+ }))
2895
+ .then((result) => {
2896
+ // skip 不是错:already-written / no-material 都是关于这个项目的答案,回 200。
2897
+ const status = result && result.ok === false ? 500 : 200;
2898
+ sendJson(res, status, { contract: "mneme.diary/v1", ...result });
2899
+ })
2900
+ .catch((error) => {
2901
+ sendJson(res, 500, { error: "diary-compose-failed", detail: String((error && error.message) || error) });
2902
+ });
2903
+ }
2904
+ });
2905
+
2906
+ register({
2907
+ kind: "exact",
2908
+ path: "/api/dsh-mneme/diary/export",
2909
+ policy: "token-write",
2910
+ handler(req, res) {
2911
+ if (!diaryGate(req, res)) return;
2912
+ const method = String(req.method || "GET").toUpperCase();
2913
+ if (method === "GET" || method === "HEAD") {
2914
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
2915
+ return;
2916
+ }
2917
+ const project = diaryProject(req, res);
2918
+ if (!project) return;
2919
+ readBody(req, res).then((text) => {
2920
+ if (text === null) return;
2921
+ const body = parseBody(text);
2922
+ const date = String(body.date ?? "");
2923
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
2924
+ sendJson(res, 400, { error: "date-required" });
2925
+ return;
2926
+ }
2927
+ try {
2928
+ const dir = diaryDir({ memoryDir: config.memoryDir, projectRoot: project, configured: config.diaryDir });
2929
+ const result = exportDiary({ sourceDir: dir, projectRoot: project, date, overwrite: body.overwrite === true });
2930
+ const status = result.ok ? 200 : result.error === "target-exists" ? 409 : result.error === "no-diary" ? 404 : 500;
2931
+ sendJson(res, status, { contract: "mneme.diary/v1", ...result });
2932
+ } catch (error) {
2933
+ sendJson(res, 500, { error: "diary-export-failed", detail: String((error && error.message) || error) });
2934
+ }
2935
+ });
2936
+ }
2937
+ });
2938
+
2939
+ // --- 日记库(左侧「ta 的日记」面板)-----------------------------------
2940
+ // 与上面五条**按项目定位**的日记路由是两件事:
2941
+ // 左侧面板长在 `main` 座位上,宿主不给它 sessionId,所以它算不出
2942
+ // 「当前会话的项目根」,也没法用 projectKey 定位单个桶。它要的是
2943
+ // 「有哪些桶、各是谁写的、各有多少篇」——那只能整体扫 diaries 根目录。
2944
+ //
2945
+ // 两条都 policy "open":只读、不写盘、不调模型,鉴权交宿主围栏。
2946
+ // 闸门与上面同源(503 装配缺件 / 409 用户没开),否则会出现「右栏能读、
2947
+ // 左栏 404」这种看起来像坏了的分叉。
2948
+ register({
2949
+ kind: "exact",
2950
+ path: "/api/dsh-mneme/diary/library",
2951
+ policy: "open",
2952
+ handler(req, res) {
2953
+ if (!diaryGate(req, res)) return;
2954
+ try {
2955
+ const { root, buckets } = listDiaryLibrary({
2956
+ memoryDir: config.memoryDir,
2957
+ configured: config.diaryDir
2958
+ });
2959
+ sendJson(res, 200, { contract: "mneme.diary/v1", root, buckets });
2960
+ } catch (error) {
2961
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
2962
+ }
2963
+ }
2964
+ });
2965
+
2966
+ register({
2967
+ kind: "exact",
2968
+ path: "/api/dsh-mneme/diary/library/entry",
2969
+ policy: "open",
2970
+ handler(req, res) {
2971
+ if (!diaryGate(req, res)) return;
2972
+ const params = new URL(req.url, "http://localhost").searchParams;
2973
+ const bucket = params.get("bucket");
2974
+ const date = params.get("date");
2975
+ // 桶名与日期都要校验:这条路径直接拼文件系统路径,`..` 必须在这里挡住。
2976
+ // 用的就是 library.js 那道 `isBucketName`——两层不能各写一份规则,否则
2977
+ // 会出现「清单里列得出来、点进去 400」:历史上真有保留中文的桶
2978
+ // (`QQ对话-4f061790`),ASCII 白名单会把它列出来却读不到,用户看到的是
2979
+ // 点了没反应。真正的越界由 `readLibraryEntry` 里的真实路径解析兜底。
2980
+ if (!bucket || !isBucketName(bucket)) {
2981
+ sendJson(res, 400, { error: "bucket-required" });
2982
+ return;
2983
+ }
2984
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
2985
+ sendJson(res, 400, { error: "date-required" });
2986
+ return;
2987
+ }
2988
+ try {
2989
+ const text = readLibraryEntry({
2990
+ memoryDir: config.memoryDir,
2991
+ configured: config.diaryDir,
2992
+ bucket,
2993
+ date
2994
+ });
2995
+ if (text === null) {
2996
+ sendJson(res, 404, { error: "diary-not-found", bucket, date });
2997
+ return;
2998
+ }
2999
+ sendJson(res, 200, { contract: "mneme.diary/v1", bucket, date, text });
3000
+ } catch (error) {
3001
+ sendJson(res, 500, { error: "diary-unreadable", detail: String((error && error.message) || error) });
3002
+ }
3003
+ }
3004
+ });
3005
+
2674
3006
  // --- chorography(仓库地志)M1 读侧路由 -------------------------------
2675
3007
  // spec §7 消费面的面板数据源。四条全 policy "open":只读、宿主围栏统一鉴权
2676
3008
  // (与 /list、/version-check 同级);**chorographyEnabled 关闭时一律 409
@@ -2704,13 +3036,63 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2704
3036
  return repo;
2705
3037
  }
2706
3038
 
3039
+ // 读侧自动重跑(chorographyAutoRefresh)。面板的语义是「读地志页时发现仓库代码
3040
+ // 变了就顺手重跑一次 T0」——T0 是零模型的确定性扫描,成本只在磁盘;但它会重写
3041
+ // 仓库里的 .chorography/**,所以默认关,且一次请求最多刷一个仓库。
3042
+ //
3043
+ // 为什么失败不 500:读是主诉求、刷新是附赠。git rev-parse 偶发失败或磁盘只读时,
3044
+ // 把整张仓库列表打成 500 等于「因为顺手的事没办成,连正式的事都不给办了」。
3045
+ // 错误塞进 autoRefreshError 让面板能说清,数据照发。
3046
+ //
3047
+ // 为什么要 caller 指定 repo:一次面板加载若把 5 个 stale 仓库全扫一遍,用户只是
3048
+ // 打开一个页面却付了 5 次全仓扫描;列表路径只在 ?repo= 明确点名时才刷。
3049
+ function autoRefreshOne(repoId, facts) {
3050
+ const result = { autoRefreshed: false, autoRefreshError: undefined, repo: facts };
3051
+ if (!config || config.chorographyAutoRefresh !== true) return result;
3052
+ if (!facts || facts.staleReason !== "git") return result;
3053
+ if (typeof choro.runT0Apply !== "function") return result;
3054
+ try {
3055
+ choro.runT0Apply({ repoRoot: facts.rootPath });
3056
+ result.autoRefreshed = true;
3057
+ // 重跑之后基线推进了,回执里的 stale 必须按新基线说话:沿用旧行的判定会让
3058
+ // 面板刚刷完就显示「代码已变」,也就是谎报一次失败。
3059
+ const fresh = typeof choro.repoFactsById === "function" ? choro.repoFactsById(repoId) : null;
3060
+ if (fresh) result.repo = fresh;
3061
+ } catch (err) {
3062
+ result.autoRefreshError = String(err?.message ?? err);
3063
+ ctx.logger?.warn?.(`dsh-mneme: choro auto refresh failed for repo ${repoId}: ${result.autoRefreshError}`);
3064
+ }
3065
+ return result;
3066
+ }
3067
+
2707
3068
  register({
2708
3069
  kind: "exact",
2709
3070
  path: "/api/dsh-mneme/choro/repos",
2710
3071
  policy: "open",
2711
3072
  handler(req, res) {
2712
3073
  if (!choroGate(req, res)) return;
2713
- sendJson(res, 200, { repos: choro.listRepos() });
3074
+ // 契约 §3.2 的字段(stale / staleReason / lastRunAt / legacy / claims)由
3075
+ // queries 层的 listRepoFacts 出;这里留 fallback 是因为既有测试与工具层会
3076
+ // 用裸对象冒充 choro(只实现 runT0Apply),缺一个方法就 500 是没必要的脆。
3077
+ const repos = typeof choro.listRepoFacts === "function" ? choro.listRepoFacts() : choro.listRepos();
3078
+ const repoParam = new URL(req.url, "http://localhost").searchParams.get("repo");
3079
+ let autoRefreshed = false;
3080
+ let autoRefreshError;
3081
+ if (repoParam !== null && repoParam !== "") {
3082
+ const target = choro.resolveRepo(repoParam);
3083
+ if (target && !target.ambiguous) {
3084
+ const idx = repos.findIndex((r) => Number(r.id) === Number(target.id));
3085
+ if (idx >= 0) {
3086
+ const refreshed = autoRefreshOne(target.id, repos[idx]);
3087
+ autoRefreshed = refreshed.autoRefreshed;
3088
+ autoRefreshError = refreshed.autoRefreshError;
3089
+ if (refreshed.repo) repos[idx] = refreshed.repo;
3090
+ }
3091
+ }
3092
+ }
3093
+ // cliCommand(#607):面板空态直接把它贴出来,用户复制即用。推导不出
3094
+ // 入口时为 null,面板据此退回通用说法(不硬编一个假路径)。
3095
+ sendJson(res, 200, { repos, autoRefreshed, autoRefreshError, cliCommand: CHORO_CLI });
2714
3096
  }
2715
3097
  });
2716
3098
 
@@ -2744,7 +3126,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2744
3126
  sendJson(res, 400, { error: "slug-required" });
2745
3127
  return;
2746
3128
  }
3129
+ // 读页是面板的主路径,也正是「读地志页时发现代码变了就重跑」这句文案对应的
3130
+ // 那一刻——自动刷新优先挂在这里(/choro/repos 只在 ?repo= 点名时才刷)。
3131
+ // 刷新必须在 readPage 之前:T0 会重写页文件,先读后刷会把刚被替换掉的旧正文
3132
+ // 回给面板,用户下一次刷新才看到新内容,看起来就像「刷了没用」。
3133
+ const before = typeof choro.repoFactsById === "function" ? choro.repoFactsById(repo.id) : null;
3134
+ const refreshed = autoRefreshOne(repo.id, before);
2747
3135
  const page = choro.readPage(repo.id, slug);
3136
+ // 顶栏「代码已变」徽章读 state.repo.stale:这一份 repo 必须和 page 同源同刻,
3137
+ // 否则刚刷完还会亮着旧徽章。
3138
+ const repoNow = refreshed.repo ?? before;
2748
3139
  if (!page) {
2749
3140
  sendJson(res, 404, { error: "page-not-found", slug });
2750
3141
  return;
@@ -2756,12 +3147,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2756
3147
  slug: page.slug,
2757
3148
  relPath: page.relPath,
2758
3149
  hint: page.error === "page-missing"
2759
- ? "run `dsh-mneme-choro <repo> --apply` to rebuild"
3150
+ ? `run ${CHORO_CLI ? `\`${CHORO_CLI}\`` : "`dsh-mneme-choro <repo> --apply`"} to rebuild`
2760
3151
  : "check the local file and its permissions"
2761
3152
  });
2762
3153
  return;
2763
3154
  }
2764
- sendJson(res, 200, { page });
3155
+ sendJson(res, 200, {
3156
+ page,
3157
+ repo: repoNow ? {
3158
+ id: repoNow.id,
3159
+ name: repoNow.name,
3160
+ rootPath: repoNow.rootPath,
3161
+ stale: repoNow.stale,
3162
+ staleReason: repoNow.staleReason,
3163
+ legacy: repoNow.legacy,
3164
+ lastRunAt: repoNow.lastRunAt
3165
+ } : { id: repo.id, name: repo.name },
3166
+ autoRefreshed: refreshed.autoRefreshed,
3167
+ autoRefreshError: refreshed.autoRefreshError
3168
+ });
2765
3169
  }
2766
3170
  });
2767
3171
 
@@ -2797,9 +3201,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2797
3201
  }
2798
3202
  });
2799
3203
 
2800
- // 面板重建入口(收尾档 P34):T0 apply 秒级同步回执;**T1 不上 HTTP**——单模块
2801
- // 30–240s 起步的长任务不适合挂死面板请求,CLI(--t1)仍是 T1 唯一入口。
3204
+ // 面板重建入口(收尾档 P34):T0 apply 秒级同步回执。
2802
3205
  // policy token-write:写的是仓库磁盘的 .chorography/**,属数据面写。
3206
+ //
3207
+ // 扩容(契约 §3.2,607 修复):body 支持 { repo?, path? | rootPath?, refresh? }。
3208
+ // 必须支持按**绝对路径**登记新仓库,否则「一个仓库都没登记」的空态面板永远出不来
3209
+ // ——面板没有 id 可传,只能给路径。已登记路径则复用既有 id(T0 的 upsertChoroRepo
3210
+ // 按 root_path 冲突更新,本来就不会重号)。
2803
3211
  register({
2804
3212
  kind: "exact",
2805
3213
  path: "/api/dsh-mneme/choro/run",
@@ -2815,6 +3223,100 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2815
3223
  if (text === null) return; // readBody 已回 413
2816
3224
  const body = parseBody(text);
2817
3225
  const repoRef = body && typeof body === "object" ? body.repo : null;
3226
+ const rootInput = body && typeof body === "object"
3227
+ ? (typeof body.path === "string" ? body.path : (typeof body.rootPath === "string" ? body.rootPath : null))
3228
+ : null;
3229
+ // refresh 是「我要求重建」的意图标记,不是「需要额外再刷一次」的开关:
3230
+ // 这条路由本身就是显式重建,T0 会在自己那次 apply 里把 git 基线推进到当前
3231
+ // HEAD,之后没有第二件事可做。保留字段是为了让面板把「用户点了重建」与
3232
+ // 「读侧顺手刷新」两种来源区分开——回执里 autoRefreshed 因此恒为 false,
3233
+ // 它不是失败信号,而是「这次没人替你偷偷刷」。
3234
+ const refresh = body && typeof body === "object" ? body.refresh === true : false;
3235
+
3236
+ let repoRoot;
3237
+ if (typeof rootInput === "string" && rootInput.trim() !== "") {
3238
+ // 路径必须绝对:相对路径的语义取决于宿主进程的 cwd,同一个请求在 CLI 与
3239
+ // 面板里会落到不同目录——那种「有时行有时不行」最难查。
3240
+ if (!isAbsolute(rootInput.trim())) {
3241
+ sendJson(res, 400, { error: "path-not-absolute", path: rootInput });
3242
+ return;
3243
+ }
3244
+ repoRoot = rootInput.trim();
3245
+ } else {
3246
+ const repo = choro.resolveRepo(repoRef);
3247
+ if (!repo || repo.ambiguous) {
3248
+ sendJson(res, repo && repo.ambiguous ? 400 : 404, {
3249
+ error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
3250
+ repos: repo && repo.ambiguous ? repo.ambiguous : undefined
3251
+ });
3252
+ return;
3253
+ }
3254
+ repoRoot = repo.root_path;
3255
+ }
3256
+
3257
+ try {
3258
+ const receipt = choro.runT0Apply({ repoRoot });
3259
+ // autoRefreshed 恒 false:显式重建已经是最强的刷新,没有「额外自动刷新」
3260
+ // 这回事。写成常量而不是省略字段,是为了让面板的两种来源(显式 / 顺手)
3261
+ // 共用同一个回执形状,不必按路由分支判 undefined。
3262
+ sendJson(res, 200, { ok: true, autoRefreshed: false, refresh, receipt });
3263
+ } catch (err) {
3264
+ // 结构化分叉而不是 500 一锅烩:路径不存在 / 不是 git 仓库都是「用户给错了
3265
+ // 东西」,面板要能据此说人话并让他改路径,而不是显示「服务端错误,重试」。
3266
+ // 判据走 err.code 字符串(ChoroRefusal.code / PlanInvalidError.name),
3267
+ // 因为 api.js 刻意不 import src/wiki/*(不让 HTTP 面依赖编排层)。
3268
+ const code = err && typeof err.code === "string" ? err.code : null;
3269
+ if (code === "no-such-dir") {
3270
+ sendJson(res, 400, { error: "path-not-found", path: repoRoot, detail: String((err && err.message) || err) });
3271
+ return;
3272
+ }
3273
+ if (code === "not-git") {
3274
+ sendJson(res, 409, { error: "not-a-git-repo", path: repoRoot, detail: String((err && err.message) || err) });
3275
+ return;
3276
+ }
3277
+ if (err && err.name === "PlanInvalidError") {
3278
+ sendJson(res, 409, {
3279
+ error: "plan-invalid",
3280
+ path: repoRoot,
3281
+ detail: String((err && err.message) || err),
3282
+ issues: Array.isArray(err.issues) ? err.issues : undefined
3283
+ });
3284
+ return;
3285
+ }
3286
+ sendJson(res, 500, { error: "t0-failed", detail: String((err && err.message) || err) });
3287
+ }
3288
+ });
3289
+ }
3290
+ });
3291
+
3292
+ // T1 后台任务(契约 §3.2 / 面板 §3.5)。**不在这里 spawn**:进程账住在
3293
+ // wiki/t1-jobs.js(api.js 不 import child_process),这里只做参数解析、闸门与
3294
+ // 409 分叉。choro.startT1Job 缺席(CLI / 测试装配)→ 503,与读侧同一条语义:
3295
+ // 503 = 环境缺件,409 = 用户没开。
3296
+ register({
3297
+ kind: "exact",
3298
+ path: "/api/dsh-mneme/choro/t1",
3299
+ policy: "token-write",
3300
+ handler(req, res) {
3301
+ if (req.method !== "POST") {
3302
+ sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
3303
+ return;
3304
+ }
3305
+ if (!choroGate(req, res)) return;
3306
+ if (typeof choro.startT1Job !== "function") {
3307
+ sendJson(res, 503, { error: "unavailable", reason: "t1-jobs-unavailable" });
3308
+ return;
3309
+ }
3310
+ // T1 花模型额度,所以除了总闸之外还有一道单独的用户闸(契约 §3.1 的理由:
3311
+ // 关总闸等于整个子系统不可见,而只想关「面板能不能花钱」的人需要更细的闸)。
3312
+ if (!config || config.chorographyT1FromPanel !== true) {
3313
+ sendJson(res, 409, { error: "disabled", reason: "t1-from-panel-disabled" });
3314
+ return;
3315
+ }
3316
+ return readBody(req, res).then((text) => {
3317
+ if (text === null) return;
3318
+ const body = parseBody(text);
3319
+ const repoRef = body && typeof body === "object" ? body.repo : null;
2818
3320
  const repo = choro.resolveRepo(repoRef);
2819
3321
  if (!repo || repo.ambiguous) {
2820
3322
  sendJson(res, repo && repo.ambiguous ? 400 : 404, {
@@ -2823,16 +3325,66 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
2823
3325
  });
2824
3326
  return;
2825
3327
  }
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) });
3328
+ const started = choro.startT1Job({
3329
+ repo,
3330
+ module: body && typeof body.module === "string" && body.module.trim() !== "" ? body.module.trim() : null,
3331
+ retryFailed: body && typeof body === "object" ? body.retryFailed === true : false
3332
+ });
3333
+ if (!started || started.started !== true) {
3334
+ // 同一仓库并发跑 T1 会把两个进程写进同一批页面(claims 交错),必须挡。
3335
+ const errCode = started && started.code === "job-running" ? "job-running" : "t1-start-failed";
3336
+ sendJson(res, errCode === "job-running" ? 409 : 500, {
3337
+ error: errCode,
3338
+ jobId: started && started.jobId ? started.jobId : undefined
3339
+ });
3340
+ return;
2831
3341
  }
3342
+ sendJson(res, 200, { jobId: started.jobId, started: true });
2832
3343
  });
2833
3344
  }
2834
3345
  });
2835
3346
 
3347
+ // 任务状态轮询(面板 2s 一次)。jobId 未知 → 404:任务记录只在内存里,宿主重启
3348
+ // 后旧 id 必然查不到,回 404 让面板回到「没有正在跑的任务」而不是假装 running。
3349
+ register({
3350
+ kind: "exact",
3351
+ path: "/api/dsh-mneme/choro/t1/status",
3352
+ policy: "token-write",
3353
+ handler(req, res) {
3354
+ if (!choroGate(req, res)) return;
3355
+ if (typeof choro.t1JobStatus !== "function") {
3356
+ sendJson(res, 503, { error: "unavailable", reason: "t1-jobs-unavailable" });
3357
+ return;
3358
+ }
3359
+ const jobId = new URL(req.url, "http://localhost").searchParams.get("jobId");
3360
+ if (!jobId) {
3361
+ sendJson(res, 400, { error: "jobId-required" });
3362
+ return;
3363
+ }
3364
+ const job = choro.t1JobStatus(jobId);
3365
+ if (!job) {
3366
+ sendJson(res, 404, { error: "job-not-found", jobId });
3367
+ return;
3368
+ }
3369
+ sendJson(res, 200, job);
3370
+ }
3371
+ });
3372
+
3373
+ // 知识库第二层(契约 §3.6)。闸与 choro 同款分叉:装配缺件 503 / 用户没开 409。
3374
+ // 这里只判开关,不判「有没有集合」——空知识库是合法状态(面板要能显示空态)。
3375
+ function kbGate(req, res) {
3376
+ if (!kb) {
3377
+ sendJson(res, 503, { error: "unavailable", reason: "kb-unavailable" });
3378
+ return false;
3379
+ }
3380
+ if (!config || config.kbEnabled !== true) {
3381
+ sendJson(res, 409, { error: "disabled", reason: "kb-not-enabled" });
3382
+ return false;
3383
+ }
3384
+ return true;
3385
+ }
3386
+ registerKbRoutes(register, { kb, cfg: config ?? {}, sendJson, readBody, parseBody, gate: kbGate });
3387
+
2836
3388
  return {
2837
3389
  routes: countRoutes(),
2838
3390
  dispose: () => {