@jjh32456/dsh-longlive 1.6.7 → 1.8.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 (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -0,0 +1,695 @@
1
+ // src/ui/routes-readonly.js — M2-F 面板只读数据面(16 条 /api/longlive/*)
2
+ //
3
+ // 契约(MEMORY-RECALL-V2-PROPOSAL v2.6.3 §12.3 / §12.7-G2/G3/G4 / §12.11-R9):
4
+ // 16 条只读路由,全部过同一道能力令牌门(panel-auth),面板只走 HTTP、
5
+ // 不直连 SQLite、不读 `.longlive/` 之外的路径。
6
+ // #16 GET /api/longlive/integrity 返回 R8 manifest 校验状态;ok=false 时
7
+ // **只读展示保留**,动作面由 actions.js 判 401 E_PANEL_INTEGRITY。
8
+ //
9
+ // 本模块可在无宿主环境直接构造(契约测试用 createReadonlyRouter + handle);
10
+ // 宿主接线见 registerReadonlyRoutes(复用 src/ui/routes.js 的 ctx.inject 模式)。
11
+ // 数据文件缺失一律给 `present:false` 空态,绝不抛错、绝不新增文件。
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { listRecords } from '../memory/records.js';
17
+ import { unifiedConflicts } from '../memory/conflicts.js';
18
+ import { readEvidenceChain } from '../memory/evidence-chain.js';
19
+ import { listBanks, readClaims } from '../memory/governance.js';
20
+ import { appendAuditRecord, normalizeAuditRecordRow } from '../memory/audit-records.js';
21
+ import { readPrestepSummary } from '../metrics/store.mjs';
22
+ import { readChannels } from '../role/channels.js';
23
+ import { readAttentionView, readPersonalityView, readRelationshipsView } from '../role/readers.js';
24
+
25
+ import { REVIEW_SCHEMA, normalizeReviewRow } from './actions.js';
26
+
27
+ export const READONLY_SCHEMA_VERSION = 1;
28
+ export const READONLY_BODY_LIMIT = 16 * 1024;
29
+ export const EVENTS_LIMIT_MAX = 200;
30
+ export const LOG_LIMIT_MAX = 200;
31
+
32
+ /** Producer module that will fill each file in later M2 slices (Gate-1 honesty). */
33
+ /** R4 P1-6:27 条只读路由的显式 id 预留表(先冻结名字与 id,实现按表注册,不改号段)。 */
34
+ export const ROUTE_ID_BY_NAME = Object.freeze({
35
+ flags: 1,
36
+ board: 2,
37
+ status: 3,
38
+ live: 4,
39
+ activity: 5,
40
+ 'evidence/latest': 6,
41
+ gap: 7,
42
+ 'recall-log': 8,
43
+ 'write-health': 9,
44
+ review: 10,
45
+ events: 11,
46
+ distill: 12,
47
+ archive: 13,
48
+ protection: 14,
49
+ 'anchor/:anchorId': 15,
50
+ integrity: 16,
51
+ records: 17,
52
+ // 18–23 activity-writer(治理/连续性):claims / evidence/:claimId / banks / conflicts / audit/records / frames
53
+ claims: 18,
54
+ 'evidence/:claimId': 19,
55
+ banks: 20,
56
+ conflicts: 21,
57
+ 'audit/records': 22,
58
+ frames: 23,
59
+ // 24–26 state-truth(角色内核):attention / relationships / personality
60
+ attention: 24,
61
+ relationships: 25,
62
+ personality: 26,
63
+ // 27 release-check:channels
64
+ channels: 27,
65
+ })
66
+
67
+ export const READONLY_ROUTES = Object.freeze([
68
+ { id: 1, path: '/api/longlive/flags', schema: 'flags/v1', producer: 'src/ui/flags.js + host configEditor', dynamic: false },
69
+ { id: 2, path: '/api/longlive/board', schema: 'board/v1', producer: 'src/activity/index.js', dynamic: false },
70
+ { id: 3, path: '/api/longlive/status', schema: 'status/v1', producer: 'src/engine-status.js + src/heartbeat.js', dynamic: false },
71
+ { id: 4, path: '/api/longlive/live', schema: 'live/v1', producer: 'src/live.js + heartbeat', dynamic: false },
72
+ { id: 5, path: '/api/longlive/activity', schema: 'activity/v1', producer: 'src/activity/store.js', dynamic: false },
73
+ { id: 6, path: '/api/longlive/evidence/latest', schema: 'evidence-package/v1', producer: 'M2-C pre-step recall pipeline', dynamic: false },
74
+ { id: 7, path: '/api/longlive/gap', schema: 'gap/v1', producer: 'M2-B lifecycle/gap-clocks.js', dynamic: false },
75
+ { id: 8, path: '/api/longlive/recall-log', schema: 'recall-log/v1', producer: 'M2-C recall pipeline', dynamic: false },
76
+ { id: 9, path: '/api/longlive/write-health', schema: 'write-health/v1', producer: 'M2-B capture writer', dynamic: false },
77
+ { id: 10, path: '/api/longlive/review', schema: 'review/v1', producer: 'M2-D maintenance/review queue', dynamic: false },
78
+ { id: 11, path: '/api/longlive/events', schema: 'events/v1', producer: 'M2-A event index', dynamic: false },
79
+ { id: 12, path: '/api/longlive/distill', schema: 'distill/v1', producer: 'M2-D distiller', dynamic: false },
80
+ { id: 13, path: '/api/longlive/archive', schema: 'archive/v1', producer: 'M2-D forget/archive flow', dynamic: false },
81
+ { id: 14, path: '/api/longlive/protection', schema: 'protection/v1', producer: 'M2-B protection predicate writer', dynamic: false },
82
+ { id: 15, path: '/api/longlive/anchor/:anchorId', schema: 'anchor/v1', producer: 'M2-A/M2-C anchor liveness', dynamic: true },
83
+ { id: 16, path: '/api/longlive/integrity', schema: 'integrity/v1', producer: 'host package manager + loader verifier', dynamic: false },
84
+ { id: 17, path: '/api/longlive/records', schema: 'records/v1', producer: 'M2-B records writer(evidence[]/sourceTier/conflict)', dynamic: false },
85
+ { id: 18, path: '/api/longlive/claims', schema: 'claims/v1', producer: 'M2-B governance.js readClaims', dynamic: false },
86
+ { id: 19, path: '/api/longlive/evidence/:claimId', schema: 'evidence-chain/v1', producer: 'M2-B evidence-chain.js', dynamic: true },
87
+ { id: 20, path: '/api/longlive/banks', schema: 'banks/v1', producer: 'M2-B governance.js listBanks', dynamic: false },
88
+ { id: 21, path: '/api/longlive/conflicts', schema: 'conflicts/v1', producer: 'M2-B conflicts.js unifiedConflicts', dynamic: false },
89
+ { id: 22, path: '/api/longlive/audit/records', schema: 'audit-records/v1', producer: 'M2-B read/write audit(.longlive/audit/records.jsonl)', dynamic: false },
90
+ { id: 23, path: '/api/longlive/frames', schema: 'frames/v1', producer: 'M2-B working-frame frame stack', dynamic: false },
91
+ // #24–#26 角色内核(v2.8 §16.3;state-truth 生产者:src/role/*)。
92
+ { id: 24, path: '/api/longlive/attention', schema: 'attention/v1', producer: 'src/role/attention.js(state-truth)', dynamic: false },
93
+ { id: 25, path: '/api/longlive/relationships', schema: 'relationships/v1', producer: 'src/role/relationships.js(state-truth)', dynamic: false },
94
+ { id: 26, path: '/api/longlive/personality', schema: 'personality/v1', producer: 'src/role/personality.js(state-truth)', dynamic: false },
95
+ // #27 channels:release-check 注册(state-truth 只提供 src/role/channels.js#readChannels 读取器)。
96
+ { id: 27, path: '/api/longlive/channels', schema: 'channels/v1', producer: 'src/role/channels.js#readChannels(state-truth 读取器)+ release-check 注册 #27', dynamic: false },
97
+ ]);
98
+
99
+ /** G3 detail route lives under #12 (one logical route, exact list + `/<id>`). */
100
+ export const DISTILL_DETAIL_PREFIX = '/api/longlive/distill/';
101
+ export const ANCHOR_DETAIL_PREFIX = '/api/longlive/anchor/';
102
+ export const EVIDENCE_DETAIL_PREFIX = '/api/longlive/evidence/';
103
+
104
+ export class ReadonlyRouteError extends Error {
105
+ constructor(code, message, status = 400) {
106
+ super(message);
107
+ this.name = 'ReadonlyRouteError';
108
+ this.code = code;
109
+ this.status = status;
110
+ }
111
+ }
112
+
113
+ function readText(file) {
114
+ try {
115
+ return fs.readFileSync(file, 'utf8');
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function readJson(file) {
122
+ const text = readText(file);
123
+ if (text === null) return { ok: false, code: 'ENOENT' };
124
+ try {
125
+ return { ok: true, value: JSON.parse(text) };
126
+ } catch {
127
+ return { ok: false, code: 'E_PARSE' };
128
+ }
129
+ }
130
+
131
+ function readJsonlLimited(file, limit) {
132
+ const text = readText(file);
133
+ if (text === null) return { present: false, rows: [], bad: 0 };
134
+ const rows = [];
135
+ let bad = 0;
136
+ const lines = text.split(/\r?\n/);
137
+ for (const line of lines) {
138
+ if (line.trim() === '') continue;
139
+ try {
140
+ rows.push(JSON.parse(line));
141
+ } catch {
142
+ bad += 1;
143
+ }
144
+ }
145
+ const max = Number.isInteger(limit) && limit > 0 ? limit : LOG_LIMIT_MAX;
146
+ return { present: true, rows: rows.slice(-max), bad };
147
+ }
148
+
149
+ function readJsonDir(dir) {
150
+ let names = [];
151
+ try {
152
+ names = fs.readdirSync(dir);
153
+ } catch {
154
+ return { present: false, files: [], rows: [] };
155
+ }
156
+ const files = names.filter((name) => name.endsWith('.json')).sort();
157
+ const rows = [];
158
+ for (const name of files) {
159
+ const read = readJson(path.join(dir, name));
160
+ if (read.ok && read.value && typeof read.value === 'object') rows.push(read.value);
161
+ }
162
+ return { present: files.length > 0, files, rows };
163
+ }
164
+
165
+ function listLiveRows(memoryRoot) {
166
+ return readJsonDir(path.join(String(memoryRoot ?? ''), '.longlive', 'live')).rows;
167
+ }
168
+
169
+ function boardRows(memoryRoot, limit = 500) {
170
+ const read = readJsonlLimited(path.join(String(memoryRoot ?? ''), '.longlive', 'board', 'activity.jsonl'), limit);
171
+ const rows = read.rows.filter((row) => row && Number.isInteger(row.seq) && typeof row.sessionId === 'string');
172
+ rows.sort((a, b) => a.seq - b.seq);
173
+ return { ...read, rows };
174
+ }
175
+
176
+ function createDefaultReaders(options) {
177
+ const memoryRoot = options.memoryRoot;
178
+ // §16.8 A:角色层 HMAC 侧车可能由 panel auditKey 或 host key file 任一写成;
179
+ // 路由读透传宿主 auditKey(缺省走 key file),校验失败时 fail-safe 降级。
180
+ const auditKey = options.auditKey ?? null;
181
+ const now = options.now;
182
+ const base = path.join(String(memoryRoot ?? ''), '.longlive');
183
+ return {
184
+ flags: () => (typeof options.flagsSnapshot === 'function'
185
+ ? options.flagsSnapshot()
186
+ : { schema: 'flags/v1', flags: {}, writable: false, note: 'host configEditor 未装配(测试/无宿主形态)' }),
187
+ board: () => {
188
+ const read = boardRows(memoryRoot);
189
+ return {
190
+ schema: 'board/v1',
191
+ present: read.present,
192
+ rows: read.rows,
193
+ badLines: read.bad,
194
+ maxSeq: read.rows.reduce((max, row) => (row.seq > max ? row.seq : max), 0),
195
+ };
196
+ },
197
+ status: () => {
198
+ const rows = listLiveRows(memoryRoot);
199
+ return { schema: 'status/v1', present: rows.length > 0, sessions: rows, counts: { sessions: rows.length } };
200
+ },
201
+ live: () => {
202
+ const rows = listLiveRows(memoryRoot);
203
+ return { schema: 'live/v1', present: rows.length > 0, rows };
204
+ },
205
+ activity: () => {
206
+ const board = boardRows(memoryRoot);
207
+ const live = listLiveRows(memoryRoot);
208
+ const bySession = new Map();
209
+ for (const row of live) {
210
+ const id = typeof row?.sessionId === 'string' ? row.sessionId : null;
211
+ if (id === null) continue;
212
+ bySession.set(id, { sessionId: id, goal: row.goal ?? null, state: row.state ?? null, lastAt: row.updatedAt ?? null, items: [] });
213
+ }
214
+ for (const row of board.rows.filter((item) => item.archived !== true).slice(-5)) {
215
+ const id = row.sessionId;
216
+ const entry = bySession.get(id) ?? { sessionId: id, goal: null, state: null, lastAt: null, items: [] };
217
+ entry.items.push({ seq: row.seq, kind: row.kind, text: row.text, at: row.last_at ?? row.at });
218
+ bySession.set(id, entry);
219
+ }
220
+ return { schema: 'activity/v1', present: bySession.size > 0, sessions: [...bySession.values()] };
221
+ },
222
+ evidenceLatest: () => {
223
+ const read = readJson(path.join(base, 'evidence', 'latest.json'));
224
+ if (!read.ok) return { schema: 'evidence-package/v1', present: false, code: read.code };
225
+ const value = read.value ?? {};
226
+ return {
227
+ schema: 'evidence-package/v1',
228
+ present: true,
229
+ package: value.package ?? null,
230
+ packageSha256: value.packageSha256 ?? null,
231
+ writtenAt: value.writtenAt ?? null,
232
+ };
233
+ },
234
+ gap: () => {
235
+ const read = readJson(path.join(base, 'gap', 'latest.json'));
236
+ if (!read.ok) return { schema: 'gap/v1', present: false, code: read.code };
237
+ return { schema: 'gap/v1', present: true, ...read.value };
238
+ },
239
+ recallLog: (query) => {
240
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), LOG_LIMIT_MAX) : LOG_LIMIT_MAX;
241
+ const read = readJsonlLimited(path.join(base, 'recall', 'log.jsonl'), limit);
242
+ return { schema: 'recall-log/v1', present: read.present, entries: read.rows, badLines: read.bad };
243
+ },
244
+ writeHealth: () => {
245
+ // B3: pre-step latency attribution lives next to write-health (read-only summary).
246
+ const prestep = readPrestepSummary({ memoryRoot });
247
+ const read = readJson(path.join(base, 'metrics', 'write-health.json'));
248
+ if (!read.ok) return { schema: 'write-health/v1', present: false, code: read.code, prestep };
249
+ return { schema: 'write-health/v1', present: true, ...read.value, prestep };
250
+ },
251
+ review: () => {
252
+ const read = readJsonlLimited(path.join(base, 'review', 'queue.jsonl'), 500);
253
+ const items = read.rows.map((row) => normalizeReviewRow(row));
254
+ return { schema: REVIEW_SCHEMA, present: read.present, items, badLines: read.bad };
255
+ },
256
+ events: (query) => {
257
+ if (typeof options.eventsReader === 'function') {
258
+ const out = options.eventsReader(query);
259
+ return { schema: 'events/v1', present: true, ...(out ?? {}) };
260
+ }
261
+ return {
262
+ schema: 'events/v1',
263
+ present: false,
264
+ events: [],
265
+ reason: 'E_EVENTS_INDEX_NOT_BUILT',
266
+ note: 'M2-A 事件索引未建(Gate-1 只交付契约层,不直连 SQLite)。',
267
+ };
268
+ },
269
+ distill: (query) => {
270
+ const read = readJsonDir(path.join(base, 'distill'));
271
+ const id = typeof query.id === 'string' ? query.id : '';
272
+ if (id !== '') {
273
+ const hit = read.rows.find((row) => row?.id === id);
274
+ if (!hit) return { schema: 'distill/v1', present: false, code: 'E_DISTILL_NOT_FOUND' };
275
+ return { schema: 'distill/v1', present: true, distill: hit };
276
+ }
277
+ return { schema: 'distill/v1', present: read.present, items: read.rows };
278
+ },
279
+ archive: () => {
280
+ const dirRead = readJsonDir(path.join(base, 'archive', 'tombstones'));
281
+ const tombstones = readText(path.join(String(memoryRoot ?? ''), 'tombstones.md'));
282
+ const entries = dirRead.rows;
283
+ return {
284
+ schema: 'archive/v1',
285
+ present: entries.length > 0 || (tombstones !== null && tombstones.trim() !== ''),
286
+ entries,
287
+ tombstonesPresent: tombstones !== null,
288
+ };
289
+ },
290
+ protection: () => {
291
+ const read = readJson(path.join(base, 'protected.json'));
292
+ if (!read.ok) return { schema: 'protection/v1', present: false, default: 'protect', entries: [], version: 1 };
293
+ const value = read.value ?? {};
294
+ return {
295
+ schema: 'protection/v1',
296
+ present: true,
297
+ default: value.default === 'protect' ? 'protect' : (value.default ?? 'protect'),
298
+ version: Number.isInteger(value.version) ? value.version : 1,
299
+ entries: Array.isArray(value.entries) ? value.entries : [],
300
+ };
301
+ },
302
+ anchor: (params) => {
303
+ const anchorId = typeof params.anchorId === 'string' ? params.anchorId : '';
304
+ if (typeof options.anchorResolver === 'function') {
305
+ const hit = options.anchorResolver(anchorId);
306
+ if (!hit) return { schema: 'anchor/v1', present: false, code: 'E_ANCHOR_NOT_FOUND' };
307
+ return { schema: 'anchor/v1', present: true, ...hit };
308
+ }
309
+ const read = readJsonlLimited(path.join(base, 'index', 'anchors.jsonl'), 1000);
310
+ const hit = read.rows.find((row) => row?.anchorId === anchorId);
311
+ if (!hit) return { schema: 'anchor/v1', present: false, code: 'E_ANCHOR_NOT_FOUND' };
312
+ return { schema: 'anchor/v1', present: true, anchor: hit };
313
+ },
314
+ records: (query) => {
315
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
316
+ const listed = listRecords({ memoryRoot, limit });
317
+ // EA-15: L5 forgotten/archived rows stay visible as audit metadata but never
318
+ // expose their body text through the panel records view.
319
+ const items = listed.items.map((row) => {
320
+ const state = row?.state;
321
+ if (state === 'forgotten' || state === 'archived') {
322
+ return { ...row, content: null, excerpt: null, body: null, redactedByState: state };
323
+ }
324
+ return row;
325
+ });
326
+ // EA-16/A5: panel record reads are audited (single source: audit/records.jsonl),
327
+ // but only when real rows exist (fresh roots stay side-effect free).
328
+ if (items.length > 0) {
329
+ try {
330
+ appendAuditRecord({ memoryRoot, action: 'read', recordId: null, readerTier: 'host', questionId: 'unknown', expectedTurns: null, answerText: null, extra: { route: '#17 records', total: listed.total } });
331
+ } catch { /* read auditing must never break the panel */ }
332
+ }
333
+ return {
334
+ schema: 'records/v1',
335
+ present: listed.present,
336
+ items,
337
+ total: listed.total,
338
+ conflicts: listed.conflicts,
339
+ conflictsCount: listed.conflicts.length,
340
+ unverified: listed.unverified,
341
+ badLines: listed.badLines,
342
+ ...(listed.present ? {} : { code: 'E_RECORDS_MISSING' }),
343
+ };
344
+ },
345
+ // #18–#23 治理/连续性只读(v2.8 §15.1/§15.2;缺数据一律空态,不 500)。
346
+ claims: (query = {}) => {
347
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
348
+ const listed = readClaims({ memoryRoot, actor: { kind: 'host' } });
349
+ const items = listed.items.slice(-limit);
350
+ return { schema: 'claims/v1', present: listed.present, items, total: listed.items.length, badLines: listed.badLines, ...(listed.present ? {} : { code: 'E_CLAIMS_MISSING' }) };
351
+ },
352
+ evidenceChain: (params = {}) => {
353
+ const claimId = typeof params.claimId === 'string' ? params.claimId : '';
354
+ if (claimId === '') return { schema: 'evidence-chain/v1', present: false, code: 'E_CLAIM_ID', lines: [] };
355
+ const chain = readEvidenceChain({ memoryRoot, claimId });
356
+ return { schema: 'evidence-chain/v1', present: chain.present, claimId: chain.claimId, version: chain.version, count: chain.count, head: chain.head, integrityOk: chain.integrityOk, lines: chain.lines, ...(chain.present ? {} : { code: 'E_EVIDENCE_CHAIN_MISSING' }) };
357
+ },
358
+ banks: () => {
359
+ const banks = listBanks({ memoryRoot });
360
+ const custom = banks.filter((bank) => bank.builtin !== true);
361
+ return { schema: 'banks/v1', present: custom.length > 0, items: custom, builtin: banks.filter((bank) => bank.builtin === true), total: banks.length, ...(custom.length > 0 ? {} : { code: 'E_BANKS_EMPTY' }) };
362
+ },
363
+ conflicts: (query = {}) => {
364
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
365
+ const unified = unifiedConflicts({ memoryRoot });
366
+ const conflicts = unified.conflicts.slice(0, limit);
367
+ return { schema: 'conflicts/v1', present: unified.entries.length > 0, items: conflicts, entries: unified.entries.slice(0, limit), total: unified.conflicts.length, source: unified.source, ...(unified.entries.length > 0 ? {} : { code: 'E_CONFLICTS_EMPTY' }) };
368
+ },
369
+ auditRecords: (query = {}) => {
370
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 1000) : 200;
371
+ const read = readJsonlLimited(path.join(base, 'audit', 'records.jsonl'), limit);
372
+ return { schema: 'audit-records/v1', present: read.present, items: read.rows.map((row) => normalizeAuditRecordRow(row)), badLines: read.bad, ...(read.present ? {} : { code: 'E_AUDIT_RECORDS_MISSING' }) };
373
+ },
374
+ frames: (query = {}) => {
375
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 50;
376
+ const dir = path.join(base, 'working');
377
+ const items = [];
378
+ try {
379
+ for (const name of fs.readdirSync(dir)) {
380
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
381
+ const value = readJson(path.join(dir, name));
382
+ if (!value.ok || !value.value || typeof value.value !== 'object') continue;
383
+ const frame = value.value;
384
+ items.push({
385
+ sessionId: frame.sessionId ?? name.replace(/\.json$/, ''),
386
+ frameId: frame.frameId ?? null,
387
+ frameNo: Number.isInteger(frame.frameNo) ? frame.frameNo : null,
388
+ turnRange: Array.isArray(frame.turnRange) ? frame.turnRange : null,
389
+ focus: frame.focus ?? null,
390
+ ttlMs: Number.isInteger(frame.ttlMs) ? frame.ttlMs : null,
391
+ lastUsedAt: frame.lastUsedAt ?? null,
392
+ state: frame.state ?? null,
393
+ });
394
+ }
395
+ } catch {
396
+ /* missing working dir => honest empty state */
397
+ }
398
+ items.sort((a, b) => String(b.lastUsedAt ?? '').localeCompare(String(a.lastUsedAt ?? '')));
399
+ return { schema: 'frames/v1', present: items.length > 0, items: items.slice(0, limit), total: items.length, ...(items.length > 0 ? {} : { code: 'E_FRAMES_EMPTY' }) };
400
+ },
401
+ // #24–#26 角色内核只读(v2.8 §16.3):attention / relationships / personality。
402
+ attention: (query = {}) => {
403
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 50;
404
+ return readAttentionView({ memoryRoot, now, limit, auditKey });
405
+ },
406
+ relationships: () => readRelationshipsView({ memoryRoot, now, auditKey }),
407
+ personality: () => readPersonalityView({ memoryRoot, now, auditKey }),
408
+ // #27 channels:只读状态(1.8.0 只做宿主内注册与只读,不接真实 QQ 协议,§16.7)。
409
+ channels: () => readChannels({ memoryRoot, now, auditKey }),
410
+ integrity: () => {
411
+ if (options.integrity && typeof options.integrity.status === 'function') {
412
+ return { schema: 'integrity/v1', ...options.integrity.status() };
413
+ }
414
+ return {
415
+ schema: 'integrity/v1',
416
+ ok: false,
417
+ manifestVersion: null,
418
+ checkedAt: now().toISOString(),
419
+ files: [],
420
+ lastError: 'E_INTEGRITY_NOT_WIRED',
421
+ };
422
+ },
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Create the read-only router (pure, no host wiring).
428
+ *
429
+ * @param {object} options
430
+ * @param {string} options.memoryRoot
431
+ * @param {ReturnType<import('./panel-auth.js').createPanelAuth>} options.auth
432
+ * @param {ReturnType<import('./panel-integrity.js').createIntegrityGuard>} [options.integrity]
433
+ * @param {(query:object)=>object} [options.eventsReader]
434
+ * @param {(anchorId:string)=>object|null} [options.anchorResolver]
435
+ * @param {Buffer|string} [options.auditKey] host auditKey for role-layer HMAC sidecars
436
+ * @param {() => object} [options.flagsSnapshot]
437
+ * @param {() => Date} [options.now]
438
+ */
439
+ export function createReadonlyRouter(options = {}) {
440
+ const auth = options.auth;
441
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
442
+ const readers = createDefaultReaders({ ...options, now });
443
+
444
+ function gate(request) {
445
+ if (!auth || typeof auth.actionGate !== 'function') {
446
+ throw new ReadonlyRouteError('E_NO_CAP', '宿主权限门未装配。', 401);
447
+ }
448
+ try {
449
+ return auth.actionGate({
450
+ source: request?.source ?? 'http',
451
+ headers: request?.headers ?? {},
452
+ actorKind: request?.actorKind ?? 'host',
453
+ });
454
+ } catch (error) {
455
+ throw new ReadonlyRouteError(error.code ?? 'E_NO_CAP', error.message ?? '权限校验失败。', error.status ?? 401);
456
+ }
457
+ }
458
+
459
+ function match(urlPath) {
460
+ for (const route of READONLY_ROUTES) {
461
+ if (!route.dynamic && route.path === urlPath) return { route, params: {}, query: {} };
462
+ }
463
+ const anchorPrefix = ANCHOR_DETAIL_PREFIX;
464
+ if (urlPath.startsWith(anchorPrefix)) {
465
+ const anchorId = decodeURIComponent(urlPath.slice(anchorPrefix.length));
466
+ if (anchorId !== '') {
467
+ return { route: READONLY_ROUTES.find((route) => route.id === 15), params: { anchorId } };
468
+ }
469
+ }
470
+ // G3 distill detail: `/api/longlive/distill/<id>` belongs to logical route
471
+ // #12 (it must not widen the documented route count: 16 -> 17 with #17 records).
472
+ if (urlPath.startsWith(DISTILL_DETAIL_PREFIX) && urlPath.length > DISTILL_DETAIL_PREFIX.length) {
473
+ const distillId = decodeURIComponent(urlPath.slice(DISTILL_DETAIL_PREFIX.length));
474
+ if (distillId !== '' && !distillId.includes('/')) {
475
+ return { route: READONLY_ROUTES.find((route) => route.id === 12), params: { id: distillId }, query: { id: distillId } };
476
+ }
477
+ }
478
+ // #19 evidence chain detail: /api/longlive/evidence/<claimId> (exact /evidence/latest matched first).
479
+ if (urlPath.startsWith(EVIDENCE_DETAIL_PREFIX) && urlPath.length > EVIDENCE_DETAIL_PREFIX.length) {
480
+ const claimId = decodeURIComponent(urlPath.slice(EVIDENCE_DETAIL_PREFIX.length));
481
+ if (claimId !== '' && !claimId.includes('/') && claimId !== 'latest') {
482
+ return { route: READONLY_ROUTES.find((route) => route.id === 19), params: { claimId }, query: {} };
483
+ }
484
+ }
485
+ return null;
486
+ }
487
+
488
+ /**
489
+ * Handle one in-process read-only request.
490
+ * @param {{method?:string, path:string, headers?:object, source?:string, actorKind?:string}} request
491
+ * @returns {{status:number, body:object}}
492
+ */
493
+ function handle(request = {}) {
494
+ const method = String(request.method ?? 'GET').toUpperCase();
495
+ const rawPath = String(request.path ?? '');
496
+ let url;
497
+ try {
498
+ url = new URL(rawPath, 'http://longlive.local');
499
+ } catch {
500
+ return { status: 400, body: { ok: false, error: { code: 'E_PATH', message: '非法请求路径。' } } };
501
+ }
502
+ const detail = match(url.pathname);
503
+ const query = { ...(detail?.query ?? {}) };
504
+ for (const [key, value] of url.searchParams.entries()) {
505
+ query[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : value;
506
+ }
507
+ if (method !== 'GET' && method !== 'HEAD') {
508
+ return {
509
+ status: 405,
510
+ body: { ok: false, error: { code: 'E_METHOD', message: `只读路由只支持 GET/HEAD(收到 ${method})。` } },
511
+ headers: { allow: 'GET, HEAD' },
512
+ };
513
+ }
514
+ try {
515
+ gate(request);
516
+ } catch (error) {
517
+ if (error instanceof ReadonlyRouteError) {
518
+ return { status: error.status, body: { ok: false, error: { code: error.code, message: error.message } } };
519
+ }
520
+ return { status: 401, body: { ok: false, error: { code: 'E_NO_CAP', message: '权限校验失败。' } } };
521
+ }
522
+ if (detail === null) {
523
+ return { status: 404, body: { ok: false, error: { code: 'E_ROUTE_UNKNOWN', message: `未知只读路由:${url.pathname}` } } };
524
+ }
525
+ if (method === 'HEAD') {
526
+ // HEAD passes the same capability gate; no body (same-origin probes).
527
+ return { status: 200, body: null, headers: { 'content-type': 'application/json; charset=utf-8' } };
528
+ }
529
+ const { route, params } = detail;
530
+ try {
531
+ const data = readRoute(route, params, query);
532
+ return {
533
+ status: 200,
534
+ body: {
535
+ ok: true,
536
+ present: data?.present !== false,
537
+ generatedAt: now().toISOString(),
538
+ ...data,
539
+ },
540
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
541
+ };
542
+ } catch (error) {
543
+ if (error instanceof ReadonlyRouteError) {
544
+ return { status: error.status, body: { ok: false, error: { code: error.code, message: error.message } } };
545
+ }
546
+ return { status: 500, body: { ok: false, error: { code: 'E_READONLY_FAILED', message: error?.message ?? String(error) } } };
547
+ }
548
+ }
549
+
550
+ /**
551
+ * §16.8 / R4 P1-6:固定 id → handler 显式映射(不依赖数组顺序)。
552
+ * 每条 route id 必须在表里有 handler;缺表 = 启动即抛,避免数组顺序漂移造成串路。
553
+ */
554
+ const HANDLERS_BY_ID = new Map([
555
+ [1, (params, query) => readers.flags()],
556
+ [2, (params, query) => readers.board()],
557
+ [3, (params, query) => readers.status()],
558
+ [4, (params, query) => readers.live()],
559
+ [5, (params, query) => readers.activity()],
560
+ [6, (params, query) => readers.evidenceLatest()],
561
+ [7, (params, query) => readers.gap()],
562
+ [8, (params, query) => readers.recallLog(query)],
563
+ [9, (params, query) => readers.writeHealth()],
564
+ [10, (params, query) => readers.review()],
565
+ [11, (params, query) => readers.events(query)],
566
+ [12, (params, query) => readers.distill(query)],
567
+ [13, (params, query) => readers.archive()],
568
+ [14, (params, query) => readers.protection()],
569
+ [15, (params, query) => readers.anchor(params)],
570
+ [16, (params, query) => readers.integrity()],
571
+ [17, (params, query) => readers.records(query)],
572
+ [18, (params, query) => readers.claims(query)],
573
+ [19, (params) => readers.evidenceChain(params)],
574
+ [20, (params, query) => readers.banks(query)],
575
+ [21, (params, query) => readers.conflicts(query)],
576
+ [22, (params, query) => readers.auditRecords(query)],
577
+ [23, (params, query) => readers.frames(query)],
578
+ [24, (params, query) => readers.attention(query)],
579
+ [25, () => readers.relationships()],
580
+ [26, () => readers.personality()],
581
+ [27, () => readers.channels()],
582
+ ]);
583
+ for (const route of READONLY_ROUTES) {
584
+ if (!HANDLERS_BY_ID.has(route.id)) {
585
+ throw new ReadonlyRouteError('E_ROUTE_HANDLER_MISSING', `只读路由 #${route.id} 没有显式 handler(id 表与 READONLY_ROUTES 不一致)。`, 500);
586
+ }
587
+ }
588
+
589
+ function readRoute(route, params, query) {
590
+ const handler = HANDLERS_BY_ID.get(route.id);
591
+ if (typeof handler !== 'function') {
592
+ throw new ReadonlyRouteError('E_ROUTE_UNKNOWN', `未知只读路由 #${route.id}`, 404);
593
+ }
594
+ return handler(params, query);
595
+ }
596
+
597
+ return { handle, routes: READONLY_ROUTES, readers };
598
+ }
599
+
600
+ const warned = new Set();
601
+ function warnOnce(key, error) {
602
+ if (warned.has(key)) return;
603
+ warned.add(key);
604
+ try {
605
+ console.warn(`[longlive] ${key}: ${error?.stack ?? error?.message ?? String(error)}`);
606
+ } catch {
607
+ /* logging must never break activation */
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Register the 16 read-only routes on the host Web server.
613
+ * A profile without a Web server simply gets no route; activation never depends on it.
614
+ */
615
+ export function registerReadonlyRoutes(ctx, options = {}) {
616
+ if (typeof ctx?.inject !== 'function') return false;
617
+ try {
618
+ ctx.inject(['webServer'], (webCtx) => {
619
+ const webServer = webCtx?.webServer;
620
+ if (webServer === undefined || typeof webServer.register !== 'function') return;
621
+ const router = createReadonlyRouter(options);
622
+ const handleRequest = (req, res) => {
623
+ const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
624
+ const host = String(req?.headers?.host ?? 'longlive.local');
625
+ const urlPath = String(req?.url ?? '/');
626
+ const verdict = router.handle({
627
+ method,
628
+ path: `http://${host}${urlPath}`,
629
+ headers: req?.headers ?? {},
630
+ source: 'http',
631
+ });
632
+ if (verdict.body === null) {
633
+ try {
634
+ res.writeHead(verdict.status, verdict.headers ?? {});
635
+ res.end();
636
+ } catch {
637
+ /* peer gone */
638
+ }
639
+ return;
640
+ }
641
+ const payload = JSON.stringify(verdict.body);
642
+ try {
643
+ res.writeHead(verdict.status, {
644
+ 'content-type': 'application/json; charset=utf-8',
645
+ 'cache-control': 'no-store',
646
+ 'content-length': Buffer.byteLength(payload),
647
+ ...(verdict.headers ?? {}),
648
+ });
649
+ res.end(payload);
650
+ } catch {
651
+ try {
652
+ res.destroy?.();
653
+ } catch {
654
+ /* peer gone */
655
+ }
656
+ }
657
+ };
658
+ for (const route of READONLY_ROUTES) {
659
+ const registration = route.dynamic
660
+ ? { kind: 'prefix', path: route.path.replace(/\/:anchorId$/, '/'), handler: handleRequest }
661
+ : { kind: 'exact', path: route.path, handler: handleRequest };
662
+ try {
663
+ const dispose = webServer.register(registration);
664
+ if (typeof dispose === 'function' && typeof webCtx.effect === 'function') {
665
+ try {
666
+ webCtx.effect(() => () => dispose(), `longlive: readonly route ${route.id}`);
667
+ } catch {
668
+ /* fake context without an effect scope */
669
+ }
670
+ }
671
+ } catch (error) {
672
+ warnOnce(`readonly route ${route.id} registration failed`, error);
673
+ }
674
+ }
675
+ // G3 detail route (`/api/longlive/distill/<id>`) is served by a prefix
676
+ // route so it does not widen the documented 16-route count.
677
+ try {
678
+ const dispose = webServer.register({ kind: 'prefix', path: DISTILL_DETAIL_PREFIX, handler: handleRequest });
679
+ if (typeof dispose === 'function' && typeof webCtx.effect === 'function') {
680
+ try {
681
+ webCtx.effect(() => () => dispose(), 'longlive: distill detail route');
682
+ } catch {
683
+ /* fake context */
684
+ }
685
+ }
686
+ } catch (error) {
687
+ warnOnce('distill detail route registration failed', error);
688
+ }
689
+ });
690
+ return true;
691
+ } catch (error) {
692
+ warnOnce('readonly route inject failed', error);
693
+ return false;
694
+ }
695
+ }