prts-terrarchive 0.2.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 (287) hide show
  1. package/GAME_ASSETS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +274 -0
  4. package/README.md +423 -0
  5. package/THIRD_PARTY_NOTICES.md +61 -0
  6. package/bin/bake-rhine-details.mjs +34 -0
  7. package/bin/install.js +273 -0
  8. package/bin/pack-map-assets.mjs +110 -0
  9. package/bin/preview-rhine.mjs +71 -0
  10. package/bin/rhine-detail-baker.ts +84 -0
  11. package/contracts/cloud_tools_v1.schema.json +243 -0
  12. package/contracts/corpus_i18n_v1.schema.json +179 -0
  13. package/contracts/corpus_tools_v1.schema.json +1801 -0
  14. package/cordis.patch.yml +32 -0
  15. package/docs/endfield-official-i18n.md +103 -0
  16. package/lib/client.js +3912 -0
  17. package/lib/endfield-map/map.js.br +0 -0
  18. package/lib/endfield-map/map.js.gz +0 -0
  19. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
  20. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
  21. package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
  22. package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
  23. package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
  24. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
  25. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
  26. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
  27. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
  28. package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
  29. package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
  30. package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
  31. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
  32. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
  33. package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
  34. package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
  35. package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
  36. package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
  37. package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
  38. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
  39. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
  40. package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
  41. package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
  42. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
  43. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
  44. package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
  45. package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
  46. package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
  47. package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
  48. package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
  49. package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
  50. package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
  51. package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
  52. package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
  53. package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
  54. package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
  55. package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
  56. package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
  57. package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
  58. package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
  59. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
  60. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
  61. package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
  62. package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
  63. package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
  64. package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
  65. package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
  66. package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
  67. package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
  68. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
  69. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
  70. package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
  71. package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
  72. package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
  73. package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
  74. package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
  75. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
  76. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
  77. package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
  78. package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
  79. package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
  80. package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
  81. package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
  82. package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
  83. package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
  84. package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
  85. package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
  86. package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
  87. package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
  88. package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
  89. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
  90. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
  91. package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
  92. package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
  93. package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
  94. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
  95. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
  96. package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
  97. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
  98. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
  99. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
  100. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
  101. package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
  102. package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
  103. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
  104. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
  105. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
  106. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
  107. package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
  108. package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
  109. package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
  110. package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
  111. package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
  112. package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
  113. package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
  114. package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
  115. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
  116. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
  117. package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
  118. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
  119. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
  120. package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
  121. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
  122. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
  123. package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
  124. package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
  125. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
  126. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
  127. package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
  128. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
  129. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
  130. package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
  131. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
  132. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
  133. package/lib/rhine/assets/archive-assembly.glb +0 -0
  134. package/lib/rhine/assets/archive-cassette.glb +0 -0
  135. package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
  136. package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
  137. package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
  138. package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
  139. package/lib/rhine/fonts/MiSans-license.pdf +0 -0
  140. package/lib/rhine/fonts/NOTICE.txt +6 -0
  141. package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
  142. package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
  143. package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
  144. package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
  145. package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
  146. package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
  147. package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
  148. package/lib/rhine/licenses/three-MIT.txt +21 -0
  149. package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
  150. package/lib/rhine/rhine.css +1 -0
  151. package/lib/rhine/rhine.js +5106 -0
  152. package/lib/skins/common.css +31 -0
  153. package/lib/skins/endfield-aic.css +141 -0
  154. package/lib/skins/prts-agent.css +136 -0
  155. package/lib/skins/rhine-lab.css +70 -0
  156. package/package.json +112 -0
  157. package/presets/definition.js +26 -0
  158. package/presets/prts/agent.cordis.yml +25 -0
  159. package/presets/prts/preset.yml +3 -0
  160. package/presets/register.js +86 -0
  161. package/resources/entity-routing.json +21 -0
  162. package/skills/prts-investigation/SKILL.md +121 -0
  163. package/skills/prts-retrieval/SKILL.md +35 -0
  164. package/skills/prts-retrieval/references/cloud-tools.md +13 -0
  165. package/skills/prts-retrieval/references/diagnostics.md +15 -0
  166. package/skills/prts-retrieval/references/local-tools.md +24 -0
  167. package/skills/prts-retrieval/references/module-arknights.md +23 -0
  168. package/skills/prts-retrieval/references/module-dual.md +15 -0
  169. package/skills/prts-retrieval/references/module-endfield.md +21 -0
  170. package/skills/prts-retrieval/references/retrieval-process.md +44 -0
  171. package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
  172. package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
  173. package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
  174. package/skills/prts-retrieval/references/tools-runtime.md +48 -0
  175. package/skills/prts-retrieval/references/wiki-schema.md +38 -0
  176. package/src/archive-presentation.js +287 -0
  177. package/src/cloud-projection.js +124 -0
  178. package/src/cloud.js +498 -0
  179. package/src/entity-recognizer.js +311 -0
  180. package/src/entity-routing.js +82 -0
  181. package/src/evidence-state.js +332 -0
  182. package/src/i18n.js +365 -0
  183. package/src/index.js +1261 -0
  184. package/src/installer.js +1362 -0
  185. package/src/investigation-review.js +87 -0
  186. package/src/investigation-store.js +579 -0
  187. package/src/investigation-tools.js +113 -0
  188. package/src/localization-format.js +23 -0
  189. package/src/manual-search.js +103 -0
  190. package/src/read.js +1097 -0
  191. package/src/release-compatibility.js +73 -0
  192. package/src/release-watch.js +40 -0
  193. package/src/search-projection.js +139 -0
  194. package/src/search.js +1522 -0
  195. package/src/skill.js +98 -0
  196. package/src/source-map.js +348 -0
  197. package/src/state.js +314 -0
  198. package/src/store.js +1569 -0
  199. package/src/timeline.js +386 -0
  200. package/src/ui.js +1353 -0
  201. package/src/wiki.js +111 -0
  202. package/tsconfig.rhine.json +15 -0
  203. package/ui/rhine/archive-navigation-limit.ts +31 -0
  204. package/ui/rhine/archive-rack.ts +83 -0
  205. package/ui/rhine/assets/evidence-observatory.webp +0 -0
  206. package/ui/rhine/board-plane-transform.ts +24 -0
  207. package/ui/rhine/catalogue.ts +72 -0
  208. package/ui/rhine/deferred-preparation.ts +146 -0
  209. package/ui/rhine/evidence-board-controls.css +22 -0
  210. package/ui/rhine/evidence-board-history.ts +73 -0
  211. package/ui/rhine/evidence-board-model.ts +189 -0
  212. package/ui/rhine/evidence-board-panel.ts +762 -0
  213. package/ui/rhine/evidence-board-scene.ts +548 -0
  214. package/ui/rhine/evidence-board.css +191 -0
  215. package/ui/rhine/evidence-card-art.ts +470 -0
  216. package/ui/rhine/evidence-card-style.ts +17 -0
  217. package/ui/rhine/evidence-inbox-scene.ts +43 -0
  218. package/ui/rhine/evidence-inbox.css +29 -0
  219. package/ui/rhine/evidence-inbox.ts +104 -0
  220. package/ui/rhine/index.ts +12 -0
  221. package/ui/rhine/interaction.css +20 -0
  222. package/ui/rhine/investigation-board-plane.ts +5 -0
  223. package/ui/rhine/investigation-board.css +34 -0
  224. package/ui/rhine/investigation-board.ts +348 -0
  225. package/ui/rhine/investigation-edits.ts +46 -0
  226. package/ui/rhine/investigation-layout.ts +29 -0
  227. package/ui/rhine/label-prefetch.ts +34 -0
  228. package/ui/rhine/looping-title.ts +40 -0
  229. package/ui/rhine/manual-search.css +41 -0
  230. package/ui/rhine/manual-search.ts +89 -0
  231. package/ui/rhine/original/appearance.ts +180 -0
  232. package/ui/rhine/original/archive-label.ts +84 -0
  233. package/ui/rhine/original/archive-lighting.ts +39 -0
  234. package/ui/rhine/original/archive-loop.ts +82 -0
  235. package/ui/rhine/original/archive-refill.ts +64 -0
  236. package/ui/rhine/original/array-detail-data.ts +2 -0
  237. package/ui/rhine/original/array-detail.ts +41 -0
  238. package/ui/rhine/original/array-interior.ts +411 -0
  239. package/ui/rhine/original/array-occlusion.ts +166 -0
  240. package/ui/rhine/original/array-shell.ts +38 -0
  241. package/ui/rhine/original/array-visibility.ts +309 -0
  242. package/ui/rhine/original/asset-url.ts +4 -0
  243. package/ui/rhine/original/brand.ts +12 -0
  244. package/ui/rhine/original/cassette-lod.ts +81 -0
  245. package/ui/rhine/original/data.ts +21 -0
  246. package/ui/rhine/original/decryption.ts +185 -0
  247. package/ui/rhine/original/detail-signature.ts +18 -0
  248. package/ui/rhine/original/glass-reveal.ts +45 -0
  249. package/ui/rhine/original/internal-optics.ts +70 -0
  250. package/ui/rhine/original/label-renderer.ts +119 -0
  251. package/ui/rhine/original/label-textures.ts +117 -0
  252. package/ui/rhine/original/model-viewer.ts +658 -0
  253. package/ui/rhine/original/motion.ts +116 -0
  254. package/ui/rhine/original/opaque-backfaces.ts +64 -0
  255. package/ui/rhine/original/program-preparation.ts +111 -0
  256. package/ui/rhine/original/quality-renderer.ts +97 -0
  257. package/ui/rhine/original/quality-settings.ts +111 -0
  258. package/ui/rhine/original/render-quality.ts +163 -0
  259. package/ui/rhine/original/scene.ts +2051 -0
  260. package/ui/rhine/original/shelf-interior.ts +142 -0
  261. package/ui/rhine/original/ui-transitions.ts +111 -0
  262. package/ui/rhine/original/viewer-camera.ts +92 -0
  263. package/ui/rhine/original/viewport-layout.ts +41 -0
  264. package/ui/rhine/original-ui.css +2334 -0
  265. package/ui/rhine/readability.css +46 -0
  266. package/ui/rhine/reading-object.ts +96 -0
  267. package/ui/rhine/reading-origin.ts +28 -0
  268. package/ui/rhine/reading-room.css +55 -0
  269. package/ui/rhine/report-markdown.ts +98 -0
  270. package/ui/rhine/scene-edge-navigation.ts +84 -0
  271. package/ui/rhine/scene.ts +1726 -0
  272. package/ui/rhine/shelf-layout.ts +38 -0
  273. package/ui/rhine/snapshot-update-gate.ts +22 -0
  274. package/ui/rhine/source-reading.ts +15 -0
  275. package/ui/rhine/source-selection.css +164 -0
  276. package/ui/rhine/temporary-performance-observers.ts +163 -0
  277. package/ui/rhine/temporary-performance-panel.ts +124 -0
  278. package/ui/rhine/temporary-performance.css +19 -0
  279. package/ui/rhine/temporary-performance.ts +552 -0
  280. package/ui/rhine/temporary-render-diagnostics.ts +105 -0
  281. package/ui/rhine/tool-activity.ts +69 -0
  282. package/ui/rhine/types.ts +219 -0
  283. package/ui/rhine/visible-scene-hit.ts +13 -0
  284. package/ui/rhine/workbench.css +818 -0
  285. package/ui/rhine/workbench.ts +2245 -0
  286. package/ui/rhine/workspace-travel.ts +32 -0
  287. package/vite.rhine.config.ts +19 -0
