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
@@ -0,0 +1,579 @@
1
+ /** Durable investigation portfolios, shared by Host UI and agent preset instances. */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { z } from 'zod'
4
+ import { inspectPortfolio, noteUserChange, reviewSummary } from './investigation-review.js'
5
+
6
+ const id = z.string().min(1).max(512)
7
+ const short = z.string().max(512)
8
+ const point = z.object({ x: z.number().finite().min(-20).max(20), y: z.number().finite().min(-12).max(12) })
9
+ const range = z.object({ start: z.number().int().positive(), end: z.number().int().positive() })
10
+ export const clueKinds = ['excerpt', 'finding', 'time', 'relation', 'question', 'contrast']
11
+ const sourceSchema = z.object({
12
+ id, sourceId: id, title: short, kind: z.string().max(80), origin: z.enum(['local', 'cloud', 'web']),
13
+ state: z.enum(['found', 'read']), agentRead: z.boolean(), excerpt: z.string().max(4000),
14
+ content: z.string().max(64000).optional(), contentTruncated: z.boolean().optional(),
15
+ documentId: z.string().max(2048).optional(), documentUid: z.string().max(512).optional(),
16
+ sourceRef: z.string().max(2048).optional(), dataVersion: z.string().max(128).optional(),
17
+ url: z.string().max(4096).optional(), lineStart: z.number().int().positive().optional(),
18
+ lineEnd: z.number().int().positive().optional(), ranges: z.array(range).max(256).default([]),
19
+ callId: short, updatedAt: z.string(), contentHash: z.string(),
20
+ })
21
+ const referenceSchema = z.object({ source_id: id, quote: z.string().max(3000).optional(),
22
+ line_start: z.number().int().positive().optional(), line_end: z.number().int().positive().optional() })
23
+ export const clueSchema = z.object({
24
+ id, kind: z.enum(clueKinds), title: z.string().min(1).max(120), summary: z.string().max(480), detail: z.string().max(12000),
25
+ interpretation: z.enum(['observation', 'inference', 'question']), status: z.enum(['active', 'unresolved', 'superseded', 'retracted']),
26
+ importance: z.enum(['key', 'supporting', 'background']), sources: z.array(referenceSchema).max(32),
27
+ contentRevision: z.number().int().nonnegative(), layoutRevision: z.number().int().nonnegative(),
28
+ authoredBy: z.enum(['agent', 'user']), editedBy: z.enum(['agent', 'user']),
29
+ createdAt: z.string(), updatedAt: z.string(), styleSeed: z.number().int(), variant: z.number().int().min(0).max(2),
30
+ position: point.optional(), scale: z.number().min(.5).max(2).optional(), rotation: z.number().min(-30).max(30).optional(),
31
+ userLayoutLocked: z.boolean(), mergedInto: id.optional(),
32
+ })
33
+ const relationSchema = z.object({ id, from: id, to: id, type: z.enum(['supports', 'contradicts', 'precedes', 'relates']), label: z.string().max(100) })
34
+ const reportSchema = z.object({ id, version: z.number().int().positive(), title: z.string().min(1).max(180),
35
+ summary: z.string().max(1000), markdown: z.string().min(1).max(120000), publishedAt: z.string(), runId: id,
36
+ basisKnowledgeRevision: z.number().int(), clues: z.array(clueSchema).max(128), sources: z.array(sourceSchema).max(1024) })
37
+ const inboxEntrySchema = z.object({ source_id: id, note: z.string().max(600),
38
+ status: z.enum(['pending', 'promoted', 'removed']), addedBy: z.enum(['agent', 'user']),
39
+ addedAt: z.string(), updatedAt: z.string(), removedBy: z.enum(['agent', 'user']).optional(), clueIds: z.array(id).max(128) })
40
+ const boardSchema = z.object({ id, title: z.string().min(1).max(120), objective: z.string().min(1).max(2000),
41
+ createdAt: z.string(), updatedAt: z.string(), knowledgeRevision: z.number().int().nonnegative(), layoutRevision: z.number().int().nonnegative(),
42
+ clues: z.array(clueSchema).max(512), relations: z.array(relationSchema).max(1024), reports: z.array(reportSchema).max(128),
43
+ evidenceInbox: z.array(inboxEntrySchema).max(512).default([]), inboxRevision: z.number().int().nonnegative().default(0),
44
+ openQuestions: z.array(z.string().max(500)).max(32), nextClue: z.number().int().positive(),
45
+ })
46
+ const runSchema = z.object({ id, boardId: id, turnId: short, reason: z.string().max(500), startedAt: z.string(),
47
+ status: z.enum(['running', 'completed', 'interrupted', 'error']), endedAt: z.string().optional() })
48
+ export const portfolioSchema = z.object({ schemaVersion: z.literal(1), sessionId: id, commitSeq: z.number().int().nonnegative(),
49
+ boards: z.array(boardSchema).max(128), runs: z.array(runSchema).max(4096), sources: z.array(sourceSchema).max(4096),
50
+ nextSource: z.number().int().positive(), mutations: z.record(z.string(), z.object({ hash: z.string(), result: z.json() })),
51
+ migrations: z.array(z.string().max(512)).max(128),
52
+ rack: z.array(z.object({ source_id: id, addedAt: z.string() })).max(512).default([]),
53
+ rackRevision: z.number().int().nonnegative().default(0),
54
+ attention: z.array(z.object({ key: z.string().max(2048), area: z.enum(['rack', 'board', 'inbox']),
55
+ board_id: id.optional(), item_id: id.optional(), title: short, action: z.string().max(32),
56
+ revision: z.number().int().nonnegative(), updated_at: z.string() })).max(16384).default([]),
57
+ })
58
+
59
+ const clone = value => structuredClone(value)
60
+ const hash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
61
+ const now = () => new Date().toISOString()
62
+ const bounded = (value, length = 512) => typeof value === 'string' ? value.slice(0, length) : ''
63
+ export function investigationError(code, message, details = {}) { return Object.assign(new Error(message), { code, details }) }
64
+ function requireValue(condition, message, code = 'INVALID_INVESTIGATION') { if (!condition) throw investigationError(code, message) }
65
+ function abort(signal) { if (signal?.aborted) throw investigationError('CANCELLED', '调查操作已取消') }
66
+ function safeUrl(value) {
67
+ if (!value) return undefined
68
+ try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && url.href.length <= 4096 ? url.href : undefined } catch { return undefined }
69
+ }
70
+ function validateSession(value) { requireValue(typeof value === 'string' && value.trim().length > 0 && value.length <= 512, '需要有效的会话 ID'); return value }
71
+ function freshPortfolio(sessionId) { return { schemaVersion: 1, sessionId, commitSeq: 0, boards: [], runs: [], sources: [], nextSource: 1, mutations: {}, migrations: [] } }
72
+ function boardOf(portfolio, boardId) { const board = portfolio.boards.find(row => row.id === boardId); requireValue(board, '该调查板不存在于当前会话', 'INVESTIGATION_NOT_FOUND'); return board }
73
+ function requireRun(portfolio, boardId, runId, turnId) {
74
+ const run = portfolio.runs.find(row => row.id === runId)
75
+ requireValue(run?.boardId === boardId && run.status === 'running' && run.turnId === String(turnId ?? ''), '本次运行未绑定到这块调查板,或已经结束;请重新选择调查', 'INVESTIGATION_RUN_MISMATCH')
76
+ return run
77
+ }
78
+ function sourceIdentity(source) { return source.documentUid || source.documentId ? JSON.stringify([source.dataVersion || '', source.documentUid || source.documentId]) : source.url ? `url:${source.url.replace(/#.*$/, '')}` : source.sourceId || source.id }
79
+ function uniqueRanges(values) {
80
+ return [...new Map(values.filter(r => Number.isSafeInteger(r?.start) && r.start > 0 && Number.isSafeInteger(r.end) && r.end >= r.start).map(r => [`${r.start}:${r.end}`, { start: r.start, end: r.end }])).values()].slice(-256)
81
+ }
82
+ function normalizeSource(source, alias, callId, trusted) {
83
+ const read = trusted && (source.agentRead === true || source.state === 'read')
84
+ const body = bounded(source.content, 64000)
85
+ return sourceSchema.parse({ id: alias, sourceId: bounded(source.sourceId || source.id, 512), title: bounded(source.title),
86
+ kind: bounded(source.kind || 'source', 80), origin: ['local', 'cloud', 'web'].includes(source.origin) ? source.origin : 'local',
87
+ state: read ? 'read' : 'found', agentRead: read, excerpt: bounded(source.excerpt, 4000), ...(body ? { content: body } : {}),
88
+ contentTruncated: !!source.contentTruncated || typeof source.content === 'string' && source.content.length > 64000,
89
+ ...Object.fromEntries(['documentId', 'documentUid', 'sourceRef', 'dataVersion'].filter(key => typeof source[key] === 'string' && source[key]).map(key => [key, bounded(source[key], key === 'dataVersion' ? 128 : key === 'documentUid' ? 512 : 2048)])),
90
+ ...(safeUrl(source.url) ? { url: safeUrl(source.url) } : {}),
91
+ ...(Number.isSafeInteger(source.lineStart) && source.lineStart > 0 ? { lineStart: source.lineStart } : {}),
92
+ ...(Number.isSafeInteger(source.lineEnd) && source.lineEnd > 0 ? { lineEnd: source.lineEnd } : {}),
93
+ ranges: read ? uniqueRanges([...(source.readRanges || source.ranges || []), ...(source.lineStart ? [{ start: source.lineStart, end: source.lineEnd || source.lineStart }] : [])]) : [],
94
+ callId: bounded(callId), updatedAt: now(), contentHash: hash(body || bounded(source.excerpt, 4000)),
95
+ })
96
+ }
97
+ function ingestSources(portfolio, incoming, callId, trusted) {
98
+ const aliases = {}
99
+ for (const raw of incoming.slice(0, 128)) {
100
+ if (!raw?.title || !(raw.id || raw.sourceId)) continue
101
+ const identity = sourceIdentity(raw)
102
+ const previous = portfolio.sources.find(source => sourceIdentity(source) === identity || source.sourceId === (raw.sourceId || raw.id))
103
+ if (!previous) requireValue(portfolio.sources.length < 4096, '当前会话来源记录已满;已保存的资料不会删除', 'INVESTIGATION_CAPACITY')
104
+ const item = normalizeSource(raw, previous?.id || `R${String(portfolio.nextSource++).padStart(4, '0')}`, callId, trusted)
105
+ aliases[raw.sourceId || raw.id] = item.id
106
+ if (previous) {
107
+ // A later search hit must not turn a read source back into a summary.
108
+ const preferOld = previous.agentRead && !item.agentRead
109
+ Object.assign(previous, { ...item, ...preferOld ? { state: previous.state, agentRead: true,
110
+ excerpt: previous.excerpt, content: previous.content, contentTruncated: previous.contentTruncated, contentHash: previous.contentHash,
111
+ callId: previous.callId } : {}, ranges: uniqueRanges([...previous.ranges, ...item.ranges]) })
112
+ } else portfolio.sources.push(item)
113
+ }
114
+ return aliases
115
+ }
116
+ function resolveReferences(portfolio, incoming) {
117
+ requireValue(Array.isArray(incoming) && incoming.length <= 32, 'sources 必须是最多 32 项的来源引用列表')
118
+ return incoming.map(raw => {
119
+ const ref = referenceSchema.parse(raw)
120
+ const source = portfolio.sources.find(row => row.id === ref.source_id || row.sourceId === ref.source_id || row.documentUid === ref.source_id)
121
+ requireValue(source, `来源 ${ref.source_id} 未在当前会话返回;用 investigation_get 的 sources 部分查看有效 ID`, 'INVESTIGATION_SOURCE_MISSING')
122
+ if (ref.line_start || ref.line_end) {
123
+ requireValue(ref.line_start && ref.line_end && ref.line_end >= ref.line_start, '引用行范围不完整')
124
+ requireValue(source.agentRead && source.ranges.some(r => r.start <= ref.line_start && r.end >= ref.line_end), '引用的行范围不在实际已读范围内', 'INVESTIGATION_SOURCE_UNREAD')
125
+ }
126
+ if (ref.quote) requireValue((source.content || source.excerpt).includes(ref.quote), '摘录不在保存的返回片段内;请先读取对应原文或去掉直接引文', 'INVESTIGATION_QUOTE_MISMATCH')
127
+ return { ...ref, source_id: source.id }
128
+ })
129
+ }
130
+ function cluePatch(portfolio, board, change, actor, mapping) {
131
+ const previous = change.id ? board.clues.find(row => row.id === change.id) : undefined
132
+ requireValue(!change.id || previous, `线索 ${change.id} 不存在`, 'INVESTIGATION_CLUE_MISSING')
133
+ const action = change.action || 'upsert'
134
+ if (action === 'layout') {
135
+ requireValue(previous, '布局修改需要已有线索')
136
+ if (change.expected_layout_revision !== undefined) requireValue(previous.layoutRevision === change.expected_layout_revision, '线索布局已更新,请刷新后重试', 'INVESTIGATION_CONFLICT')
137
+ requireValue(actor === 'user', 'Agent 不设置纸片坐标')
138
+ for (const key of ['position', 'scale', 'rotation']) if (change[key] !== undefined) previous[key] = change[key]
139
+ previous.userLayoutLocked = true; previous.layoutRevision++; board.layoutRevision++
140
+ clueSchema.parse(previous)
141
+ return previous
142
+ }
143
+ if (previous && actor === 'user') requireValue(change.expected_content_revision === previous.contentRevision, '这条线索已经更新,请先读取新内容再编辑', 'INVESTIGATION_CONFLICT')
144
+ if (action === 'retract') {
145
+ requireValue(previous, '撤回需要已有线索')
146
+ requireValue(actor === 'user' || previous.editedBy !== 'user', '用户编辑过的线索不能由 Agent 直接撤回', 'INVESTIGATION_USER_EDIT')
147
+ previous.status = 'retracted'; previous.contentRevision++; previous.updatedAt = now(); previous.editedBy = actor
148
+ return previous
149
+ }
150
+ requireValue(action === 'upsert', '不支持的线索操作')
151
+ if (!previous) requireValue(board.clues.length < 512, '这块板已达到 512 条线索,已有内容仍保留', 'INVESTIGATION_CAPACITY')
152
+ if (previous?.editedBy === 'user' && actor === 'agent') {
153
+ for (const key of ['title', 'summary', 'detail', 'kind', 'status', 'interpretation']) requireValue(change[key] === undefined || change[key] === previous[key], '这条线索已由用户编辑;可补充来源,不能直接覆盖用户内容', 'INVESTIGATION_USER_EDIT')
154
+ }
155
+ const clueId = previous?.id || `C${String(board.nextClue++).padStart(3, '0')}`
156
+ const seed = parseInt(hash(`${board.id}:${clueId}`).slice(0, 7), 16)
157
+ const next = previous || { id: clueId, kind: 'finding', title: '', summary: '', detail: '', interpretation: 'observation',
158
+ status: 'active', importance: 'supporting', sources: [], contentRevision: 0, layoutRevision: 0,
159
+ authoredBy: actor, editedBy: actor, createdAt: now(), updatedAt: now(), styleSeed: seed,
160
+ variant: board.clues.length % 3, userLayoutLocked: false }
161
+ for (const key of ['kind', 'title', 'summary', 'detail', 'interpretation', 'status', 'importance']) if (change[key] !== undefined) next[key] = change[key]
162
+ if (change.sources !== undefined) {
163
+ const incoming = resolveReferences(portfolio, change.sources)
164
+ next.sources = [...new Map([...next.sources, ...incoming].map(ref => [JSON.stringify(ref), ref])).values()]
165
+ }
166
+ if (next.kind === 'question') next.interpretation = 'question'
167
+ if (next.interpretation === 'observation' && actor === 'agent' && next.kind !== 'question') requireValue(next.sources.length, '观察或原文证据需要来源;无来源的研究方向请标为 question/inference')
168
+ if (!previous && actor === 'user') for (const key of ['position', 'scale', 'rotation']) if (change[key] !== undefined) {
169
+ next[key] = change[key]; next.userLayoutLocked = true
170
+ }
171
+ next.updatedAt = now(); next.contentRevision++
172
+ if (previous?.editedBy !== 'user' || actor === 'user') next.editedBy = actor
173
+ const checked = clueSchema.parse(next)
174
+ if (previous) Object.assign(previous, checked); else board.clues.push(checked)
175
+ if (change.client_key) mapping[change.client_key] = clueId
176
+ return checked
177
+ }
178
+ function pendingEvidence(board) { return board.evidenceInbox.filter(item => item.status === 'pending') }
179
+ function checkInboxRevision(board, revision) {
180
+ requireValue(Number.isSafeInteger(revision) && revision === board.inboxRevision, '证据盒已经更新,请刷新后重试', 'INVESTIGATION_INBOX_CONFLICT')
181
+ }
182
+ function changeInbox(portfolio, board, changes, actor) {
183
+ requireValue(Array.isArray(changes) && changes.length > 0 && changes.length <= 20, '每次整理证据盒需要 1–20 项变更')
184
+ let changed = false
185
+ for (const change of changes) {
186
+ const source = portfolio.sources.find(s => s.id === change.source_id || s.sourceId === change.source_id)
187
+ requireValue(source, '只能暂存本会话实际返回的资料;先读取有效来源编号', 'INVESTIGATION_SOURCE_MISSING')
188
+ let item = board.evidenceInbox.find(row => row.source_id === source.id)
189
+ requireValue(['add', 'remove'].includes(change.action), '证据盒仅支持 add 或 remove')
190
+ if (change.action === 'remove') {
191
+ requireValue(item?.status === 'pending', '这份资料已经不在待整理盒中', 'INVESTIGATION_INBOX_CONFLICT')
192
+ requireValue(actor === 'user' || item.addedBy !== 'user', '用户放入的重点材料请整理上板或保留,不要自动移出', 'INVESTIGATION_USER_EDIT')
193
+ item.status = 'removed'; item.removedBy = actor; item.updatedAt = now(); changed = true
194
+ } else {
195
+ requireValue(typeof change.note === 'string' && change.note.length <= 600, '请用 600 字以内说明待核对的问题')
196
+ if (item?.status === 'pending') continue // Same document has one slot; do not overwrite the user's note.
197
+ requireValue(actor === 'user' || !(item?.status === 'removed' && item.removedBy === 'user'), '用户已移出这份材料,不要自动放回', 'INVESTIGATION_USER_EDIT')
198
+ if (!item) {
199
+ requireValue(board.evidenceInbox.length < 512, '这块板的证据盒已达容量上限,已有材料仍保留', 'INVESTIGATION_CAPACITY')
200
+ item = { source_id: source.id, note: '', status: 'pending', addedBy: actor, addedAt: now(), updatedAt: now(), clueIds: [] }
201
+ board.evidenceInbox.push(item)
202
+ }
203
+ delete item.removedBy
204
+ Object.assign(item, { note: change.note, status: 'pending', addedBy: actor, addedAt: now(), updatedAt: now(), clueIds: [] }); changed = true
205
+ }
206
+ }
207
+ if (changed) { board.inboxRevision++; board.updatedAt = now() }
208
+ }
209
+ function consumeEvidence(board, clues) {
210
+ let changed = false
211
+ for (const item of pendingEvidence(board)) {
212
+ const linked = clues.filter(c => !['retracted', 'superseded'].includes(c.status) && c.sources.some(ref => ref.source_id === item.source_id))
213
+ if (linked.length) { item.status = 'promoted'; item.clueIds = linked.map(c => c.id); item.updatedAt = now(); changed = true }
214
+ }
215
+ if (changed) board.inboxRevision++
216
+ }
217
+ function boardSummary(board, portfolio) {
218
+ const latest = board.reports.at(-1), run = [...portfolio.runs].reverse().find(run => run.boardId === board.id)
219
+ return { id: board.id, title: board.title, objective: board.objective, updatedAt: board.updatedAt,
220
+ knowledgeRevision: board.knowledgeRevision, layoutRevision: board.layoutRevision, inboxRevision: board.inboxRevision, pendingEvidenceCount: pendingEvidence(board).length,
221
+ clueCount: board.clues.filter(c => !['retracted', 'superseded'].includes(c.status)).length,
222
+ reportVersion: latest?.version || 0, reportStale: !!latest && latest.basisKnowledgeRevision !== board.knowledgeRevision,
223
+ run: run ? clone(run) : null }
224
+ }
225
+
226
+ /** One instance per storageDomain service, not one per agent plugin instance. */
227
+ export function createInvestigationStore(storageDomain) {
228
+ const domains = new Map(), snapshots = new Map(), listeners = new Map(), pendingReceipts = new Map()
229
+ let disposed = false
230
+ async function ensure(sessionId) {
231
+ validateSession(sessionId); requireValue(!disposed, '调查服务已关闭', 'INVESTIGATION_CLOSED')
232
+ if (!domains.has(sessionId)) {
233
+ const loading = (async () => {
234
+ // A genuine Zod schema, with no static dependency on a particular DSH build.
235
+ const domain = await storageDomain.open({ name: `prts_inv_${hash(sessionId).slice(0, 32)}`, version: 1, layout: 'single',
236
+ tables: { portfolios: { valueSchema: portfolioSchema } } })
237
+ const table = domain.table('portfolios')
238
+ if (!table.get('state')) await table.put('state', freshPortfolio(sessionId))
239
+ // No live run survives a process/service restart. Preserve its partial work.
240
+ if (table.get('state').runs.some(run => run.status === 'running')) await table.update('state', current => {
241
+ const next = clone(current)
242
+ for (const run of next.runs) if (run.status === 'running') { run.status = 'interrupted'; run.endedAt = now() }
243
+ next.commitSeq++; return next
244
+ })
245
+ const saved = portfolioSchema.parse(table.get('state'))
246
+ requireValue(saved.sessionId === sessionId, '调查记录的会话身份不匹配')
247
+ snapshots.set(sessionId, clone(saved))
248
+ return { domain, table }
249
+ })()
250
+ domains.set(sessionId, loading)
251
+ loading.catch(() => domains.delete(sessionId))
252
+ }
253
+ return domains.get(sessionId)
254
+ }
255
+ async function mutate(sessionId, key, input, apply, signal) {
256
+ abort(signal)
257
+ const { table } = await ensure(sessionId)
258
+ const requestHash = hash(input), mutationKey = hash(key)
259
+ let result, changed = false
260
+ const next = await table.update('state', saved => {
261
+ abort(signal)
262
+ const cached = saved.mutations[mutationKey]
263
+ if (cached) { requireValue(cached.hash === requestHash, '同一个操作 ID 不能用于不同内容', 'INVESTIGATION_IDEMPOTENCY'); result = clone(cached.result); return saved }
264
+ const portfolio = portfolioSchema.parse(clone(saved))
265
+ result = apply(portfolio)
266
+ portfolio.commitSeq++
267
+ portfolio.mutations[mutationKey] = { hash: requestHash, result: JSON.parse(JSON.stringify(result)) }
268
+ // Every retained mutation remains replayable; exceeding the budget fails before commit.
269
+ requireValue(Object.keys(portfolio.mutations).length <= 40000, '当前会话的调查操作记录已满,请开始新会话;已有调查不会删除', 'INVESTIGATION_CAPACITY')
270
+ portfolioSchema.parse(portfolio)
271
+ requireValue(Buffer.byteLength(JSON.stringify(portfolio)) <= 32 * 1024 * 1024, '当前会话调查数据已达 32 MiB,请开始新会话;本次变更尚未保存', 'INVESTIGATION_CAPACITY')
272
+ changed = true
273
+ return portfolio
274
+ })
275
+ snapshots.set(sessionId, portfolioSchema.parse(clone(next)))
276
+ if (changed) for (const listener of listeners.get(sessionId) || []) { try { listener(next.commitSeq) } catch {} }
277
+ return clone(result)
278
+ }
279
+ const store = {
280
+ async read(sessionId, args = {}) {
281
+ const { board_id, section = 'board', query = '', cursor = 0, limit = 20 } = args
282
+ await store.flushReceipts(sessionId)
283
+ const { table } = await ensure(sessionId), p = portfolioSchema.parse(table.get('state'))
284
+ const working = [...p.runs].reverse().find(run => run.status === 'running')
285
+ if (['rack', 'source', 'changes'].includes(section)) {
286
+ const { _review, ...value } = inspectPortfolio(p, args, boardSummary)
287
+ if (section === 'rack') value.sources = value.sources.map(source => ({ ...source, content: p.sources.find(s => s.id === source.id)?.content }))
288
+ return value
289
+ }
290
+ if (!board_id && section !== 'sources') {
291
+ const rows = [...p.boards].reverse().filter(b => !query || `${b.title} ${b.objective}`.toLowerCase().includes(String(query).toLowerCase()))
292
+ const offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
293
+ return { commitSeq: p.commitSeq, rackRevision: p.rackRevision, workingBoardId: working?.boardId || null, boards: rows.slice(offset, offset + count).map(b => boardSummary(b, p)), nextCursor: rows.length > offset + count ? offset + count : null }
294
+ }
295
+ if (section === 'sources') return { commitSeq: p.commitSeq, sources: p.sources.slice(-Math.min(128, Number(limit) || 40)).map(({ content, ...source }) => clone(source)) }
296
+ const board = boardOf(p, board_id)
297
+ if (section === 'inbox') {
298
+ const items = pendingEvidence(board), offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
299
+ return { board_id: board.id, title: board.title, inbox_revision: board.inboxRevision, pending_count: items.length,
300
+ items: items.slice(offset, offset + count).map(item => { const { content, ...source } = p.sources.find(s => s.id === item.source_id); return { ...clone(item), source: clone(source) } }),
301
+ nextCursor: items.length > offset + count ? offset + count : null }
302
+ }
303
+ return { commitSeq: p.commitSeq, workingBoardId: working?.boardId || null, ...boardSummary(board, p), board: clone(board),
304
+ sources: p.sources.filter(source => board.clues.some(c => c.sources.some(ref => ref.source_id === source.id)) || board.evidenceInbox.some(item => item.source_id === source.id && item.status !== 'removed')).map(source => clone(source)) }
305
+ },
306
+ peek(sessionId) { return snapshots.get(sessionId) },
307
+ async prepare(sessionId) { await store.flushReceipts(sessionId); await ensure(sessionId) },
308
+ async inspect(sessionId, args = {}) {
309
+ await store.prepare(sessionId)
310
+ const { table } = await ensure(sessionId)
311
+ return inspectPortfolio(portfolioSchema.parse(table.get('state')), args, boardSummary)
312
+ },
313
+ reviewSummary(sessionId) { return reviewSummary(snapshots.get(sessionId)) },
314
+ acknowledge(sessionId, receipt, callId) {
315
+ if (!receipt?.length) return Promise.resolve()
316
+ const pending = mutate(sessionId, `review:${callId}`, receipt, p => {
317
+ const seen = new Map(receipt.map(item => [item.key, item.revision]))
318
+ // A user update racing this tool result remains unread.
319
+ p.attention = p.attention.filter(item => seen.get(item.key) !== item.revision)
320
+ return { pending_count: p.attention.length }
321
+ })
322
+ const previous = pendingReceipts.get(sessionId) || Promise.resolve()
323
+ pendingReceipts.set(sessionId, Promise.allSettled([previous, pending]).then(() => {}))
324
+ return pending
325
+ },
326
+ async saveRack(sessionId, args, signal) {
327
+ requireValue(Array.isArray(args.sources) && args.sources.length > 0 && args.sources.length <= 150, '每次收藏需要 1–150 份资料')
328
+ const importing = args.action === 'import'
329
+ requireValue(importing || args.action === 'add', '档案架支持 add 或 import')
330
+ requireValue(importing || typeof args.mutation_id === 'string' && args.mutation_id.length < 512, '收藏需要唯一操作 ID')
331
+ await store.flushReceipts(sessionId)
332
+ const input = { action: args.action, sources: args.sources }
333
+ return mutate(sessionId, importing ? `rack-import:${hash(input)}` : `rack:${args.mutation_id}`, input, p => {
334
+ requireValue(args.sources.every(s => s?.title && (s.id || s.sourceId)), '收藏需要有效的资料标题和来源 ID')
335
+ const before = new Map(p.sources.map(s => [s.id, s.contentHash]))
336
+ // A browser's legacy cache must never overwrite a newer server bookmark.
337
+ const incoming = importing ? args.sources.filter(raw => !p.sources.some(s =>
338
+ (sourceIdentity(s) === sourceIdentity(raw) || s.sourceId === (raw.sourceId || raw.id)) && p.rack.some(r => r.source_id === s.id))) : args.sources
339
+ const aliases = ingestSources(p, incoming.map(s => ({ ...s, content: s.content ?? s.excerpt })), 'user-rack', false), added = [], updated = []
340
+ for (const sourceId of new Set(Object.values(aliases))) if (!p.rack.some(item => item.source_id === sourceId)) {
341
+ requireValue(p.rack.length < 512, '档案架已达到 512 份手动收藏,已有资料仍会保留', 'INVESTIGATION_CAPACITY')
342
+ p.rack.push({ source_id: sourceId, addedAt: now() }); added.push(sourceId)
343
+ noteUserChange(p, { area: 'rack', item_id: sourceId, action: 'added', title: p.sources.find(s => s.id === sourceId).title })
344
+ } else if (before.get(sourceId) !== p.sources.find(s => s.id === sourceId).contentHash) {
345
+ updated.push(sourceId)
346
+ noteUserChange(p, { area: 'rack', item_id: sourceId, action: 'edited', title: p.sources.find(s => s.id === sourceId).title })
347
+ }
348
+ if (added.length || updated.length) p.rackRevision++
349
+ return { added_ids: added, updated_ids: updated, rack_revision: p.rackRevision, pending_count: p.attention.length }
350
+ }, signal)
351
+ },
352
+ async open(sessionId, args, execution) {
353
+ const key = `open:${execution.callId}`
354
+ return mutate(sessionId, key, args, p => {
355
+ let board
356
+ if (args.mode === 'new') {
357
+ requireValue(p.boards.length < 128, '当前会话最多 128 块调查板,已有板仍可继续使用', 'INVESTIGATION_CAPACITY')
358
+ board = boardSchema.parse({ id: `B-${randomUUID()}`, title: args.title, objective: args.objective,
359
+ createdAt: now(), updatedAt: now(), knowledgeRevision: 0, layoutRevision: 0, clues: [], relations: [], reports: [], openQuestions: [], nextClue: 1 })
360
+ p.boards.push(board)
361
+ } else { requireValue(args.mode === 'resume', 'mode 只能为 new 或 resume'); board = boardOf(p, args.board_id) }
362
+ for (const previous of p.runs) if (previous.turnId === String(execution.turnId || '') && previous.boardId !== board.id && previous.status === 'running') { previous.status = 'interrupted'; previous.endedAt = now() }
363
+ const same = p.runs.find(run => run.turnId === String(execution.turnId || '') && run.boardId === board.id && run.status === 'running')
364
+ const run = same || { id: `run-${randomUUID()}`, boardId: board.id, turnId: bounded(String(execution.turnId || '')),
365
+ reason: bounded(args.reason, 500), startedAt: now(), status: 'running' }
366
+ if (!same) p.runs.push(run)
367
+ board.updatedAt = now()
368
+ return { board_id: board.id, run_id: run.id, revision: board.knowledgeRevision, inbox_revision: board.inboxRevision, pending_evidence: pendingEvidence(board).length, title: board.title, objective: board.objective, open_questions: board.openQuestions }
369
+ }, execution.signal)
370
+ },
371
+ async update(sessionId, args, execution) {
372
+ await store.flushReceipts(sessionId)
373
+ return mutate(sessionId, `update:${execution.callId}`, args, p => {
374
+ const board = boardOf(p, args.board_id); requireRun(p, board.id, args.run_id, execution.turnId)
375
+ requireValue(board.knowledgeRevision === args.expected_revision, '调查内容已更新,请用 investigation_get 重读后提交', 'INVESTIGATION_CONFLICT')
376
+ requireValue(Array.isArray(args.clues || []) && (args.clues || []).length <= 20, '每次更新最多 20 条线索')
377
+ const mapping = {}, updated = []
378
+ for (const change of args.clues || []) updated.push(cluePatch(p, board, change, 'agent', mapping).id)
379
+ const resolveId = value => mapping[value] || value
380
+ requireValue(Array.isArray(args.relations || []) && (args.relations || []).length <= 32, '每次更新最多 32 条关系')
381
+ for (const raw of args.relations || []) {
382
+ const from = resolveId(raw.from), to = resolveId(raw.to)
383
+ requireValue(from !== to && board.clues.some(c => c.id === from) && board.clues.some(c => c.id === to), '关系必须连接板上两条不同的线索')
384
+ const existing = board.relations.find(r => r.from === from && r.to === to && r.type === raw.type)
385
+ const relation = relationSchema.parse({ id: existing?.id || `rel-${randomUUID()}`, from, to, type: raw.type, label: raw.label || '' })
386
+ if (existing) Object.assign(existing, relation); else board.relations.push(relation)
387
+ }
388
+ requireValue(Array.isArray(args.merges || []) && (args.merges || []).length <= 20, '每次最多归并 20 条线索')
389
+ for (const merge of args.merges || []) {
390
+ const from = board.clues.find(c => c.id === resolveId(merge.from)), to = board.clues.find(c => c.id === resolveId(merge.into))
391
+ requireValue(from && to && from !== to && !['superseded', 'retracted'].includes(to.status), '归并需要两条有效线索')
392
+ requireValue(from.editedBy !== 'user', '用户修改的线索不能自动归并撤下', 'INVESTIGATION_USER_EDIT')
393
+ to.sources = [...new Map([...to.sources, ...from.sources].map(ref => [JSON.stringify(ref), ref])).values()]
394
+ to.contentRevision++; from.status = 'superseded'; from.mergedInto = to.id; from.contentRevision++
395
+ board.relations = board.relations.map(r => ({ ...r, from: r.from === from.id ? to.id : r.from, to: r.to === from.id ? to.id : r.to })).filter(r => r.from !== r.to)
396
+ }
397
+ consumeEvidence(board, board.clues.filter(c => updated.includes(c.id)))
398
+ if (args.open_questions !== undefined) board.openQuestions = args.open_questions
399
+ board.knowledgeRevision++; board.updatedAt = now()
400
+ return { board_id: board.id, revision: board.knowledgeRevision, inbox_revision: board.inboxRevision, created_ids: mapping, updated_ids: updated,
401
+ warnings: board.clues.filter(c => updated.includes(c.id) && c.sources.some(ref => !p.sources.find(s => s.id === ref.source_id)?.agentRead)).map(c => `${c.id} 包含仅检索摘要的来源,尚未读取全文`) }
402
+ }, execution.signal)
403
+ },
404
+ async stage(sessionId, args, execution) {
405
+ await store.flushReceipts(sessionId)
406
+ return mutate(sessionId, `stage:${execution.callId}`, args, p => {
407
+ const board = boardOf(p, args.board_id); requireRun(p, board.id, args.run_id, execution.turnId)
408
+ checkInboxRevision(board, args.expected_inbox_revision)
409
+ changeInbox(p, board, args.changes, 'agent')
410
+ return { board_id: board.id, inbox_revision: board.inboxRevision, pending_count: pendingEvidence(board).length }
411
+ }, execution.signal)
412
+ },
413
+ async editInbox(sessionId, args, signal) {
414
+ requireValue(typeof args.mutation_id === 'string' && args.mutation_id.length < 512, '证据盒操作需要唯一 ID')
415
+ await store.flushReceipts(sessionId)
416
+ return mutate(sessionId, `inbox:${args.mutation_id}`, args, p => {
417
+ const board = boardOf(p, args.board_id)
418
+ checkInboxRevision(board, args.expected_inbox_revision)
419
+ const aliases = ingestSources(p, args.sources || [], 'user-inbox', false)
420
+ const sourceId = aliases[args.source_id] || args.source_id
421
+ if (args.action !== 'promote') {
422
+ const before = board.inboxRevision
423
+ changeInbox(p, board, [{ action: args.action, source_id: sourceId, note: args.note || '' }], 'user')
424
+ if (board.inboxRevision !== before) noteUserChange(p, { area: 'inbox', board_id: board.id, item_id: sourceId, action: args.action, title: p.sources.find(s => s.id === sourceId).title })
425
+ }
426
+ else {
427
+ const item = board.evidenceInbox.find(row => row.source_id === sourceId && row.status === 'pending')
428
+ requireValue(item, '材料已被处理,请刷新证据盒', 'INVESTIGATION_INBOX_CONFLICT')
429
+ requireValue(typeof args.title === 'string' && args.title.trim() && typeof args.detail === 'string' && args.detail.trim(), '先写下线索标题与摘记,再放上证据板')
430
+ const clue = cluePatch(p, board, { title: args.title.trim(), summary: bounded(args.detail.trim(), 480), detail: args.detail.trim(),
431
+ kind: args.kind || 'excerpt', interpretation: args.interpretation || 'question', status: args.interpretation === 'question' ? 'unresolved' : 'active',
432
+ sources: [{ source_id: sourceId }] }, 'user', {})
433
+ consumeEvidence(board, [clue]); board.knowledgeRevision++; board.updatedAt = now()
434
+ noteUserChange(p, { area: 'inbox', board_id: board.id, item_id: sourceId, action: 'promoted', title: clue.title })
435
+ noteUserChange(p, { area: 'board', board_id: board.id, item_id: clue.id, action: 'added', title: clue.title })
436
+ return { board_id: board.id, inbox_revision: board.inboxRevision, clue_id: clue.id }
437
+ }
438
+ return { board_id: board.id, inbox_revision: board.inboxRevision, pending_count: pendingEvidence(board).length }
439
+ }, signal)
440
+ },
441
+ async publish(sessionId, args, execution) {
442
+ await store.flushReceipts(sessionId)
443
+ return mutate(sessionId, `publish:${execution.callId}`, args, p => {
444
+ const board = boardOf(p, args.board_id); requireRun(p, board.id, args.run_id, execution.turnId)
445
+ requireValue(board.knowledgeRevision === args.expected_revision, '有新线索尚未纳入报告,请重读调查后发布', 'INVESTIGATION_CONFLICT')
446
+ requireValue(Array.isArray(args.clue_ids) && args.clue_ids.length <= 128, 'clue_ids 必须列出报告使用的线索')
447
+ const cited = [...new Set(args.clue_ids)].map(value => { const clue = board.clues.find(c => c.id === value); requireValue(clue && !['retracted', 'superseded'].includes(clue.status), `报告引用的线索 ${value} 已不可用`); return clone(clue) })
448
+ const sources = p.sources.filter(s => cited.some(c => c.sources.some(ref => ref.source_id === s.id))).map(s => { const copy = clone(s); delete copy.content; return copy })
449
+ const report = reportSchema.parse({ id: `report-${randomUUID()}`, version: board.reports.length + 1, title: args.title,
450
+ summary: args.summary, markdown: args.markdown, publishedAt: now(), runId: args.run_id,
451
+ basisKnowledgeRevision: board.knowledgeRevision, clues: cited, sources })
452
+ board.reports.push(report); board.updatedAt = now()
453
+ return { board_id: board.id, report_id: report.id, version: report.version, title: report.title, revision: board.knowledgeRevision,
454
+ message: '调查报告已保存到线索板中央。向用户说明结果即可,不必再次生成一份不同的全文。' }
455
+ }, execution.signal)
456
+ },
457
+ async createUserBoard(sessionId, args, signal) {
458
+ requireValue(typeof args.mutation_id === 'string', '创建需要唯一操作 ID')
459
+ return mutate(sessionId, `create:${args.mutation_id}`, args, p => {
460
+ const board = boardSchema.parse({ id: `B-${randomUUID()}`, title: args.title || '手动研究摘记', objective: args.objective || args.title || '用户手动整理的研究资料',
461
+ createdAt: now(), updatedAt: now(), knowledgeRevision: 0, layoutRevision: 0, clues: [], relations: [], reports: [], openQuestions: [], nextClue: 1 })
462
+ p.boards.push(board)
463
+ noteUserChange(p, { area: 'board', board_id: board.id, action: 'created', title: board.title })
464
+ return { board_id: board.id }
465
+ }, signal)
466
+ },
467
+ async edit(sessionId, args, signal) {
468
+ requireValue(typeof args.mutation_id === 'string' && args.mutation_id.length < 512, '编辑需要唯一的操作 ID')
469
+ return mutate(sessionId, `user:${args.mutation_id}`, args, p => {
470
+ const board = boardOf(p, args.board_id), mapping = {}, edited = []
471
+ const aliases = ingestSources(p, args.sources || [], 'user', false)
472
+ requireValue(Array.isArray(args.changes) && args.changes.length <= 32, '每次编辑最多 32 条变更')
473
+ let contentChanged = false
474
+ for (const raw of args.changes) {
475
+ const change = clone(raw)
476
+ if (change.sources) change.sources = change.sources.map(ref => ({ ...ref, source_id: aliases[ref.source_id] || ref.source_id }))
477
+ const clue = cluePatch(p, board, change, 'user', mapping)
478
+ edited.push(clue.id)
479
+ if (change.action !== 'layout') {
480
+ contentChanged = true
481
+ noteUserChange(p, { area: 'board', board_id: board.id, item_id: clue.id,
482
+ action: change.action === 'retract' ? 'retracted' : change.id ? 'edited' : 'added', title: clue.title })
483
+ }
484
+ }
485
+ if (args.relations?.length) {
486
+ for (const raw of args.relations) {
487
+ const from = mapping[raw.from] || raw.from, to = mapping[raw.to] || raw.to
488
+ requireValue(board.clues.some(c => c.id === from) && board.clues.some(c => c.id === to) && from !== to, '关系的线索不可用')
489
+ if (raw.remove) board.relations = board.relations.filter(r => !(r.from === from && r.to === to))
490
+ else if (!board.relations.some(r => r.from === from && r.to === to)) board.relations.push(relationSchema.parse({ id: `rel-${randomUUID()}`, from, to, type: raw.type || 'relates', label: raw.label || '' }))
491
+ }
492
+ contentChanged = true
493
+ noteUserChange(p, { area: 'board', board_id: board.id, action: 'relations', title: board.title })
494
+ }
495
+ if (contentChanged) board.knowledgeRevision++
496
+ board.updatedAt = now()
497
+ return { board_id: board.id, revision: board.knowledgeRevision, created_ids: mapping, updated_ids: edited,
498
+ clue_revisions: Object.fromEntries(board.clues.filter(c => edited.includes(c.id)).map(c => [c.id,
499
+ { content_revision: c.contentRevision, layout_revision: c.layoutRevision }])) }
500
+ }, signal)
501
+ },
502
+ async importLegacy(sessionId, args, signal) {
503
+ requireValue(Array.isArray(args.cards) && args.cards.length <= 128 && typeof args.migration_id === 'string', '旧板迁移数据无效')
504
+ return mutate(sessionId, `migration:${args.migration_id}`, args, p => {
505
+ if (p.migrations.includes(args.migration_id)) return { imported: false }
506
+ const board = boardSchema.parse({ id: `B-${randomUUID()}`, title: '手动研究摘记', objective: '从原有线索板迁移的手动研究内容', createdAt: now(), updatedAt: now(), knowledgeRevision: 1, layoutRevision: 0,
507
+ clues: [], relations: [], reports: [], openQuestions: [], nextClue: 1 })
508
+ const mapping = {}
509
+ for (const card of args.cards) {
510
+ let references = []
511
+ if (card.sourceId) {
512
+ const local = /^document:([^:]+):(.+)$/.exec(card.sourceId)
513
+ const url = safeUrl(card.sourceId)
514
+ const aliases = ingestSources(p, [{ id: card.sourceId, title: card.sourceTitle || card.title, excerpt: bounded(card.body, 4000),
515
+ origin: url ? 'web' : 'local', ...(url ? { url } : {}), ...(local ? { dataVersion: local[1], documentUid: local[2] } : {}) }], 'legacy-user', false)
516
+ references = [{ source_id: aliases[card.sourceId] }]
517
+ }
518
+ const clue = cluePatch(p, board, { client_key: card.id, title: card.title, summary: bounded(card.body, 480), detail: bounded(card.body, 12000), sources: references,
519
+ kind: card.kind === 'question' ? 'question' : card.kind === 'source' ? 'excerpt' : 'finding', interpretation: 'question' }, 'user', mapping)
520
+ if (card.position) { clue.position = card.position; clue.userLayoutLocked = true }
521
+ if (Number.isFinite(card.rotation)) clue.rotation = Math.max(-30, Math.min(30, card.rotation))
522
+ if (Number.isFinite(card.scale)) clue.scale = Math.max(.5, Math.min(2, card.scale))
523
+ }
524
+ for (const card of args.cards) for (const to of card.links || []) if (mapping[to] && mapping[to] !== mapping[card.id]) board.relations.push({ id: `rel-${randomUUID()}`, from: mapping[card.id], to: mapping[to], type: 'relates', label: '' })
525
+ p.migrations.push(args.migration_id)
526
+ if (board.clues.length) {
527
+ p.boards.push(board)
528
+ noteUserChange(p, { area: 'board', board_id: board.id, action: 'imported', title: board.title })
529
+ for (const clue of board.clues) noteUserChange(p, { area: 'board', board_id: board.id, item_id: clue.id, action: 'imported', title: clue.title })
530
+ }
531
+ return { imported: true, board_id: board.clues.length ? board.id : null }
532
+ }, signal)
533
+ },
534
+ recordSources(sessionId, sources, callId) {
535
+ if (!sources.length) return Promise.resolve()
536
+ const pending = mutate(sessionId, `sources:${callId}`, sources, p => ({ sources: ingestSources(p, sources, callId, true) }))
537
+ const previous = pendingReceipts.get(sessionId) || Promise.resolve()
538
+ const settled = Promise.allSettled([previous, pending]).then(() => {})
539
+ pendingReceipts.set(sessionId, settled)
540
+ return pending
541
+ },
542
+ async flushReceipts(sessionId) { await pendingReceipts.get(sessionId) },
543
+ async endTurn(sessionId, turnId, outcome) {
544
+ // An open may still be committing when cancellation closes the turn.
545
+ // Queue behind it instead of deciding from the older in-memory snapshot.
546
+ if (!domains.has(sessionId)) return
547
+ return mutate(sessionId, `end:${turnId}`, outcome, p => {
548
+ for (const run of p.runs) if (run.turnId === String(turnId) && run.status === 'running') { run.status = outcome; run.endedAt = now() }
549
+ return { status: outcome }
550
+ })
551
+ },
552
+ async watch(sessionId, after, signal) {
553
+ const { table } = await ensure(sessionId); abort(signal)
554
+ if (table.get('state').commitSeq > after) return { commitSeq: table.get('state').commitSeq }
555
+ return new Promise((resolve, reject) => {
556
+ const rows = listeners.get(sessionId) || new Set(); listeners.set(sessionId, rows)
557
+ let timer
558
+ const cleanup = () => { clearTimeout(timer); rows.delete(changed); signal?.removeEventListener('abort', cancelled) }
559
+ const changed = commitSeq => { cleanup(); resolve({ commitSeq }) }
560
+ const cancelled = () => { cleanup(); reject(investigationError('CANCELLED', '订阅已关闭')) }
561
+ rows.add(changed); signal?.addEventListener('abort', cancelled, { once: true })
562
+ timer = setTimeout(() => changed(table.get('state').commitSeq), 25000)
563
+ if (signal?.aborted) cancelled()
564
+ else if (table.get('state').commitSeq > after) changed(table.get('state').commitSeq)
565
+ })
566
+ },
567
+ async close() { disposed = true; for (const rows of listeners.values()) for (const listener of [...rows]) listener(-1); listeners.clear(); await Promise.allSettled([...domains.values()].map(async loading => (await loading).domain.close())); domains.clear(); snapshots.clear() },
568
+ }
569
+ return store
570
+ }
571
+
572
+ const stores = new WeakMap()
573
+ export function acquireInvestigationStore(facility) {
574
+ let entry = stores.get(facility)
575
+ if (!entry) { entry = { service: createInvestigationStore(facility), refs: 0 }; stores.set(facility, entry) }
576
+ entry.refs++
577
+ let released = false
578
+ return { service: entry.service, release: async () => { if (released) return; released = true; if (--entry.refs === 0) { stores.delete(facility); await entry.service.close() } } }
579
+ }