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.
- package/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- 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
|
+
}
|