package/src/read.js ADDED
@@ -0,0 +1,1097 @@
1
+ /**
2
+ * corpus_read 契约实现(corpus_tools_v1.schema.json,prts-corpus-tools-v1)。
3
+ *
4
+ * 与浏览器端 agent/browser/src/corpus-executor.js 语义对齐:
5
+ * - 单篇 locator + around/range/document,或活动/任务合集 + 连续位置分页
6
+ * - source_ref 内嵌行号在 around 模式下即中心行;document_id + around 必须显式给 center_line;
7
+ * display_title + around 的 center_line 可选(执行时缺失则报 LINE_RANGE_INVALID)
8
+ * - 全文行完整性校验(INDEX_CORRUPT)
9
+ * - expected_data_version 版本绑定(PACKAGE_VERSION_MISMATCH)
10
+ * - document 模式 HMAC 游标分页(插件生命周期内有效)
11
+ * - story 文档只返回请求的原文;剧情总结与时间线必须显式检索
12
+ */
13
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
14
+ import { assertCorpusVersion, corpusVersionSnapshot, computeLinesIntegrity,
15
+ documentGame, documentUid, naturalDocumentTitle,
16
+ operatorRecordSegment, publicCharacterMaterial, publicStoryPart,
17
+ publicStoryStageCode } from './store.js'
18
+ import { WIKI_SECTION_VALUES, wikiSectionRanges } from './wiki.js'
19
+
20
+ export const CONTRACT_VERSION = 'prts-corpus-tools-v1'
21
+
22
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
23
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/
24
+ const SOURCE_REF_PATTERN =
25
+ /^(?:(?:official_game:(?:story:[^:]+|character:[^:]+:[^:]+)|client_data:(?:reviewed_wiki|terra_journey|entities|references):[0-9a-f]{24})|prts:(?:arknights|endfield):[A-Za-z0-9._:%/-]+):L([1-9][0-9]*)$/
26
+
27
+ export const END_FIELD_STORY_CONTENT_TYPES = Object.freeze([
28
+ 'dialogue', 'cutscene', 'radio', 'remote_comm', 'black_screen',
29
+ 'environment_talk', 'sns_topic', 'sns_chat', 'narration',
30
+ ])
31
+
32
+ /** 游标签名密钥:插件实例生命周期内有效(重启即失效,符合契约的游标不透明语义)。 */
33
+ const CURSOR_SECRET = randomBytes(32)
34
+
35
+ /** 契约错误:映射为 errorResponse(status=error)。 */
36
+ export class ContractError extends Error {
37
+ /**
38
+ * @param {string} code toolError.code 枚举值
39
+ * @param {string} message
40
+ * @param {{ retryable?: boolean }} [options]
41
+ */
42
+ constructor(code, message, options = {}) {
43
+ super(message)
44
+ this.code = code
45
+ this.retryable = options.retryable ?? false
46
+ }
47
+ }
48
+
49
+ const estimateTokens = (value) => Math.ceil(
50
+ (typeof value === 'number' ? value : String(value ?? '').length) / 2.5,
51
+ )
52
+
53
+ /** ---- 游标(document 模式分页) ---- */
54
+
55
+ function encodeCursor(payload) {
56
+ const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
57
+ const sig = createHmac('sha256', CURSOR_SECRET).update(body).digest('base64url')
58
+ return `${body}.${sig}`
59
+ }
60
+
61
+ function decodeCursor(cursor) {
62
+ const dot = cursor.lastIndexOf('.')
63
+ if (dot <= 0) throw new ContractError('CURSOR_INVALID', 'malformed cursor')
64
+ const [body, sig] = [cursor.slice(0, dot), cursor.slice(dot + 1)]
65
+ // 与 search.js 的游标校验同口径:常数时间比较,长度不等先拒。
66
+ const expected = createHmac('sha256', CURSOR_SECRET).update(body).digest()
67
+ const received = Buffer.from(sig, 'base64url')
68
+ if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
69
+ throw new ContractError('CURSOR_INVALID', 'cursor signature mismatch')
70
+ }
71
+ let payload
72
+ try {
73
+ payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
74
+ } catch {
75
+ throw new ContractError('CURSOR_INVALID', 'cursor payload is not JSON')
76
+ }
77
+ if (typeof payload !== 'object' || payload === null || typeof payload.v !== 'number') {
78
+ throw new ContractError('CURSOR_INVALID', 'cursor payload has unknown shape')
79
+ }
80
+ return payload
81
+ }
82
+
83
+ /** 模型只回传 cursor;在工具边界内部恢复文档 locator 与 document 模式。 */
84
+ export function readContractFromCursor(cursor) {
85
+ const payload = decodeCursor(String(cursor || ''))
86
+ if (payload.v !== 1 || typeof payload.document_id !== 'string' || !payload.document_id) {
87
+ throw new ContractError('CURSOR_INVALID', 'cursor 不是单篇文档读取游标')
88
+ }
89
+ if (payload.max_lines !== undefined && !Number.isInteger(payload.max_lines)) {
90
+ throw new ContractError('CURSOR_INVALID', 'cursor max_lines 无效')
91
+ }
92
+ if (payload.max_chars !== undefined && !Number.isInteger(payload.max_chars)) {
93
+ throw new ContractError('CURSOR_INVALID', 'cursor max_chars 无效')
94
+ }
95
+ return { locator: { document_id: payload.document_id },
96
+ selection: { mode: 'document', cursor: String(cursor) },
97
+ limits: { ...(payload.max_lines !== undefined ? { max_lines: payload.max_lines } : {}),
98
+ ...(payload.max_chars !== undefined ? { max_chars: payload.max_chars } : {}) } }
99
+ }
100
+
101
+ /** ---- 参数校验(跨字段规则由代码执行;DSL 只保证基础形态) ---- */
102
+
103
+ function requireInt(value, { min, max, field }) {
104
+ if (!Number.isInteger(value)) throw new ContractError('INVALID_REQUEST', `${field} must be an integer`)
105
+ if (value < min || value > max) {
106
+ throw new ContractError('INVALID_REQUEST', `${field} must be within [${min}, ${max}]`)
107
+ }
108
+ return value
109
+ }
110
+
111
+ function requireIdString(value, field) {
112
+ if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
113
+ throw new ContractError('INVALID_REQUEST', `${field} must match ${ID_PATTERN}`)
114
+ }
115
+ return value
116
+ }
117
+
118
+ /**
119
+ * 校验并归一化 corpus_read 请求(填默认值,产出 normalized_request)。
120
+ * @throws {ContractError}
121
+ */
122
+ export function normalizeReadRequest(raw) {
123
+ if (typeof raw !== 'object' || raw === null) {
124
+ throw new ContractError('INVALID_REQUEST', 'request must be an object')
125
+ }
126
+ const { intent_id: intentId, request_id: requestIdInput, expected_data_version: expectedVersion } = raw
127
+
128
+ const intentIdChecked = requireIdString(intentId, 'intent_id')
129
+ const requestId = requestIdInput === undefined
130
+ ? `req-${randomBytes(8).toString('hex')}`
131
+ : requireIdString(requestIdInput, 'request_id')
132
+ if (expectedVersion !== undefined && !SHA256_PATTERN.test(expectedVersion)) {
133
+ throw new ContractError('INVALID_REQUEST', 'expected_data_version must be a lowercase sha256 hex string')
134
+ }
135
+
136
+ // locator:单篇文档、明日方舟活动或终末地集合恰好一个
137
+ const locatorRaw = raw.locator
138
+ if (typeof locatorRaw !== 'object' || locatorRaw === null) {
139
+ throw new ContractError('INVALID_REQUEST', 'locator must be an object')
140
+ }
141
+ const hasSourceRef = locatorRaw.source_ref !== undefined
142
+ const hasDocumentId = locatorRaw.document_id !== undefined
143
+ const hasDocumentUid = locatorRaw.document_uid !== undefined
144
+ const hasDisplayTitle = locatorRaw.display_title !== undefined
145
+ const hasActivityId = locatorRaw.activity_id !== undefined
146
+ const hasActivityName = locatorRaw.activity_name !== undefined
147
+ const hasCollectionName = locatorRaw.collection_name !== undefined
148
+ const hasStreamLocator = hasActivityId || hasActivityName || hasCollectionName
149
+ const locatorCount = Number(hasSourceRef) + Number(hasDocumentId) + Number(hasDocumentUid) + Number(hasDisplayTitle)
150
+ + Number(hasActivityId) + Number(hasActivityName) + Number(hasCollectionName)
151
+ if (locatorCount !== 1) {
152
+ throw new ContractError('INVALID_REQUEST',
153
+ 'locator must contain exactly one of source_ref / document_id / document_uid / display_title / activity_id / activity_name / collection_name')
154
+ }
155
+ let locator
156
+ let refLine = null
157
+ if (hasSourceRef) {
158
+ const sourceRef = locatorRaw.source_ref
159
+ if (typeof sourceRef !== 'string' || sourceRef.length > 1024) {
160
+ throw new ContractError('SOURCE_REF_INVALID', 'source_ref must be a string of at most 1024 chars')
161
+ }
162
+ const match = SOURCE_REF_PATTERN.exec(sourceRef)
163
+ if (!match) throw new ContractError('SOURCE_REF_INVALID', `source_ref does not match contract pattern: ${sourceRef}`)
164
+ refLine = Number.parseInt(match[1], 10)
165
+ locator = { source_ref: sourceRef }
166
+ } else if (hasDocumentId) {
167
+ const documentId = locatorRaw.document_id
168
+ if (typeof documentId !== 'string' || documentId.length < 1 || documentId.length > 512 || !/\S/.test(documentId)) {
169
+ throw new ContractError('INVALID_REQUEST', 'document_id must be a non-empty identifier')
170
+ }
171
+ locator = { document_id: documentId }
172
+ } else if (hasDocumentUid) {
173
+ const uid = String(locatorRaw.document_uid || '').trim()
174
+ if (!/^doc_[A-Za-z0-9_-]{16}$/u.test(uid)) {
175
+ throw new ContractError('INVALID_REQUEST', 'document_uid must be a public doc_ locator')
176
+ }
177
+ locator = { document_uid: uid }
178
+ } else if (hasDisplayTitle) {
179
+ const displayTitle = locatorRaw.display_title
180
+ if (typeof displayTitle !== 'string' || displayTitle.length < 1 || displayTitle.length > 512 || !/\S/.test(displayTitle)) {
181
+ throw new ContractError('INVALID_REQUEST', 'display_title must be a non-empty title of at most 512 chars')
182
+ }
183
+ locator = { display_title: displayTitle }
184
+ } else if (hasActivityId) {
185
+ const activityId = String(locatorRaw.activity_id).trim()
186
+ if (!activityId || activityId.length > 512) {
187
+ throw new ContractError('INVALID_REQUEST', 'activity_id must be a non-empty identifier of at most 512 chars')
188
+ }
189
+ locator = { activity_id: activityId }
190
+ } else if (hasActivityName) {
191
+ const activityName = String(locatorRaw.activity_name).trim()
192
+ if (!activityName || activityName.length > 512) {
193
+ throw new ContractError('INVALID_REQUEST', 'activity_name must be a non-empty title of at most 512 chars')
194
+ }
195
+ locator = { activity_name: activityName }
196
+ } else {
197
+ const collectionName = String(locatorRaw.collection_name).trim()
198
+ if (!collectionName || collectionName.length > 512) {
199
+ throw new ContractError('INVALID_REQUEST', 'collection_name must be a non-empty title of at most 512 chars')
200
+ }
201
+ locator = { collection_name: collectionName }
202
+ }
203
+
204
+ // selection:around / range / document / section / activity / collection
205
+ const selectionRaw = raw.selection
206
+ if (typeof selectionRaw !== 'object' || selectionRaw === null) {
207
+ throw new ContractError('INVALID_REQUEST', 'selection must be an object')
208
+ }
209
+ const mode = selectionRaw.mode
210
+ let selection
211
+ if (mode === 'around') {
212
+ if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'around mode requires a document locator')
213
+ if (hasSourceRef) {
214
+ if (selectionRaw.center_line !== undefined) {
215
+ throw new ContractError('INVALID_REQUEST', 'around with source_ref locator must not set center_line')
216
+ }
217
+ selection = {
218
+ mode: 'around',
219
+ before_lines: selectionRaw.before_lines ?? 3,
220
+ after_lines: selectionRaw.after_lines ?? 3,
221
+ }
222
+ } else {
223
+ // document_id:center_line 必填;display_title:center_line 可选(执行时缺失报错)
224
+ if ((hasDocumentId || hasDocumentUid) && selectionRaw.center_line === undefined) {
225
+ throw new ContractError('INVALID_REQUEST', 'around with document_id/document_uid locator requires center_line')
226
+ }
227
+ if (selectionRaw.center_line !== undefined) {
228
+ requireInt(selectionRaw.center_line, { min: 1, max: 1e9, field: 'center_line' })
229
+ }
230
+ selection = {
231
+ mode: 'around',
232
+ ...(selectionRaw.center_line !== undefined ? { center_line: selectionRaw.center_line } : {}),
233
+ before_lines: selectionRaw.before_lines ?? 3,
234
+ after_lines: selectionRaw.after_lines ?? 3,
235
+ }
236
+ }
237
+ requireInt(selection.before_lines, { min: 0, max: 100, field: 'before_lines' })
238
+ requireInt(selection.after_lines, { min: 0, max: 100, field: 'after_lines' })
239
+ } else if (mode === 'range') {
240
+ if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'range mode requires a document locator')
241
+ const startLine = requireInt(selectionRaw.start_line, { min: 1, max: 1e9, field: 'start_line' })
242
+ const endLine = requireInt(selectionRaw.end_line, { min: 1, max: 1e9, field: 'end_line' })
243
+ if (endLine < startLine) throw new ContractError('LINE_RANGE_INVALID', 'end_line must be >= start_line')
244
+ selection = { mode: 'range', start_line: startLine, end_line: endLine }
245
+ } else if (mode === 'document') {
246
+ if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'document mode requires a document locator')
247
+ const startLine = selectionRaw.start_line === undefined ? 1
248
+ : requireInt(selectionRaw.start_line, { min: 1, max: 1e9, field: 'start_line' })
249
+ if (selectionRaw.cursor != null && selectionRaw.start_line !== undefined) {
250
+ throw new ContractError('INVALID_REQUEST', 'document mode cannot combine cursor with start_line')
251
+ }
252
+ selection = { mode: 'document', cursor: selectionRaw.cursor ?? null, start_line: startLine }
253
+ } else if (mode === 'section') {
254
+ if (hasStreamLocator) {
255
+ throw new ContractError('INVALID_REQUEST', 'section mode requires a document locator')
256
+ }
257
+ const section = String(selectionRaw.section || '').trim()
258
+ if (!WIKI_SECTION_VALUES.includes(section)) {
259
+ throw new ContractError('INVALID_REQUEST', 'section must be a supported Wiki field')
260
+ }
261
+ selection = { mode: 'section', section }
262
+ } else if (mode === 'activity' || mode === 'collection') {
263
+ const expectedLocator = mode === 'activity'
264
+ ? hasActivityId || hasActivityName || hasDocumentUid
265
+ : hasCollectionName || hasDocumentUid
266
+ if (!expectedLocator) {
267
+ throw new ContractError('INVALID_REQUEST',
268
+ `${mode} mode requires ${mode === 'activity' ? 'activity_id or activity_name' : 'collection_name'} locator`)
269
+ }
270
+ if (selectionRaw.cursor != null && selectionRaw.start_position !== undefined) {
271
+ throw new ContractError('INVALID_REQUEST', `${mode} mode cannot combine cursor with start_position`)
272
+ }
273
+ const startPosition = selectionRaw.start_position === undefined ? 1
274
+ : requireInt(selectionRaw.start_position, { min: 1, max: 1e9, field: 'start_position' })
275
+ let contentTypes = []
276
+ if (selectionRaw.content_types !== undefined) {
277
+ if (mode !== 'collection' || !Array.isArray(selectionRaw.content_types)
278
+ || selectionRaw.content_types.length > END_FIELD_STORY_CONTENT_TYPES.length
279
+ || selectionRaw.content_types.some((item) => !END_FIELD_STORY_CONTENT_TYPES.includes(item))) {
280
+ throw new ContractError('INVALID_REQUEST',
281
+ 'content_types is only available in collection mode and must contain supported original-story types')
282
+ }
283
+ contentTypes = [...new Set(selectionRaw.content_types)]
284
+ }
285
+ selection = { mode, cursor: selectionRaw.cursor ?? null, start_position: startPosition,
286
+ ...(contentTypes.length ? { content_types: contentTypes } : {}) }
287
+ } else {
288
+ throw new ContractError('INVALID_REQUEST',
289
+ 'selection.mode must be around | range | document | section | activity | collection')
290
+ }
291
+
292
+ const format = raw.format === undefined ? 'lines' : raw.format
293
+ if (format !== 'lines' && format !== 'plain_text') {
294
+ throw new ContractError('INVALID_REQUEST', 'format must be lines | plain_text')
295
+ }
296
+
297
+ const includeAdjacent = raw.include_adjacent_documents === undefined ? true : raw.include_adjacent_documents
298
+ if (typeof includeAdjacent !== 'boolean') {
299
+ throw new ContractError('INVALID_REQUEST', 'include_adjacent_documents must be a boolean')
300
+ }
301
+
302
+ const limitsRaw = raw.limits ?? {}
303
+ if (typeof limitsRaw !== 'object' || limitsRaw === null) {
304
+ throw new ContractError('INVALID_REQUEST', 'limits must be an object')
305
+ }
306
+ const limits = {
307
+ max_lines: limitsRaw.max_lines ?? 100,
308
+ max_chars: limitsRaw.max_chars ?? 12000,
309
+ }
310
+ requireInt(limits.max_lines, { min: 1, max: 500, field: 'limits.max_lines' })
311
+ requireInt(limits.max_chars, { min: 100, max: 100000, field: 'limits.max_chars' })
312
+
313
+ const normalized = {
314
+ intent_id: intentIdChecked,
315
+ request_id: requestId,
316
+ locator,
317
+ selection,
318
+ format,
319
+ include_adjacent_documents: includeAdjacent,
320
+ limits,
321
+ }
322
+ if (expectedVersion !== undefined) normalized.expected_data_version = expectedVersion
323
+ return { normalized, refLine }
324
+ }
325
+
326
+ /** ---- 文档摘要投影 ---- */
327
+
328
+ const SUMMARY_FIELDS = [
329
+ 'game', 'resource_type', 'content_type', 'collection_name', 'collection_type',
330
+ 'document_id', 'document_type', 'document_category', 'document_kind', 'display_title',
331
+ 'collection_id', 'activity_id', 'activity_name', 'source_story_id', 'story_id', 'story_code',
332
+ 'story_name', 'part_type', 'part_label', 'char_id', 'character_name', 'path', 'text_sha256',
333
+ 'line_count', 'sequence_index', 'sequence_source', 'sequence_confidence',
334
+ 'previous_document_id', 'next_document_id', 'source_ref_prefix', 'entity_id',
335
+ ]
336
+
337
+ function toDocumentSummary(documentRecord) {
338
+ const summary = { document_uid: documentUid(documentRecord.document_id),
339
+ game: documentGame(documentRecord) }
340
+ // 与浏览器 executor.summary() 一致:固定摘要字段缺失时输出空字符串,
341
+ // 不能把 undefined 带过 Harness 的 lossless-JSON 工具边界。
342
+ for (const field of SUMMARY_FIELDS) summary[field] = documentRecord[field] ?? ''
343
+ return summary
344
+ }
345
+
346
+ /** 由完整 record 构建文档摘要:实体文档的 entity_id 位于 record.entity 而非 document 上。 */
347
+ function recordSummary(record) {
348
+ const summary = toDocumentSummary(record.document)
349
+ if (record.document?.document_type === 'entity' && record.entity?.entity_id) {
350
+ summary.entity_id = String(record.entity.entity_id)
351
+ }
352
+ return summary
353
+ }
354
+
355
+ /** ---- 执行 ---- */
356
+
357
+ /**
358
+ * 执行 corpus_read。
359
+ * @param {import('./store.js').CorpusStore} store
360
+ * @param {object} rawArgs 模型原始参数
361
+ * @param {{ signal?: AbortSignal }} runtime
362
+ * @returns {Promise<object>} 契约响应(ok / error)
363
+ */
364
+ export async function executeRead(store, rawArgs, runtime) {
365
+ const startedAt = Date.now()
366
+ let snapshot = null
367
+ try {
368
+ await store.ready()
369
+ snapshot = corpusVersionSnapshot(store)
370
+ const { normalized, refLine } = normalizeReadRequest(rawArgs)
371
+ if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted before execution')
372
+
373
+ if (normalized.expected_data_version !== undefined && normalized.expected_data_version !== store.dataVersion) {
374
+ throw new ContractError('PACKAGE_VERSION_MISMATCH',
375
+ `expected data_version ${normalized.expected_data_version} but active release is ${store.dataVersion}`,
376
+ { retryable: true })
377
+ }
378
+
379
+ // 合集通读:枚举活动/任务的全部官方剧情,按顺序跨文档读取与分页。
380
+ if (normalized.selection.mode === 'activity' || normalized.selection.mode === 'collection') {
381
+ const response = await executeStoryStreamRead(store, normalized, { runtime, startedAt })
382
+ assertCorpusVersion(store, snapshot)
383
+ return response
384
+ }
385
+
386
+ // 定位文档
387
+ let found
388
+ if (normalized.locator.document_id !== undefined) {
389
+ found = await store.getDocument(normalized.locator.document_id)
390
+ if (found === null) throw new ContractError('DOCUMENT_NOT_FOUND', `document not found: ${normalized.locator.document_id}`)
391
+ } else if (normalized.locator.document_uid !== undefined) {
392
+ found = await store.getDocumentByUid(normalized.locator.document_uid)
393
+ if (found === null) {
394
+ throw new ContractError('DOCUMENT_NOT_FOUND',
395
+ `本地资料包中找不到 document_uid=${normalized.locator.document_uid}`)
396
+ }
397
+ } else if (normalized.locator.display_title !== undefined) {
398
+ try {
399
+ found = await store.getDocumentByTitle(normalized.locator.display_title)
400
+ } catch (error) {
401
+ if (error?.code === 'DOCUMENT_AMBIGUOUS') {
402
+ throw new ContractError('DOCUMENT_AMBIGUOUS', error.message)
403
+ }
404
+ throw error
405
+ }
406
+ if (found === null) {
407
+ throw new ContractError('DOCUMENT_NOT_FOUND', '本地资料包中找不到该完整标题对应的文档')
408
+ }
409
+ } else {
410
+ const sourceRef = normalized.locator.source_ref
411
+ const prefix = sourceRef.slice(0, sourceRef.lastIndexOf(':L'))
412
+ const documentId = store.getDocumentIdByPrefix(prefix)
413
+ if (documentId === null) throw new ContractError('DOCUMENT_NOT_FOUND', `unknown source_ref prefix: ${prefix}`)
414
+ found = await store.getDocument(documentId)
415
+ if (found === null) throw new ContractError('DOCUMENT_NOT_FOUND', `document not found: ${documentId}`)
416
+ }
417
+ assertCorpusVersion(store, snapshot)
418
+ // GameData 把 [uc]info/ 一行式简介排在 obt/ 对话正文之前;document 模式
419
+ // 命中简介时优先换成可读全文(与浏览器执行器 readableStoryRecord 一致)。
420
+ let record = found.record
421
+ if (normalized.selection.mode === 'document') {
422
+ const sourceStoryId = String(record.document.source_story_id || '')
423
+ if (record.document.document_kind === 'synopsis' && sourceStoryId.startsWith('[uc]info/')) {
424
+ const fullStory = await store.getDocumentBySourceStoryId(sourceStoryId.slice('[uc]info/'.length))
425
+ if (fullStory?.record?.document?.document_kind === 'story') record = fullStory.record
426
+ }
427
+ }
428
+
429
+ const packId = found.packId
430
+ const packManifest = store.packs.get(packId)
431
+ const document = record.document
432
+ const documentId = document.document_id
433
+ const lineCount = document.line_count
434
+
435
+ // 行完整性(全文):行文本 \n 连接后 sha256 与 local_integrity 比对
436
+ const actualIntegrity = computeLinesIntegrity(record.lines)
437
+ const expectedIntegrity = record.local_integrity?.sha256
438
+ const integrityVerified = expectedIntegrity === actualIntegrity
439
+ if (!integrityVerified) {
440
+ throw new ContractError('INDEX_CORRUPT', `integrity mismatch for ${documentId}: expected ${expectedIntegrity}, got ${actualIntegrity}`)
441
+ }
442
+
443
+ if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted after integrity check')
444
+
445
+ // 解析选区 → [startLine, endLine]
446
+ const { selection } = normalized
447
+ let startLine
448
+ let endLine
449
+ let cursorNextLine = null
450
+ if (selection.mode === 'around') {
451
+ const center = normalized.locator.source_ref !== undefined ? refLine : selection.center_line
452
+ if (!center || center > lineCount) {
453
+ throw new ContractError('LINE_RANGE_INVALID', `center line ${center ?? '(missing)'} is missing or beyond document length ${lineCount}`)
454
+ }
455
+ startLine = Math.max(1, center - selection.before_lines)
456
+ endLine = Math.min(lineCount, center + selection.after_lines)
457
+ } else if (selection.mode === 'range') {
458
+ if (selection.start_line > lineCount) {
459
+ throw new ContractError('LINE_RANGE_INVALID', `start_line ${selection.start_line} is beyond document length ${lineCount}`)
460
+ }
461
+ startLine = selection.start_line
462
+ endLine = Math.min(lineCount, selection.end_line)
463
+ } else if (selection.mode === 'section') {
464
+ if (document.document_type !== 'knowledge' || document.document_kind !== 'wiki') {
465
+ throw new ContractError('INVALID_REQUEST', 'section mode can only read Wiki documents')
466
+ }
467
+ const ranges = wikiSectionRanges(record, [selection.section])
468
+ if (!ranges.length) {
469
+ throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”`)
470
+ }
471
+ if (ranges.length > 1) {
472
+ throw new ContractError('DOCUMENT_AMBIGUOUS',
473
+ `Wiki 文档“${document.display_title}”包含多个“${selection.section}”字段;请用 corpus_search 获取具体行范围`)
474
+ }
475
+ startLine = ranges[0].start_line
476
+ endLine = ranges[0].end_line
477
+ } else {
478
+ let nextLine = selection.start_line
479
+ if (selection.cursor !== null) {
480
+ if (typeof selection.cursor !== 'string' || selection.cursor.length < 1 || selection.cursor.length > 4096) {
481
+ throw new ContractError('CURSOR_INVALID', 'cursor must be a string of 1..4096 chars')
482
+ }
483
+ const payload = decodeCursor(selection.cursor)
484
+ if (payload.v !== 1 || payload.document_id !== documentId || payload.data_version !== store.dataVersion) {
485
+ throw new ContractError('CURSOR_VERSION_MISMATCH', 'cursor is bound to a different document or data_version')
486
+ }
487
+ nextLine = payload.next_line
488
+ if (nextLine < 1 || nextLine > lineCount) {
489
+ throw new ContractError('CURSOR_INVALID', `cursor next_line ${nextLine} is out of range`)
490
+ }
491
+ }
492
+ startLine = nextLine
493
+ endLine = lineCount
494
+ }
495
+
496
+ // 应用 limits 截取
497
+ const { max_lines: maxLines, max_chars: maxChars } = normalized.limits
498
+ const spanLines = endLine - startLine + 1
499
+ let truncated = false
500
+ let truncationReason = null
501
+ let selectedEnd = endLine
502
+ if (spanLines > maxLines) {
503
+ selectedEnd = startLine + maxLines - 1
504
+ truncated = true
505
+ truncationReason = 'max_lines'
506
+ }
507
+
508
+ const selectedLines = []
509
+ let charCount = 0
510
+ for (let lineNumber = startLine; lineNumber <= selectedEnd; lineNumber += 1) {
511
+ const line = record.lines[lineNumber - 1]
512
+ if (charCount + line.text.length > maxChars) {
513
+ truncated = true
514
+ truncationReason = truncationReason ?? 'max_chars'
515
+ break
516
+ }
517
+ charCount += line.text.length
518
+ selectedLines.push(line)
519
+ }
520
+ // 首行即超过 max_chars 时不能返回“0 行 + 原地游标”的 ok 响应——document
521
+ // 模式的 next_cursor 不会前进,模型分页会死循环。与 activity 模式一致
522
+ // 上报预算不足,让调用方提高 max_chars 后重试。
523
+ if (!selectedLines.length && truncated) {
524
+ throw new ContractError('BUDGET_EXCEEDED',
525
+ `读取范围内首行长度已超过 max_chars=${maxChars};请提高 max_chars 后重试`)
526
+ }
527
+ const returned = selectedLines.length
528
+ const hasMore = truncated || selectedEnd < endLine
529
+ let nextCursor = null
530
+ if (selection.mode === 'document' && hasMore) {
531
+ cursorNextLine = startLine + returned
532
+ nextCursor = encodeCursor({
533
+ v: 1,
534
+ document_id: documentId,
535
+ data_version: store.dataVersion,
536
+ next_line: cursorNextLine,
537
+ max_lines: maxLines,
538
+ max_chars: maxChars,
539
+ })
540
+ }
541
+
542
+ // 内容投影
543
+ let content
544
+ if (normalized.format === 'plain_text') {
545
+ content = { format: 'plain_text', text: selectedLines.map((line) => line.text).join('\n') }
546
+ } else {
547
+ content = {
548
+ format: 'lines',
549
+ lines: selectedLines.map((line) => ({
550
+ line_number: line.line_number,
551
+ ...(line.source_line_id ? { source_line_id: line.source_line_id } : {}),
552
+ line_type: line.line_type ?? '',
553
+ speaker_raw: line.speaker_raw ?? '',
554
+ ...(line.speaker_id ? { speaker_id: line.speaker_id } : {}),
555
+ text: line.text ?? '',
556
+ ...(line.audio ? { audio: line.audio } : {}),
557
+ ...(line.hint ? { hint: line.hint } : {}),
558
+ source_ref: `${document.source_ref_prefix}:L${line.line_number}`,
559
+ })),
560
+ }
561
+ }
562
+
563
+ // 相邻文档摘要
564
+ let adjacentDocuments
565
+ if (normalized.include_adjacent_documents) {
566
+ const previousId = document.previous_document_id || null
567
+ const nextId = document.next_document_id || null
568
+ const previous = previousId ? await store.getDocument(previousId) : null
569
+ const next = nextId ? await store.getDocument(nextId) : null
570
+ adjacentDocuments = {
571
+ previous: previous ? toDocumentSummary(previous.record.document) : null,
572
+ next: next ? toDocumentSummary(next.record.document) : null,
573
+ }
574
+ }
575
+
576
+ assertCorpusVersion(store, snapshot)
577
+ return {
578
+ contract_version: CONTRACT_VERSION,
579
+ status: 'ok',
580
+ request_id: normalized.request_id,
581
+ data_version: store.dataVersion,
582
+ package_schema_version: packManifest.package_schema_version ?? 1,
583
+ index_schema_version: packManifest.index_schema_version ?? 1,
584
+ normalized_request: normalized,
585
+ document: recordSummary(record),
586
+ selection: {
587
+ mode: selection.mode,
588
+ ...(selection.mode === 'section' ? { wiki_section: selection.section } : {}),
589
+ line_start: returned > 0 ? selectedLines[0].line_number : startLine,
590
+ line_end: returned > 0 ? selectedLines[returned - 1].line_number : startLine - 1,
591
+ line_count: returned,
592
+ character_count: charCount,
593
+ truncated,
594
+ ...(truncationReason ? { truncation_reason: truncationReason } : {}),
595
+ },
596
+ content,
597
+ page: {
598
+ limit: maxLines,
599
+ returned,
600
+ has_more: hasMore,
601
+ next_cursor: nextCursor,
602
+ total: selection.mode === 'document' ? lineCount : spanLines,
603
+ total_relation: 'eq',
604
+ },
605
+ ...(adjacentDocuments ? { adjacent_documents: adjacentDocuments } : {}),
606
+ integrity: {
607
+ verified: integrityVerified,
608
+ expected_text_sha256: expectedIntegrity,
609
+ actual_text_sha256: actualIntegrity,
610
+ },
611
+ stats: {
612
+ elapsed_ms: Date.now() - startedAt,
613
+ scanned_documents: 1,
614
+ scanned_lines: lineCount,
615
+ returned_chars: charCount,
616
+ estimated_input_tokens: estimateTokens(charCount),
617
+ truncated,
618
+ },
619
+ warnings: [],
620
+ }
621
+ } catch (error) {
622
+ try { if (snapshot) assertCorpusVersion(store, snapshot) } catch (changed) { error = changed }
623
+ if (error instanceof ContractError || error?.code === 'PACKAGE_VERSION_MISMATCH') {
624
+ return {
625
+ contract_version: CONTRACT_VERSION,
626
+ status: 'error',
627
+ request_id: typeof rawArgs?.request_id === 'string' ? rawArgs.request_id : `req-${randomBytes(8).toString('hex')}`,
628
+ data_version: store.dataVersion ?? null,
629
+ error: { code: error.code, message: error.message, retryable: error.retryable },
630
+ }
631
+ }
632
+ if (error?.code === 'ENOENT' || /current\.json|release-manifest/.test(error?.message ?? '')) {
633
+ return {
634
+ contract_version: CONTRACT_VERSION,
635
+ status: 'error',
636
+ request_id: `req-${randomBytes(8).toString('hex')}`,
637
+ data_version: null,
638
+ error: { code: 'PACKAGE_NOT_INSTALLED',
639
+ message: '本地资料包未安装或不完整,请在 PRTS 语料设置中重新下载或激活版本',
640
+ retryable: true },
641
+ }
642
+ }
643
+ throw error // 基础设施故障交给宿主 isError
644
+ }
645
+ }
646
+
647
+ /** 合集行保留可检索自然标题;同名碎片由稳定 document_uid 消歧。 */
648
+ function streamDocumentLabels(docs) {
649
+ return docs.map((item) => naturalDocumentTitle(item.document))
650
+ }
651
+
652
+ /** activity/collection 共用的跨文档连续读取。 */
653
+ async function executeStoryStreamRead(store, normalized, { runtime, startedAt }) {
654
+ const { selection } = normalized
655
+ const isActivity = selection.mode === 'activity'
656
+ let docs
657
+ const anchorDocumentId = normalized.locator.document_uid
658
+ ? store.getDocumentIdByUid(normalized.locator.document_uid) || '' : ''
659
+ if (normalized.locator.document_uid && !anchorDocumentId) {
660
+ throw new ContractError('DOCUMENT_NOT_FOUND',
661
+ `本地资料包中找不到 document_uid=${normalized.locator.document_uid}`)
662
+ }
663
+ try {
664
+ docs = isActivity
665
+ ? store.activityStoryDocuments({ activityId: normalized.locator.activity_id,
666
+ activityName: normalized.locator.activity_name, anchorDocumentId })
667
+ : store.endfieldCollectionDocuments({ collectionName: normalized.locator.collection_name,
668
+ contentTypes: selection.content_types ?? [], anchorDocumentId })
669
+ } catch (error) {
670
+ if (error?.code === 'DOCUMENT_AMBIGUOUS') {
671
+ throw new ContractError('DOCUMENT_AMBIGUOUS', error.message)
672
+ }
673
+ throw error
674
+ }
675
+ const targetName = String(isActivity
676
+ ? normalized.locator.activity_name || normalized.locator.activity_id || ''
677
+ : normalized.locator.collection_name || '')
678
+ if (!docs.length) {
679
+ throw new ContractError('DOCUMENT_NOT_FOUND',
680
+ `本地资料包中找不到${isActivity ? '活动' : '终末地集合'}“${targetName}”的剧情原文`)
681
+ }
682
+
683
+ const totalLines = docs.reduce((total, item) => total + Number(item.document.line_count || 0), 0)
684
+ const collectionId = String(docs[0].document.collection_id || docs[0].document.activity_id || '')
685
+ const streamKey = JSON.stringify([selection.mode, collectionId, selection.content_types ?? []])
686
+ let docIndex = 0
687
+ let startLine = 1
688
+ let startPosition = selection.start_position
689
+ if (selection.cursor !== null) {
690
+ if (typeof selection.cursor !== 'string' || selection.cursor.length < 1 || selection.cursor.length > 4096) {
691
+ throw new ContractError('CURSOR_INVALID', 'cursor must be a string of 1..4096 chars')
692
+ }
693
+ const payload = decodeCursor(selection.cursor)
694
+ const legacyActivity = payload.v === 2 && isActivity
695
+ if ((!legacyActivity && (payload.v !== 3 || payload.stream_key !== streamKey))
696
+ || payload.data_version !== store.dataVersion) {
697
+ throw new ContractError('CURSOR_VERSION_MISMATCH',
698
+ 'cursor is bound to a different collection, filter, or data_version')
699
+ }
700
+ if (!Number.isInteger(payload.doc_index) || payload.doc_index < 0 || payload.doc_index >= docs.length
701
+ || !Number.isInteger(payload.next_line) || payload.next_line < 1
702
+ || payload.next_line > Number(docs[payload.doc_index].document.line_count || 0)) {
703
+ throw new ContractError('CURSOR_INVALID', 'story-stream cursor payload is invalid')
704
+ }
705
+ docIndex = payload.doc_index
706
+ startLine = payload.next_line
707
+ startPosition = docs.slice(0, docIndex).reduce(
708
+ (total, item) => total + Number(item.document.line_count || 0), 0) + startLine
709
+ } else {
710
+ if (startPosition > totalLines) {
711
+ throw new ContractError('LINE_RANGE_INVALID',
712
+ `position ${startPosition} is beyond collection length ${totalLines}`)
713
+ }
714
+ let remaining = startPosition
715
+ for (let index = 0; index < docs.length; index += 1) {
716
+ const lineCount = Number(docs[index].document.line_count || 0)
717
+ if (remaining <= lineCount) {
718
+ docIndex = index
719
+ startLine = remaining
720
+ break
721
+ }
722
+ remaining -= lineCount
723
+ }
724
+ }
725
+
726
+ const labels = streamDocumentLabels(docs)
727
+ const { max_lines: maxLines, max_chars: maxChars } = normalized.limits
728
+ const selected = []
729
+ let charCount = 0
730
+ let currentPosition = startPosition
731
+ let nextLocation = null
732
+ let truncationReason = null
733
+ streamDocuments:
734
+ for (let index = docIndex; index < docs.length; index += 1) {
735
+ if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted during story-stream read')
736
+ const found = await store.getDocument(docs[index].document.document_id)
737
+ if (!found) continue
738
+ const record = found.record
739
+ const document = record.document
740
+ const actualIntegrity = computeLinesIntegrity(record.lines)
741
+ if (record.local_integrity?.sha256 !== actualIntegrity) {
742
+ throw new ContractError('INDEX_CORRUPT',
743
+ `integrity mismatch for ${document.document_id}: expected ${record.local_integrity?.sha256}, got ${actualIntegrity}`)
744
+ }
745
+ const start = index === docIndex ? startLine : 1
746
+ for (let number = start; number <= record.lines.length; number += 1) {
747
+ const line = record.lines[number - 1]
748
+ if (selected.length >= maxLines) {
749
+ truncationReason = 'max_lines'
750
+ nextLocation = { doc_index: index, next_line: number }
751
+ break streamDocuments
752
+ }
753
+ if (charCount + line.text.length > maxChars) {
754
+ truncationReason = 'max_chars'
755
+ nextLocation = { doc_index: index, next_line: number }
756
+ break streamDocuments
757
+ }
758
+ charCount += line.text.length
759
+ selected.push({ line, document, packId: found.packId,
760
+ documentTitle: labels[index], streamPosition: currentPosition })
761
+ currentPosition += 1
762
+ nextLocation = number < record.lines.length
763
+ ? { doc_index: index, next_line: number + 1 }
764
+ : index + 1 < docs.length ? { doc_index: index + 1, next_line: 1 } : null
765
+ }
766
+ }
767
+ if (!selected.length) {
768
+ throw new ContractError('BUDGET_EXCEEDED',
769
+ `读取范围内首行长度已超过 max_chars=${maxChars};请提高 max_chars 后重试`)
770
+ }
771
+
772
+ const nextStreamPosition = nextLocation ? currentPosition : null
773
+ const nextCursor = nextLocation ? encodeCursor({ v: 3, data_version: store.dataVersion,
774
+ stream_key: streamKey, ...nextLocation }) : null
775
+ const first = selected[0]
776
+ const firstDocSummary = toDocumentSummary(first.document)
777
+ const streamSources = new Map()
778
+ for (const item of selected) {
779
+ const id = String(item.document.document_id || '')
780
+ const current = streamSources.get(id) || {
781
+ document_id: id, document_uid: documentUid(id), title: item.documentTitle,
782
+ line_start: item.line.line_number, line_end: item.line.line_number,
783
+ }
784
+ current.line_start = Math.min(current.line_start, item.line.line_number)
785
+ current.line_end = Math.max(current.line_end, item.line.line_number)
786
+ streamSources.set(id, current)
787
+ }
788
+ const streamName = String(isActivity
789
+ ? first.document.activity_name || targetName : first.document.collection_name || targetName)
790
+ const stream = {
791
+ mode: selection.mode,
792
+ game: isActivity ? 'arknights' : 'endfield',
793
+ name: streamName,
794
+ document_count: docs.length,
795
+ total_lines: totalLines,
796
+ position_start: startPosition,
797
+ position_end: currentPosition - 1,
798
+ next_position: nextStreamPosition,
799
+ order_kind: isActivity ? 'source_sequence' : 'derived_content_grouping',
800
+ order_confidence: isActivity ? 'source_backed' : 'derived',
801
+ order_note: isActivity
802
+ ? '按明日方舟活动资料的来源序列连续读取。'
803
+ : '终末地碎片缺少可证明的全局时间线;当前仅按内容类型分组,并在组内按自然编号排序,不代表游戏内先后。',
804
+ anchor_document_uid: documentUid(first.document.document_id),
805
+ sources: [...streamSources.values()],
806
+ ...(selection.content_types?.length ? { content_types: selection.content_types } : {}),
807
+ }
808
+ const activity = isActivity ? {
809
+ activity_id: String(firstDocSummary.activity_id || firstDocSummary.collection_id || ''),
810
+ activity_name: streamName,
811
+ story_count: docs.length,
812
+ total_lines: totalLines,
813
+ } : null
814
+
815
+ const content = normalized.format === 'plain_text'
816
+ ? { format: 'plain_text', text: selected.map((item) => item.line.text).join('\n') }
817
+ : { format: 'lines', lines: selected.map((item) => ({
818
+ line_number: item.line.line_number,
819
+ ...(item.line.source_line_id ? { source_line_id: item.line.source_line_id } : {}),
820
+ line_type: item.line.line_type ?? '',
821
+ speaker_raw: item.line.speaker_raw ?? '',
822
+ ...(item.line.speaker_id ? { speaker_id: item.line.speaker_id } : {}),
823
+ text: item.line.text ?? '',
824
+ ...(item.line.audio ? { audio: item.line.audio } : {}),
825
+ ...(item.line.hint ? { hint: item.line.hint } : {}),
826
+ source_ref: `${item.document.source_ref_prefix}:L${item.line.line_number}`,
827
+ document_id: item.document.document_id,
828
+ document_uid: documentUid(item.document.document_id),
829
+ document_title: item.documentTitle,
830
+ stream_position: item.streamPosition,
831
+ })) }
832
+
833
+ const firstPackManifest = store.packs.get(first.packId)
834
+ const returnedIntegrity = computeLinesIntegrity(selected.map((item) => item.line))
835
+ return {
836
+ contract_version: CONTRACT_VERSION,
837
+ status: 'ok',
838
+ request_id: normalized.request_id,
839
+ data_version: store.dataVersion,
840
+ package_schema_version: firstPackManifest?.package_schema_version ?? 1,
841
+ index_schema_version: firstPackManifest?.index_schema_version ?? 1,
842
+ normalized_request: normalized,
843
+ document: firstDocSummary,
844
+ selection: {
845
+ mode: selection.mode,
846
+ line_start: startPosition,
847
+ line_end: currentPosition - 1,
848
+ line_count: selected.length,
849
+ character_count: charCount,
850
+ truncated: Boolean(nextLocation),
851
+ ...(truncationReason ? { truncation_reason: truncationReason } : {}),
852
+ },
853
+ content,
854
+ page: { limit: maxLines, returned: selected.length, has_more: Boolean(nextLocation),
855
+ next_cursor: nextCursor, total: totalLines, total_relation: 'eq' },
856
+ stream,
857
+ ...(activity ? { activity } : {}),
858
+ integrity: { verified: true, expected_text_sha256: returnedIntegrity,
859
+ actual_text_sha256: returnedIntegrity },
860
+ stats: {
861
+ elapsed_ms: Date.now() - startedAt,
862
+ scanned_documents: docs.length,
863
+ scanned_lines: selected.length,
864
+ returned_chars: charCount,
865
+ estimated_input_tokens: estimateTokens(selected.map((item) => item.line.text).join('\n')),
866
+ truncated: Boolean(nextLocation),
867
+ },
868
+ warnings: [],
869
+ }
870
+ }
871
+
872
+ /** ---- 模型可见文本渲染 ---- */
873
+
874
+ /**
875
+ * 模型可见的单行渲染格式(renderRead 使用)。evidence-state.js 的读取去重
876
+ * 依赖在模型 surface 中检索同一格式的行标记,两处必须共用本函数,否则
877
+ * 去重判定会与模型实际可见文本脱节。
878
+ */
879
+ export function readableRenderedLine(line, marker = 'L') {
880
+ const speaker = String(line.speaker_raw || '').trim()
881
+ let text = String(line.text || '')
882
+ if (speaker) {
883
+ const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
884
+ text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
885
+ }
886
+ return `${marker}${line.line_number} ${line.line_type || ''} ${speaker ? `${speaker}: ` : ''}${text}`
887
+ .replace(/\s+$/u, '')
888
+ }
889
+
890
+ function publicLine(line) {
891
+ const speaker = String(line.speaker_raw || '').trim()
892
+ let text = String(line.text || '')
893
+ if (speaker) {
894
+ const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
895
+ text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
896
+ }
897
+ return { line: line.line_number,
898
+ ...(line.source_line_id ? { source_line_id: line.source_line_id } : {}),
899
+ line_type: line.line_type || '', speaker,
900
+ ...(line.speaker_id ? { speaker_id: line.speaker_id } : {}),
901
+ text,
902
+ ...(line.document_title ? { document_title: line.document_title } : {}),
903
+ ...(line.document_uid ? { document_uid: line.document_uid } : {}),
904
+ ...(Number.isInteger(line.stream_position) ? { stream_position: line.stream_position } : {}),
905
+ ...(line.document_title ? { citation: `《${line.document_title}》${line.document_uid
906
+ ? `(document_uid=${line.document_uid})` : ''}第 ${line.line_number} 行` } : {}),
907
+ ...(line.audio ? { audio: line.audio } : {}),
908
+ ...(line.hint ? { hint: line.hint } : {}) }
909
+ }
910
+
911
+ /** 执行层富响应 → 模型/程序共用的自然定位 public result。 */
912
+ export function projectReadPublic(value) {
913
+ if (value?.status === 'error') return value
914
+ if (value?.primary?.kind === 'official_story_collection') return value
915
+ if (value?.primary) {
916
+ const hasMore = Boolean(value.page?.has_more)
917
+ const nextLine = Number(value.primary.selection?.line_end) + 1
918
+ const existing = value.page?.continuation
919
+ const shortLocator = existing && typeof existing === 'object'
920
+ ? existing
921
+ : value.primary.stage_code && value.primary.story_part
922
+ ? { stage_code: value.primary.stage_code, story_part: value.primary.story_part }
923
+ : { title: value.primary.title }
924
+ const dataVersion = String(existing?.data_version ?? value.presentation?.data_version
925
+ ?? value.data_version ?? '')
926
+ return { ...value, page: {
927
+ returned_lines: Number(value.page?.returned_lines || value.primary.lines?.length || 0),
928
+ has_more: hasMore,
929
+ continuation: hasMore && Number.isInteger(nextLine) && nextLine > 0
930
+ ? { ...shortLocator, mode: 'document', line: nextLine,
931
+ ...(dataVersion ? { data_version: dataVersion } : {}) }
932
+ : null,
933
+ } }
934
+ }
935
+ if (value.stream) {
936
+ const lines = value.content?.format === 'lines' ? (value.content.lines || []).map(publicLine) : []
937
+ const stream = value.stream
938
+ const isActivity = stream.mode === 'activity'
939
+ const requestedLocator = value.normalized_request?.locator || {}
940
+ const continuationLocator = requestedLocator.document_uid
941
+ ? { document_uid: requestedLocator.document_uid }
942
+ : { [isActivity ? 'activity_name' : 'collection_name']: stream.name }
943
+ const continuation = value.page?.has_more && Number.isInteger(stream.next_position)
944
+ ? { ...continuationLocator,
945
+ mode: stream.mode, position: stream.next_position,
946
+ ...(stream.content_types?.length ? { content_types: stream.content_types } : {}),
947
+ data_version: String(value.data_version || '') }
948
+ : null
949
+ const title = isActivity
950
+ ? `${stream.name} / 活动剧情连续阅读` : `${stream.name} / 终末地任务连续阅读`
951
+ return {
952
+ primary: { game: stream.game, title, kind: 'official_story_collection',
953
+ selection: { mode: stream.mode, line_start: stream.position_start,
954
+ line_end: stream.position_end, truncated: Boolean(value.selection?.truncated) },
955
+ lines,
956
+ ordering: stream.order_kind,
957
+ ordering_note: stream.order_note,
958
+ ...(value.content?.format === 'plain_text' ? { text: value.content.text || '' } : {}),
959
+ citation: '连续阅读结果按每行的 document_title 与 line 引用' },
960
+ page: { returned_lines: Number(value.page?.returned || lines.length),
961
+ has_more: Boolean(value.page?.has_more), continuation },
962
+ }
963
+ }
964
+ const title = naturalDocumentTitle(value.document || {})
965
+ const stageCode = publicStoryStageCode(value.document?.story_code)
966
+ const storyPart = publicStoryPart(value.document?.part_type)
967
+ const hasStoryStage = documentGame(value.document || {}) === 'arknights' && stageCode
968
+ && Boolean(storyPart)
969
+ const recordSegment = operatorRecordSegment(value.document || {})
970
+ const hasOperatorRecord = Boolean(recordSegment) && value.document?.document_kind === 'story'
971
+ && value.document?.part_type === 'body'
972
+ const material = publicCharacterMaterial(value.document || {})
973
+ const continuationLocator = hasStoryStage
974
+ ? { stage_code: stageCode, story_part: storyPart }
975
+ : hasOperatorRecord
976
+ ? { character_name: value.document.character_name,
977
+ record_name: value.document.story_name, segment: recordSegment }
978
+ : material
979
+ ? { character_name: value.document.character_name, material,
980
+ game: documentGame(value.document || {}) }
981
+ : { title }
982
+ const lines = value.content?.format === 'lines' ? (value.content.lines || []).map(publicLine) : []
983
+ const kind = value.document?.document_type === 'story' ? 'official_story'
984
+ : value.document?.document_type === 'knowledge' && value.document?.document_kind === 'wiki'
985
+ ? 'wiki_curated' : 'local_document'
986
+ return {
987
+ primary: { game: documentGame(value.document || {}), title,
988
+ ...(hasStoryStage ? { stage_code: stageCode, story_part: storyPart } : {}),
989
+ ...(hasOperatorRecord ? { character_name: value.document.character_name,
990
+ record_name: value.document.story_name, segment: recordSegment } : {}),
991
+ ...(material ? { character_name: value.document.character_name, material } : {}),
992
+ kind,
993
+ selection: { mode: value.selection?.mode, line_start: value.selection?.line_start,
994
+ line_end: value.selection?.line_end,
995
+ ...(value.selection?.wiki_section ? { section: value.selection.wiki_section } : {}),
996
+ truncated: Boolean(value.selection?.truncated) },
997
+ lines,
998
+ ...(value.content?.format === 'plain_text' ? { text: value.content.text || '' } : {}),
999
+ citation: value.selection?.wiki_section ? `《${title}》Wiki·${value.selection.wiki_section}`
1000
+ : `《${title}》第 ${value.selection?.line_start === value.selection?.line_end
1001
+ ? value.selection?.line_start : `${value.selection?.line_start}-${value.selection?.line_end}`} 行` },
1002
+ ...(value.coverage ? { coverage: value.coverage } : {}),
1003
+ ...(value.guidance ? { guidance: value.guidance } : {}),
1004
+ page: { returned_lines: Number(value.page?.returned || lines.length),
1005
+ has_more: Boolean(value.page?.has_more),
1006
+ continuation: value.page?.has_more
1007
+ ? { ...continuationLocator, mode: 'document', line: Number(value.selection?.line_end) + 1,
1008
+ data_version: String(value.data_version || '') }
1009
+ : null },
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * 将契约响应渲染为模型可见文本(output.render 用)。
1015
+ * @param {object} _args
1016
+ * @param {object} value executeRead 的返回值
1017
+ */
1018
+ export function renderRead(_args, value) {
1019
+ if (value?.status === 'error') {
1020
+ return [{
1021
+ type: 'text',
1022
+ text: `[corpus_read:error] code=${value.error.code} retryable=${value.error.retryable}\n${value.error.message}`,
1023
+ }]
1024
+ }
1025
+ const projected = projectReadPublic(value)
1026
+ const parts = []
1027
+ if (projected.primary) {
1028
+ const primary = projected.primary
1029
+ parts.push(`# ${primary.title}`)
1030
+ if (primary.kind === 'official_story_collection') {
1031
+ parts.push(`连续位置:第 ${primary.selection.line_start}-${primary.selection.line_end} 行`)
1032
+ if (primary.ordering_note) parts.push(`顺序说明:${primary.ordering_note}`)
1033
+ let activeTitle = ''
1034
+ let activeUid = ''
1035
+ let groupStart = null
1036
+ let groupEnd = null
1037
+ const finishGroup = () => {
1038
+ if (activeTitle && groupStart != null) {
1039
+ parts.push(`引用:《${activeTitle}》${activeUid ? `(document_uid=${activeUid})` : ''}第 ${groupStart === groupEnd ? groupStart : `${groupStart}-${groupEnd}`} 行`)
1040
+ }
1041
+ }
1042
+ for (const line of primary.lines || []) {
1043
+ if (line.document_title !== activeTitle || line.document_uid !== activeUid) {
1044
+ finishGroup()
1045
+ activeTitle = line.document_title || primary.title
1046
+ activeUid = line.document_uid || ''
1047
+ groupStart = line.line
1048
+ groupEnd = line.line
1049
+ parts.push(`## ${activeTitle}${activeUid ? `(document_uid=${activeUid})` : ''}`)
1050
+ } else {
1051
+ groupEnd = line.line
1052
+ }
1053
+ parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1054
+ speaker_raw: line.speaker, text: line.text }))
1055
+ }
1056
+ finishGroup()
1057
+ if (primary.text) parts.push(primary.text)
1058
+ if (projected.page.has_more && projected.page.continuation) {
1059
+ parts.push(`继续阅读:corpus_read(${JSON.stringify(projected.page.continuation)})`)
1060
+ }
1061
+ return [{ type: 'text', text: parts.join('\n') }]
1062
+ }
1063
+ if (primary.kind === 'wiki_curated') {
1064
+ parts.push('引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。')
1065
+ }
1066
+ if (primary.selection?.section) parts.push(`字段:${primary.selection.section}`)
1067
+ else parts.push(`范围:第 ${primary.selection.line_start}-${primary.selection.line_end} 行`)
1068
+ if (projected.coverage?.reused_ranges?.length) {
1069
+ const ranges = projected.coverage.reused_ranges.map((range) =>
1070
+ range.line_start === range.line_end ? String(range.line_start)
1071
+ : `${range.line_start}-${range.line_end}`).join('、')
1072
+ parts.push(`复用上文:第 ${ranges} 行已在上方可见工具结果中;本次只展示尚未覆盖的行。`)
1073
+ }
1074
+ for (const line of primary.lines || []) {
1075
+ parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1076
+ speaker_raw: line.speaker, text: line.text }))
1077
+ }
1078
+ if (primary.text) parts.push(primary.text)
1079
+ parts.push(`引用:${primary.citation}`)
1080
+ if (projected.page.has_more && projected.page.continuation) {
1081
+ const next = projected.page.continuation
1082
+ parts.push(`继续阅读《${primary.title}》,从第 ${next.line} 行开始。`)
1083
+ if (next.stage_code && next.story_part) {
1084
+ parts.push(`调用:corpus_read(${JSON.stringify({ stage_code: next.stage_code,
1085
+ story_part: next.story_part, mode: 'document', line: next.line,
1086
+ data_version: next.data_version })})`)
1087
+ } else if (next.record_name || next.material) {
1088
+ parts.push(`调用:corpus_read(${JSON.stringify(next)})`)
1089
+ } else {
1090
+ parts.push(`调用:corpus_read(${JSON.stringify({ title: String(next.title || ''),
1091
+ mode: 'document', line: next.line, data_version: next.data_version })})`)
1092
+ }
1093
+ }
1094
+ return [{ type: 'text', text: parts.join('\n') }]
1095
+ }
1096
+ return [{ type: 'text', text: '[corpus_read:error] 无法投影读取结果' }]
1097
+ }