prts-terrarchive 0.2.0 → 0.2.2

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 (56) hide show
  1. package/README.en.md +84 -18
  2. package/README.md +64 -15
  3. package/THIRD_PARTY_NOTICES.md +10 -0
  4. package/bin/build-runtime.mjs +32 -0
  5. package/bin/check-runtime.mjs +56 -0
  6. package/bin/install.js +24 -5
  7. package/contracts/corpus_i18n_v1.schema.json +10 -10
  8. package/cordis.patch.yml +4 -4
  9. package/docs/web-access.md +24 -0
  10. package/lib/client.js +84 -23
  11. package/lib/rhine/rhine.css +1 -1
  12. package/lib/rhine/rhine.js +208 -208
  13. package/lib/runtime/licenses/js-yaml-MIT.txt +21 -0
  14. package/lib/runtime/licenses/zod-MIT.txt +21 -0
  15. package/lib/runtime/versions.json +4 -0
  16. package/lib/runtime/yaml.js +3062 -0
  17. package/lib/runtime/zod.js +14356 -0
  18. package/lib/skins/common.css +59 -0
  19. package/lib/skins/endfield-aic.css +22 -9
  20. package/lib/skins/prts-agent.css +15 -5
  21. package/lib/skins/rhine-lab.css +9 -0
  22. package/package.json +15 -8
  23. package/presets/composition.js +53 -0
  24. package/presets/definition.js +2 -2
  25. package/presets/prts/agent.cordis.yml +1 -1
  26. package/presets/prts/preset.yml +1 -1
  27. package/presets/register.js +23 -10
  28. package/resources/runtime/yaml.js +1 -0
  29. package/resources/runtime/zod.js +1 -0
  30. package/skills/prts-composition/SKILL.md +60 -0
  31. package/skills/prts-investigation/SKILL.md +19 -102
  32. package/skills/prts-investigation/references/board-guide.md +131 -0
  33. package/skills/prts-retrieval/SKILL.md +5 -3
  34. package/src/composition-skill.js +19 -0
  35. package/src/i18n.js +11 -17
  36. package/src/index.js +63 -81
  37. package/src/investigation-review.js +14 -6
  38. package/src/investigation-store.js +97 -25
  39. package/src/investigation-tools.js +67 -30
  40. package/src/investigation-validation.js +33 -0
  41. package/src/manual-search.js +1 -1
  42. package/src/skill.js +25 -16
  43. package/src/skin-capabilities.js +28 -0
  44. package/src/tools.js +13 -0
  45. package/src/web-guidance.js +44 -0
  46. package/ui/rhine/archive-rack.ts +10 -8
  47. package/ui/rhine/catalogue.ts +16 -4
  48. package/ui/rhine/evidence-board-panel.ts +15 -0
  49. package/ui/rhine/investigation-board.css +3 -0
  50. package/ui/rhine/investigation-board.ts +71 -24
  51. package/ui/rhine/investigation-history.ts +15 -0
  52. package/ui/rhine/reading-room.css +14 -3
  53. package/ui/rhine/scene.ts +44 -24
  54. package/ui/rhine/tool-activity.ts +13 -6
  55. package/ui/rhine/types.ts +6 -0
  56. package/ui/rhine/workbench.ts +82 -40
package/src/index.js CHANGED
@@ -45,6 +45,7 @@ import { createSharedState } from './state.js'
45
45
  import { applyUi } from './ui.js'
46
46
  import { acquireInvestigationStore } from './investigation-store.js'
47
47
  import { mountInvestigationTools } from './investigation-tools.js'
48
+ import { mountWebGuidance } from './web-guidance.js'
48
49
  import { attachLocalSourceMappings } from './source-map.js'
49
50
  import { projectCloudInspect, projectCloudSearch } from './cloud-projection.js'
50
51
  import { coveredRead, createEvidenceStateRegistry,
@@ -109,35 +110,15 @@ async function requireLocalCorpus(store) {
109
110
  }
110
111
  }
111
112
 
112
- const READ_DESCRIPTION = [
113
- '读取 PRTS.chat 本地资料。明日方舟关卡用 stage_code,只有多篇时才填 story_part;干员密录用 character_name + record_name + segment;角色资料用 character_name + material。',
114
- '整个明日方舟活动用 activity_name + mode=activity,终末地任务用 collection_name + mode=collection;合集续页原样提交 page.continuation 的 position。其他资料使用完整 title。',
115
- '搜索结果若给出 document_uid,说明标题或合集同名:它替代 title,读取时只提交 document_uid,不得同时提交 title;单篇可配 line 或 mode=document,所属合集可配 mode=activity/collection。',
116
- 'line 扩大单篇原文上下文,section 读取 Wiki 字段,mode=document 分页全文。所有续页都原样提交 page.continuation;旧会话里的 cursor 仅用于兼容。',
117
- '引用原文使用“《篇章名》第 N 行”;同名结果保留工具给出的 document_uid 作为唯一定位器。不要使用内部代号、路径或自造篇章名。',
118
- ].join(' ')
113
+ const READ_DESCRIPTION = '读取 PRTS.chat 本地资料,支持行号上下文、Wiki 字段和单篇/合集连续阅读。只选一种主定位方式;续页原样提交 page.continuation。'
119
114
 
120
- const SEARCH_DESCRIPTION = [
121
- '像 grep 一样搜索 PRTS.chat 本地语料;命中立即返回原行及上下各一行,并按文档归并。',
122
- 'query 使用短实体名、篇章展示名或原句片段;也可省略 query,仅按过滤条件列出资料入口。',
123
- '角色个人页用 character_wiki;活动/密录整理页用 story_wiki;角色在单个活动中的辅助整理用 character_activity_wiki。wiki_sections 可精确限定相关活动、相关角色、剧情总结、角色剧情概括等标签字段。',
124
- 'literal 是默认连续字面匹配;只有特殊模式才使用受限 regex。新查询的下一页保留原搜索条件,并把返回的 next_after 原样放入 after;锚点由完整资料版本、资料类型与自然标题组成,不再暴露内部 cursor。旧会话的 cursor 链仅按返回的 next_cursor 继续。',
125
- ].join(' ')
115
+ const SEARCH_DESCRIPTION = '搜索 PRTS.chat 本地语料,返回按文档归并的命中行及上下各一行;省略 query 可列目录。过滤数组内 OR、字段间 AND。'
126
116
 
127
- const TIMELINE_DESCRIPTION = [
128
- '按活动名、年份及自动展开的实体别名检索活动时间线(PRTS Wiki《泰拉年表》本地投影)。',
129
- '人物放 entity_names 以自动裂变别名;结果只给时间、事件正文和“年表出处”标记,把标记原样传回 source_marker 可反查完整来源。',
130
- ].join(' ')
117
+ const TIMELINE_DESCRIPTION = '检索明日方舟活动时间线(PRTS Wiki《泰拉年表》),返回年份、事件与可反查的出处标记。'
131
118
 
132
- const CLOUD_SEARCH_DESCRIPTION = [
133
- '用一次自然语言请求同时查询 PRTS.chat 云端的明日方舟与终末地图谱、档案、原文、自建 Wiki 和时间线组合索引;只有用户明确限定游戏时才使用 games 收窄。',
134
- '返回末尾的「## 可读取原文」列出已映射到本地篇章的完整自然语言标题与行号;有同名消歧标记时按 document_uid + line 调用,否则按 title + line。',
135
- ].join(' ')
119
+ const CLOUD_SEARCH_DESCRIPTION = '用自然语言检索 PRTS.chat 云端图谱、档案、原文、Wiki 和时间线。返回的「可读取原文」定位可交给 corpus_read 核验。'
136
120
 
137
- const CLOUD_INSPECT_DESCRIPTION = [
138
- '按 section 和过滤条件读取最近一次云端检索状态(request_id 由运行时自动注入)。',
139
- '用于定点读取回答材料、候选状态或诊断记录,并支持按 next_cursor 分页。',
140
- ].join(' ')
121
+ const CLOUD_INSPECT_DESCRIPTION = '读取最近一次云端检索的回答材料、候选或诊断记录;request_id 由运行时注入。'
141
122
 
142
123
  const TOOL_GAME_LABELS = Object.freeze({ arknights: '明日方舟', endfield: '终末地' })
143
124
 
@@ -186,34 +167,34 @@ const stringList = (description) => ({ type: 'array', items: { type: 'string' },
186
167
  const SEARCH_PARAMETERS = {
187
168
  type: 'object', additionalProperties: false,
188
169
  properties: {
189
- cursor: { type: 'string', description: '仅兼容旧会话:单独提交旧 next_cursor;新搜索使用 after' },
190
- query: { type: 'string', description: '短搜索词:实体名、篇章展示名、活动名或原句片段;不要直接提交整句研究问题' },
170
+ cursor: { type: 'string', description: '旧会话 next_cursor,单独提交;新查询用 after' },
171
+ query: { type: 'string', description: '短实体名、标题或原句片段;完整研究问题用 cloud_search' },
191
172
  resource_types: { type: 'array', items: { type: 'string', enum: RESOURCE_TYPES },
192
- description: '资料类型;character_bundle 可一次查看角色档案、模组、语音和密录' },
173
+ description: '资料类型;character_bundle 聚合角色资料' },
193
174
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
194
- description: '可选游戏过滤;省略时在同一次调用中同时检索明日方舟与终末地' },
175
+ description: '游戏范围;省略时查询全部启用游戏' },
195
176
  content_types: { type: 'array', items: { type: 'string', enum: CONTENT_TYPES },
196
- description: '统一内容形式,例如 dialogue、cutscene、radio、sns_chat;两款游戏使用相同参数' },
197
- collection_names: stringList('上级资料集合展示名;明日方舟活动与终末地任务都使用此字段'),
198
- character_names: stringList('角色展示名,如“凯尔希”'),
199
- story_names: stringList('剧情篇章的展示名,如“晶簇之内”;不要填写内部 story_id 或路径'),
177
+ description: '内容形式' },
178
+ collection_names: stringList('活动或任务等上级集合展示名'),
179
+ character_names: stringList('资料归属角色的展示名'),
180
+ story_names: stringList('剧情篇章展示名'),
200
181
  activity_names: stringList('活动展示名'),
201
182
  wiki_sections: { type: 'array', items: { type: 'string', enum: WIKI_SECTION_VALUES },
202
- description: 'Wiki 标签字段,可与资料类型、角色、活动和 query 组合;角色页常用相关活动/相关角色/剧情高光,活动页常用剧情总结/关键人物/角色剧情概括' },
183
+ description: '限定 Wiki 标签字段' },
203
184
  entity_names: stringList('只返回出现指定实体的行'),
204
- speakers: stringList('结构化说话人展示名,只匹配亲口台词;适合查某人亲口说过什么'),
185
+ speakers: stringList('说话人展示名,仅匹配亲口台词'),
205
186
  match_mode: { type: 'string', enum: ['literal', 'regex'],
206
- description: '默认 literal 连续字面匹配;除非必须,不使用受限 regex' },
187
+ description: '默认 literal 连续字面匹配;regex 为受限正则' },
207
188
  context_terms: { type: 'array', items: { type: 'string' },
208
- description: '要求命中附近同时出现的语境词(最多 8 个)' },
189
+ description: '命中附近须同时出现的语境词,最多 8 个' },
209
190
  after: { type: 'object', additionalProperties: false,
210
- description: '版本绑定的下一页锚点;与原 query 和过滤条件一起原样提交上次返回的 next_after',
191
+ description: '原样复制 page.next_after,保留原 query 和过滤条件;切版后重新搜索',
211
192
  required: ['data_version', 'resource_type', 'title', 'position'], properties: {
212
193
  data_version: { type: 'string',
213
- description: '上一页所用资料包的完整 SHA-256 版本;版本切换后必须重新搜索' },
194
+ description: '上一页的完整 SHA-256 资料版本' },
214
195
  resource_type: { type: 'string', enum: RESOURCE_TYPES },
215
- title: { type: 'string', description: '上一页扫描到的资料自然标题' },
216
- position: { type: 'integer', description: '该标题在当前资料版本中的顺序位置(0..10000000)' },
196
+ title: { type: 'string' },
197
+ position: { type: 'integer' },
217
198
  } },
218
199
  },
219
200
  }
@@ -295,34 +276,34 @@ const SEARCH_OUTPUT_SCHEMA = {
295
276
  const READ_PARAMETERS = {
296
277
  type: 'object',
297
278
  properties: {
298
- title: { type: 'string', description: '未使用其他定位器时,填写资料完整展示标题;不得与 document_uid 同时提交' },
279
+ title: { type: 'string', description: '完整展示标题;与 document_uid 互斥,须配 line、section 或 mode=document' },
299
280
  document_uid: { type: 'string',
300
- description: '仅在搜索结果提示同名歧义时原样复制 doc_ 开头的稳定定位;它替代 title,不得与 title 同时提交;可配合 mode=activity/collection 选择所属合集' },
301
- stage_code: { type: 'string', description: '明日方舟游戏内关卡代号,如 15-17、GT-3、TW-ST-1' },
281
+ description: '搜索返回的同名消歧定位,替代 title;可配合集 mode' },
282
+ stage_code: { type: 'string', description: '明日方舟关卡代号,如 15-17、TW-ST-1' },
302
283
  story_part: { type: 'string', enum: ['before', 'after', 'story'],
303
- description: '关卡存在多篇剧情时用于消歧:before=行动前,after=行动后,story=纯剧情/幕间;单篇关卡可省略' },
284
+ description: '多篇时消歧:before=行动前,after=行动后,story=幕间;单篇可省略' },
304
285
  character_name: { type: 'string', description: '角色展示名;与 record_name 或 material 配合' },
305
- record_name: { type: 'string', description: '明日方舟干员密录名称;多段密录再提供 segment' },
306
- segment: { type: 'integer', description: '干员密录段号,如 1、2' },
286
+ record_name: { type: 'string', description: '干员密录名;多段时加 segment' },
287
+ segment: { type: 'integer', description: '密录段号,从 1 开始' },
307
288
  material: { type: 'string', enum: ['profile', 'module', 'voice', 'skin', 'recruitment', 'potential'],
308
- description: '角色资料类别;profile=档案、module=模组、voice=语音、skin=时装' },
289
+ description: '资料类别:档案/模组/语音/时装/招聘合同/信物' },
309
290
  game: { type: 'string', enum: ['arknights', 'endfield'],
310
- description: '角色资料在双模块同名时用于消歧;其他定位器不要填写' },
311
- activity_name: { type: 'string', description: '明日方舟活动展示名;按活动连续阅读全部剧情' },
312
- collection_name: { type: 'string', description: '终末地任务或剧情集合展示名;跨碎片连续阅读' },
291
+ description: '仅角色资料同名时限定游戏' },
292
+ activity_name: { type: 'string', description: '明日方舟活动展示名,配 mode=activity' },
293
+ collection_name: { type: 'string', description: '终末地任务集合展示名,配 mode=collection' },
313
294
  content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES },
314
- description: '终末地集合可选内容形式过滤;续页时原样保留' },
315
- line: { type: 'integer', description: 'around 的中心官方行号;与 mode=document 同用时表示续读起始行' },
316
- position: { type: 'integer', description: '活动/任务连续阅读的下一位置;只从 page.continuation 原样复制' },
295
+ description: '终末地集合的内容形式过滤;续页保留' },
296
+ line: { type: 'integer', description: '上下文中心行号;mode=document 时为起始行' },
297
+ position: { type: 'integer', description: '合集续读位置,从 page.continuation 复制' },
317
298
  mode: { type: 'string', enum: ['document', 'activity', 'collection'],
318
- description: '单篇全文用 document;活动用 activity;终末地任务集合用 collection。title 不会自动推断,必须配 line、section 或 mode=document;document_uid、关卡、密录和角色资料可自动推断单篇全文' },
299
+ description: 'document=单篇全文,activity=活动,collection=任务集合;document_uid、关卡、密录和角色资料默认单篇全文' },
319
300
  section: { type: 'string', enum: WIKI_SECTION_VALUES, description: '读取 Wiki 标签字段' },
320
- before: { type: 'integer', description: 'around 前文行数,默认 3,上限 100' },
321
- after: { type: 'integer', description: 'around 后文行数,默认 3,上限 100' },
322
- cursor: { type: 'string', description: '仅兼容旧会话中的不透明游标;新调用应原样提交上次结果的 page.continuation' },
323
- data_version: { type: 'string', description: '续页时原样提交 page.continuation.data_version,防止版本切换后混读' },
324
- max_lines: { type: 'integer', description: '最多返回行数,默认 100,上限 500;只限制输出量,不能代替 line、section 或 mode' },
325
- max_chars: { type: 'integer', description: '最多返回字符数,默认 12000,上限 100000;只限制输出量,不能代替 line、section 或 mode' },
301
+ before: { type: 'integer', description: '前文行数,默认 3,上限 100' },
302
+ after: { type: 'integer', description: '后文行数,默认 3,上限 100' },
303
+ cursor: { type: 'string', description: '仅兼容旧会话游标' },
304
+ data_version: { type: 'string', description: '续页复制 page.continuation.data_version' },
305
+ max_lines: { type: 'integer', description: '输出行数上限,默认 100,最大 500' },
306
+ max_chars: { type: 'integer', description: '输出字符上限,默认 12000,最大 100000' },
326
307
  },
327
308
  additionalProperties: false,
328
309
  }
@@ -473,12 +454,12 @@ function readPresentationMeta(_args, value) {
473
454
  const TIMELINE_PARAMETERS = {
474
455
  type: 'object', additionalProperties: false,
475
456
  properties: {
476
- query: { type: 'string', description: '可选的事件正文短语(≤200 字符)。人物应优先放入 entity_names 以自动展开别名' },
477
- activity_names: { type: 'array', items: { type: 'string' }, description: '活动展示名,例如“孤星”(≤20 项)' },
478
- entity_names: { type: 'array', items: { type: 'string' }, description: '角色或实体展示名;工具会用别名图鉴自动裂变后检索(≤20 项)' },
479
- year_start: { type: 'integer', description: '起始年份(含);可单独使用' },
480
- year_end: { type: 'integer', description: '结束年份(含);可单独使用' },
481
- source_marker: { type: 'string', description: '反查模式:原样复制时间线结果方括号内的年表出处标记(年表出处:tle_ 开头)' },
457
+ query: { type: 'string', description: '事件正文短语,最多 200 字符' },
458
+ activity_names: { type: 'array', items: { type: 'string' }, description: '活动展示名,最多 20 项' },
459
+ entity_names: { type: 'array', items: { type: 'string' }, description: '实体展示名,自动展开别名,最多 20 项' },
460
+ year_start: { type: 'integer', description: '起始年份,含边界' },
461
+ year_end: { type: 'integer', description: '结束年份,含边界' },
462
+ source_marker: { type: 'string', description: '反查来源时复制返回的年表出处标记' },
482
463
  max_results: { type: 'integer', description: '最多返回事件数,默认 20,上限 100' },
483
464
  },
484
465
  }
@@ -486,20 +467,19 @@ const TIMELINE_PARAMETERS = {
486
467
  const CLOUD_SEARCH_PARAMETERS = {
487
468
  type: 'object', additionalProperties: false, required: ['query'],
488
469
  properties: {
489
- query: { type: 'string', description: '改写为语义完整、适合向量检索的自然语言' },
470
+ query: { type: 'string', description: '包含主体、事件和所求关系的完整问题' },
490
471
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
491
- description: '可选游戏范围;省略时同一次调用同时检索明日方舟与终末地' },
472
+ description: '游戏范围;省略时查询全部启用游戏' },
492
473
  depth: { type: 'string', enum: ['fast', 'standard', 'deep'],
493
- description: '兼容字段;不会改变云端主站的检索路由。回答深度由 Agent 自己的运行模式控制,通常省略' },
474
+ description: '兼容字段,不改变检索路线;通常省略' },
494
475
  evidence_policy: { type: 'string', enum: ['mixed', 'original_only'],
495
- description: 'mixed=完整复用 PRTS.chat 主站检索、审核与 Cleaner;original_only=只运行官方剧情原文向量路线' },
476
+ description: '默认 mixed 混合检索;original_only 仅官方剧情原文' },
496
477
  options: {
497
478
  type: 'object', additionalProperties: false,
498
- description: '通常省略。只有用户记得一句原文大意且措辞可能不准时,选择官方剧情单句向量路线',
499
479
  properties: {
500
480
  search_intent: {
501
481
  type: 'string', enum: ['single_sentence_search'],
502
- description: '只检索官方剧情单句向量表,并执行原有 LLM 验证;其他问题使用默认主站路线',
482
+ description: '仅用户记得一句官方原文大意、措辞不准时使用',
503
483
  },
504
484
  },
505
485
  },
@@ -508,13 +488,12 @@ const CLOUD_SEARCH_PARAMETERS = {
508
488
 
509
489
  const CLOUD_INSPECT_PARAMETERS = {
510
490
  type: 'object', additionalProperties: false,
511
- description: '查看最近一次云端检索的指定区段;request_id 由运行时自动注入',
512
491
  properties: {
513
492
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
514
- description: '只查看指定游戏的候选、来源或事件;省略时查看联合状态' },
493
+ description: '游戏过滤;省略时查看全部启用游戏' },
515
494
  section: { type: 'string', enum: ['summary', 'candidates', 'selected_sources', 'events', 'trace_steps', 'answer_context'],
516
495
  description: '默认 summary' },
517
- cursor: { type: 'integer', description: '分页游标:原样复制上次返回的 next_cursor' },
496
+ cursor: { type: 'integer', description: '复制返回的 next_cursor' },
518
497
  limit: { type: 'integer', description: '本页条数' },
519
498
  channels: stringList('按渠道过滤(candidates/events)'),
520
499
  query_variants: stringList('按查询变体过滤'),
@@ -529,7 +508,7 @@ const CLOUD_INSPECT_PARAMETERS = {
529
508
  event_time_from: { type: 'string', description: '事件时间下界' },
530
509
  event_time_to: { type: 'string', description: '事件时间上界' },
531
510
  content_mode: { type: 'string', enum: ['none', 'preview', 'full'],
532
- description: 'none 只取结构字段;preview 返回受限正文;full 返回该页完整正文' },
511
+ description: 'none=无正文,preview=摘要,full=本页全文' },
533
512
  content_max_chars: { type: 'integer', description: '正文字符上限' },
534
513
  },
535
514
  }
@@ -867,7 +846,7 @@ function renderCloudInspect(_args, value) {
867
846
  * @param {{
868
847
  * releasesDir?: string,
869
848
  * cacheShards?: number,
870
- * uiSkin?: 'harness' | 'prts-agent',
849
+ * uiSkin?: 'harness' | 'prts-agent' | 'endfield-aic' | 'rhine-lab',
871
850
  * download?: { order?: ('modelscope'|'site')[], siteBaseUrl?: string },
872
851
  * cloud?: { baseUrl?: string, game?: 'arknights' | 'endfield', userId?: string, token?: string, timeoutMs?: number, maxResponseBytes?: number },
873
852
  * }} [config]
@@ -875,7 +854,7 @@ function renderCloudInspect(_args, value) {
875
854
  export async function apply(ctx, config = {}) {
876
855
  // Cordis 传空配置时可能是 null 而非 undefined,默认参数不生效,需兜底
877
856
  config = config ?? {}
878
- // 工具开关:host 常驻时只做资料管理(registerTools:false),PRTS 预设(模式)加载时才注册工具(true)
857
+ // Host 常驻实例只做资料管理;PRTS 或自定义 Agent 预设按需注册工具。
879
858
  const enableTools = config.registerTools !== false
880
859
  const configuredHome = process.env.DSH_HOME?.trim()
881
860
  const dshHome = resolve(configuredHome || join(homedir(), '.dsh'))
@@ -1216,9 +1195,12 @@ export async function apply(ctx, config = {}) {
1216
1195
  const handle = acquireInvestigationStore(investigationCtx.storageDomain)
1217
1196
  shared.investigations = handle.service
1218
1197
  investigationCtx.effect(() => () => { if (shared.investigations === handle.service) shared.investigations = null; return handle.release() }, 'prts: investigations')
1219
- if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service))
1198
+ if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service, shared))
1199
+ })
1200
+ if (enableTools) await ctx.inject(['tools', 'systemPrompt'], async toolCtx => {
1201
+ mountWebGuidance(toolCtx)
1202
+ await mountTools(toolCtx)
1220
1203
  })
1221
- if (enableTools) await ctx.inject(['tools', 'systemPrompt'], mountTools)
1222
1204
 
1223
1205
  if (enableTools) {
1224
1206
  await mkdir(releasesDir, { recursive: true })
@@ -1,7 +1,7 @@
1
1
  /** Shared read model for Agent recall; browser reads never acknowledge these notices. */
2
2
  const copy = value => structuredClone(value)
3
3
  const keyOf = ({ area, board_id = '', item_id = '' }) => JSON.stringify([area, board_id, item_id])
4
- const metadata = ({ content, ...source }) => copy(source)
4
+ const metadata = ({ content, fragments, ...source }) => copy(source)
5
5
  const fail = message => { throw Object.assign(new Error(message), { code: 'INVESTIGATION_NOT_FOUND' }) }
6
6
 
7
7
  export function noteUserChange(portfolio, change) {
@@ -11,9 +11,15 @@ export function noteUserChange(portfolio, change) {
11
11
  else portfolio.attention[index] = item
12
12
  }
13
13
 
14
+ // Old tool receipts predate the explicit flag. Browser imports use reserved call IDs.
15
+ export function receivedByAgent(source) {
16
+ return source.agentReceived ?? Boolean(source.agentRead || source.state === 'read'
17
+ || source.callId && !['user', 'user-rack', 'user-inbox', 'legacy-user'].includes(source.callId))
18
+ }
19
+
14
20
  export function reviewSummary(p) {
15
21
  const pending = p?.attention || []
16
- return { pending_count: pending.length, rack_count: p?.sources.filter(s => s.agentRead || p.rack.some(r => r.source_id === s.id)).length || 0,
22
+ return { pending_count: pending.length, rack_count: p?.sources.filter(s => receivedByAgent(s) || p.rack.some(r => r.source_id === s.id)).length || 0,
17
23
  user_saved_count: p?.rack.length || 0, changes: pending.slice(-20).map(copy),
18
24
  ...(pending.length > 20 ? { more: 'investigation_get(section="changes") 可分页读取其余提醒' } : {}) }
19
25
  }
@@ -33,19 +39,20 @@ export function inspectPortfolio(p, args, boardSummary) {
33
39
  }
34
40
  if (section === 'rack' || section === 'sources') {
35
41
  const saved = new Map(p.rack.map(item => [item.source_id, item]))
36
- const rows = [...p.sources].reverse().filter(source => section === 'sources' || (saved_only ? saved.has(source.id) : source.agentRead || saved.has(source.id)))
42
+ // Rack pages follow first arrival: new receipts append instead of shifting page offsets.
43
+ const rows = (section === 'sources' ? [...p.sources].reverse() : p.sources).filter(source => section === 'sources' || (saved_only ? saved.has(source.id) : receivedByAgent(source) || saved.has(source.id)))
37
44
  .map(source => ({ ...metadata(source), saved: saved.has(source.id), ...(saved.has(source.id) ? { saved_at: saved.get(source.id).addedAt } : {}) }))
38
45
  .filter(matches)
39
46
  const result = page(rows), ids = new Set(result.items.map(item => item.id))
40
47
  return respond({ total: result.total, nextCursor: result.nextCursor, sources: result.items, rack_revision: p.rackRevision, ...reviewSummary(p),
41
- guidance: '来源目录包含摘要和定位信息;用 section="source", source_id 读取已保存正文。agentRead=false 的用户资料仍需读取原文核验。' },
48
+ guidance: '档案架保留已交付给 Agent 的资料与手动收藏;入架不代表已读原文。目录包含摘要和定位信息,用 section="source", source_id 读取已保存正文。agentRead=false 的资料仍需原文核验。' },
42
49
  item => section === 'rack' && item.area === 'rack' && ids.has(item.item_id))
43
50
  }
44
51
  if (section === 'source') {
45
52
  const source = p.sources.find(s => s.id === source_id || s.sourceId === source_id)
46
53
  if (!source) fail('来源不存在;先读取 rack 或 sources 取得 source_id')
47
54
  return respond({ source: copy(source), saved: p.rack.some(item => item.source_id === source.id),
48
- guidance: source.agentRead ? '这是已保存的工具返回正文;contentTruncated=true 时需要继续读取原文。' : '这是用户选入的材料,不代表 Agent 已核验原文;按 documentId/sourceRef/url 调用读取工具核验。' },
55
+ guidance: source.agentRead ? 'fragments 按阅读片段保留正文和行范围;content 为最后一次阅读。contentTruncated=true 时需要继续读取原文。' : '此资料尚未经过 Agent 原文核验;按 documentId/sourceRef/url 调用读取工具核验。' },
49
56
  item => item.area === 'rack' && item.item_id === source.id)
50
57
  }
51
58
  if (!['board', 'clues', 'report', 'inbox'].includes(section)) fail('未知调查资料 section')
@@ -66,7 +73,8 @@ export function inspectPortfolio(p, args, boardSummary) {
66
73
  if (section === 'report') {
67
74
  const report = report_version === undefined ? board.reports.at(-1) : board.reports.find(r => r.version === report_version)
68
75
  if (!report) fail('指定的调查报告尚不存在')
69
- return respond({ ...identity, report: { ...copy(report), sources: report.sources.map(metadata) } })
76
+ const { boardSnapshot, ...published } = report
77
+ return respond({ ...identity, report: { ...copy(published), sources: report.sources.map(metadata) } })
70
78
  }
71
79
  if (section === 'clues') {
72
80
  const result = page(board.clues.filter(c => (!clue_id || c.id === clue_id) && matches(c)))
@@ -1,7 +1,8 @@
1
1
  /** Durable investigation portfolios, shared by Host UI and agent preset instances. */
2
2
  import { createHash, randomUUID } from 'node:crypto'
3
- import { z } from 'zod'
4
- import { inspectPortfolio, noteUserChange, reviewSummary } from './investigation-review.js'
3
+ import { z } from '../lib/runtime/zod.js'
4
+ import { inspectPortfolio, noteUserChange, reviewSummary, receivedByAgent } from './investigation-review.js'
5
+ import { originalQuotation, sourceRecovery } from './investigation-validation.js'
5
6
 
6
7
  const id = z.string().min(1).max(512)
7
8
  const short = z.string().max(512)
@@ -10,8 +11,10 @@ const range = z.object({ start: z.number().int().positive(), end: z.number().int
10
11
  export const clueKinds = ['excerpt', 'finding', 'time', 'relation', 'question', 'contrast']
11
12
  const sourceSchema = z.object({
12
13
  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
+ state: z.enum(['found', 'read']), agentRead: z.boolean(), agentReceived: z.boolean().optional(), excerpt: z.string().max(4000),
14
15
  content: z.string().max(64000).optional(), contentTruncated: z.boolean().optional(),
16
+ // Only trusted tool receipts populate these fragments; callers cannot import trust.
17
+ fragments: z.array(z.object({ content: z.string().max(64000), ranges: z.array(range).max(256), callId: short })).max(256).default([]),
15
18
  documentId: z.string().max(2048).optional(), documentUid: z.string().max(512).optional(),
16
19
  sourceRef: z.string().max(2048).optional(), dataVersion: z.string().max(128).optional(),
17
20
  url: z.string().max(4096).optional(), lineStart: z.number().int().positive().optional(),
@@ -31,18 +34,20 @@ export const clueSchema = z.object({
31
34
  userLayoutLocked: z.boolean(), mergedInto: id.optional(),
32
35
  })
33
36
  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
37
  const inboxEntrySchema = z.object({ source_id: id, note: z.string().max(600),
38
38
  status: z.enum(['pending', 'promoted', 'removed']), addedBy: z.enum(['agent', 'user']),
39
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),
40
+ const boardStateSchema = z.object({ id, title: z.string().min(1).max(120), objective: z.string().min(1).max(2000),
41
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),
42
+ clues: z.array(clueSchema).max(512), relations: z.array(relationSchema).max(1024),
43
43
  evidenceInbox: z.array(inboxEntrySchema).max(512).default([]), inboxRevision: z.number().int().nonnegative().default(0),
44
44
  openQuestions: z.array(z.string().max(500)).max(32), nextClue: z.number().int().positive(),
45
45
  })
46
+ const reportSchema = z.object({ id, version: z.number().int().positive(), title: z.string().min(1).max(180),
47
+ summary: z.string().max(1000), markdown: z.string().min(1).max(120000), publishedAt: z.string(), runId: id,
48
+ basisKnowledgeRevision: z.number().int(), clues: z.array(clueSchema).max(128), sources: z.array(sourceSchema).max(4096),
49
+ boardSnapshot: boardStateSchema.extend({ sources: z.array(sourceSchema).max(4096) }).optional() })
50
+ const boardSchema = boardStateSchema.extend({ reports: z.array(reportSchema).max(128) })
46
51
  const runSchema = z.object({ id, boardId: id, turnId: short, reason: z.string().max(500), startedAt: z.string(),
47
52
  status: z.enum(['running', 'completed', 'interrupted', 'error']), endedAt: z.string().optional() })
48
53
  export const portfolioSchema = z.object({ schemaVersion: z.literal(1), sessionId: id, commitSeq: z.number().int().nonnegative(),
@@ -75,7 +80,7 @@ function requireRun(portfolio, boardId, runId, turnId) {
75
80
  requireValue(run?.boardId === boardId && run.status === 'running' && run.turnId === String(turnId ?? ''), '本次运行未绑定到这块调查板,或已经结束;请重新选择调查', 'INVESTIGATION_RUN_MISMATCH')
76
81
  return run
77
82
  }
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 }
83
+ function sourceIdentity(source) { return source.documentUid || source.documentId ? JSON.stringify([source.dataVersion || '', source.documentUid || source.documentId]) : source.url ? `url:${source.url.replace(/#.*$/, '')}` : `id:${JSON.stringify([source.dataVersion || '', source.sourceId || source.id])}` }
79
84
  function uniqueRanges(values) {
80
85
  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
86
  }
@@ -84,7 +89,7 @@ function normalizeSource(source, alias, callId, trusted) {
84
89
  const body = bounded(source.content, 64000)
85
90
  return sourceSchema.parse({ id: alias, sourceId: bounded(source.sourceId || source.id, 512), title: bounded(source.title),
86
91
  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 } : {}),
92
+ state: read ? 'read' : 'found', agentRead: read, agentReceived: trusted, excerpt: bounded(source.excerpt, 4000), ...(body ? { content: body } : {}),
88
93
  contentTruncated: !!source.contentTruncated || typeof source.content === 'string' && source.content.length > 64000,
89
94
  ...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
95
  ...(safeUrl(source.url) ? { url: safeUrl(source.url) } : {}),
@@ -94,19 +99,40 @@ function normalizeSource(source, alias, callId, trusted) {
94
99
  callId: bounded(callId), updatedAt: now(), contentHash: hash(body || bounded(source.excerpt, 4000)),
95
100
  })
96
101
  }
102
+ function readingFragments(source) {
103
+ if (!source?.agentRead) return []
104
+ if (source.fragments?.length) return source.fragments
105
+ const content = source.content || source.excerpt
106
+ if (!content) return []
107
+ // A legacy body belongs to its last read page, not to the union of past ranges.
108
+ const ranges = source.lineStart ? [{ start: source.lineStart, end: source.lineEnd || source.lineStart }]
109
+ : source.ranges?.length === 1 ? source.ranges : []
110
+ return [{ content, ranges, callId: source.callId }]
111
+ }
112
+ function mergeReadingFragments(previous, item) {
113
+ const fragments = [...new Map([...readingFragments(previous), ...readingFragments(item)]
114
+ .map(f => [hash([f.content, f.ranges]), f])).values()]
115
+ requireValue(fragments.length <= 256 && fragments.reduce((n, f) => n + f.content.length, 0) <= 512000,
116
+ '这份资料的已读片段已达保存上限;已有片段保留,本次阅读尚未保存', 'INVESTIGATION_CAPACITY')
117
+ return fragments
118
+ }
119
+ function sourceMetadata({ content, fragments, ...source }) { return clone(source) }
97
120
  function ingestSources(portfolio, incoming, callId, trusted) {
98
121
  const aliases = {}
99
122
  for (const raw of incoming.slice(0, 128)) {
100
123
  if (!raw?.title || !(raw.id || raw.sourceId)) continue
101
124
  const identity = sourceIdentity(raw)
102
- const previous = portfolio.sources.find(source => sourceIdentity(source) === identity || source.sourceId === (raw.sourceId || raw.id))
125
+ const previous = portfolio.sources.find(source => sourceIdentity(source) === identity
126
+ || !raw.documentUid && !raw.documentId && !raw.url && source.dataVersion === raw.dataVersion
127
+ && source.sourceId === (raw.sourceId || raw.id))
103
128
  if (!previous) requireValue(portfolio.sources.length < 4096, '当前会话来源记录已满;已保存的资料不会删除', 'INVESTIGATION_CAPACITY')
104
129
  const item = normalizeSource(raw, previous?.id || `R${String(portfolio.nextSource++).padStart(4, '0')}`, callId, trusted)
130
+ item.fragments = mergeReadingFragments(previous, item)
105
131
  aliases[raw.sourceId || raw.id] = item.id
106
132
  if (previous) {
107
133
  // A later search hit must not turn a read source back into a summary.
108
134
  const preferOld = previous.agentRead && !item.agentRead
109
- Object.assign(previous, { ...item, ...preferOld ? { state: previous.state, agentRead: true,
135
+ Object.assign(previous, { ...item, agentReceived: receivedByAgent(previous) || item.agentReceived, ...preferOld ? { state: previous.state, agentRead: true,
110
136
  excerpt: previous.excerpt, content: previous.content, contentTruncated: previous.contentTruncated, contentHash: previous.contentHash,
111
137
  callId: previous.callId } : {}, ranges: uniqueRanges([...previous.ranges, ...item.ranges]) })
112
138
  } else portfolio.sources.push(item)
@@ -117,19 +143,37 @@ function resolveReferences(portfolio, incoming) {
117
143
  requireValue(Array.isArray(incoming) && incoming.length <= 32, 'sources 必须是最多 32 项的来源引用列表')
118
144
  return incoming.map(raw => {
119
145
  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)
146
+ const direct = portfolio.sources.find(row => row.id === ref.source_id)
147
+ const aliases = direct ? [direct] : portfolio.sources.filter(row => row.sourceId === ref.source_id || row.documentUid === ref.source_id)
148
+ requireValue(aliases.length <= 1, `来源 ${ref.source_id} 对应多个数据版本,请使用来源目录中的 R 编号`, 'INVESTIGATION_SOURCE_AMBIGUOUS')
149
+ const source = aliases[0]
121
150
  requireValue(source, `来源 ${ref.source_id} 未在当前会话返回;用 investigation_get 的 sources 部分查看有效 ID`, 'INVESTIGATION_SOURCE_MISSING')
122
151
  if (ref.line_start || ref.line_end) {
123
152
  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')
153
+ if (!source.agentRead || !source.ranges.some(r => r.start <= ref.line_start && r.end >= ref.line_end)) {
154
+ throw investigationError('INVESTIGATION_SOURCE_UNREAD', '引用的行范围不在实际已读范围内', sourceRecovery(source, ref))
155
+ }
156
+ }
157
+ if (ref.quote) {
158
+ const fragments = readingFragments(source)
159
+ const bodies = fragments.length ? fragments.filter(f => !ref.line_start
160
+ || f.ranges.some(r => r.start <= ref.line_start && r.end >= ref.line_end)).map(f => f.content)
161
+ : [source.content || source.excerpt]
162
+ let original = null
163
+ for (const body of bodies) {
164
+ original = originalQuotation(body, ref.quote)
165
+ if (original !== null) break
166
+ }
167
+ if (original === null) throw investigationError('INVESTIGATION_QUOTE_MISMATCH',
168
+ `来源 ${source.id} 的摘录不在保存的对应片段内;请读取对应原文或修正引文`, sourceRecovery(source, ref))
169
+ ref.quote = original
125
170
  }
126
- if (ref.quote) requireValue((source.content || source.excerpt).includes(ref.quote), '摘录不在保存的返回片段内;请先读取对应原文或去掉直接引文', 'INVESTIGATION_QUOTE_MISMATCH')
127
171
  return { ...ref, source_id: source.id }
128
172
  })
129
173
  }
130
174
  function cluePatch(portfolio, board, change, actor, mapping) {
131
175
  const previous = change.id ? board.clues.find(row => row.id === change.id) : undefined
132
- requireValue(!change.id || previous, `线索 ${change.id} 不存在`, 'INVESTIGATION_CLUE_MISSING')
176
+ requireValue(!change.id || previous, `线索 ${change.id} 不存在;新建请省略 id、使用 client_key,后续更新使用 returned created_ids`, 'INVESTIGATION_CLUE_MISSING')
133
177
  const action = change.action || 'upsert'
134
178
  if (action === 'layout') {
135
179
  requireValue(previous, '布局修改需要已有线索')
@@ -236,9 +280,19 @@ export function createInvestigationStore(storageDomain) {
236
280
  tables: { portfolios: { valueSchema: portfolioSchema } } })
237
281
  const table = domain.table('portfolios')
238
282
  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)
283
+ // Upgrade provenance without discarding old tool deliveries or trusting browser imports.
284
+ // Receipt aliases also recover a found source later edited from the browser.
285
+ if (table.get('state').sources.some(source => source.agentReceived === undefined)
286
+ || table.get('state').runs.some(run => run.status === 'running')) await table.update('state', current => {
287
+ const next = portfolioSchema.parse(clone(current))
288
+ const delivered = new Set(Object.values(next.mutations).flatMap(({ result }) =>
289
+ result?.sources && !Array.isArray(result.sources) ? Object.values(result.sources) : []))
290
+ let upgraded = false
291
+ for (const source of next.sources) if (source.agentReceived === undefined) {
292
+ source.agentReceived = delivered.has(source.id) || receivedByAgent(source); upgraded = true
293
+ }
294
+ if (upgraded) next.rackRevision++
295
+ // No live run survives a process/service restart. Preserve its partial work.
242
296
  for (const run of next.runs) if (run.status === 'running') { run.status = 'interrupted'; run.endedAt = now() }
243
297
  next.commitSeq++; return next
244
298
  })
@@ -292,12 +346,12 @@ export function createInvestigationStore(storageDomain) {
292
346
  const offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
293
347
  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
348
  }
295
- if (section === 'sources') return { commitSeq: p.commitSeq, sources: p.sources.slice(-Math.min(128, Number(limit) || 40)).map(({ content, ...source }) => clone(source)) }
349
+ if (section === 'sources') return { commitSeq: p.commitSeq, sources: p.sources.slice(-Math.min(128, Number(limit) || 40)).map(sourceMetadata) }
296
350
  const board = boardOf(p, board_id)
297
351
  if (section === 'inbox') {
298
352
  const items = pendingEvidence(board), offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
299
353
  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) } }),
354
+ items: items.slice(offset, offset + count).map(item => { const source = p.sources.find(s => s.id === item.source_id); return { ...clone(item), source: sourceMetadata(source) } }),
301
355
  nextCursor: items.length > offset + count ? offset + count : null }
302
356
  }
303
357
  return { commitSeq: p.commitSeq, workingBoardId: working?.boardId || null, ...boardSummary(board, p), board: clone(board),
@@ -375,7 +429,18 @@ export function createInvestigationStore(storageDomain) {
375
429
  requireValue(board.knowledgeRevision === args.expected_revision, '调查内容已更新,请用 investigation_get 重读后提交', 'INVESTIGATION_CONFLICT')
376
430
  requireValue(Array.isArray(args.clues || []) && (args.clues || []).length <= 20, '每次更新最多 20 条线索')
377
431
  const mapping = {}, updated = []
378
- for (const change of args.clues || []) updated.push(cluePatch(p, board, change, 'agent', mapping).id)
432
+ const issues = []
433
+ for (const [index, change] of (args.clues || []).entries()) {
434
+ try { updated.push(cluePatch(p, board, change, 'agent', mapping).id) }
435
+ catch (error) {
436
+ if (!error.code && !(error instanceof z.ZodError)) throw error
437
+ issues.push({ clue_index: index, clue: change.id || change.client_key || change.title,
438
+ code: error.code || 'INVALID_INVESTIGATION', message: error.message, ...error.details })
439
+ }
440
+ }
441
+ if (issues.length) throw investigationError(issues[0].code,
442
+ `本批更新未保存,revision 仍为 ${args.expected_revision}。修正以下线索后重试;不要重复提交未修正的整批正文。\n${JSON.stringify({ issues })}`,
443
+ { applied: false, revision: args.expected_revision, issues })
379
444
  const resolveId = value => mapping[value] || value
380
445
  requireValue(Array.isArray(args.relations || []) && (args.relations || []).length <= 32, '每次更新最多 32 条关系')
381
446
  for (const raw of args.relations || []) {
@@ -445,10 +510,13 @@ export function createInvestigationStore(storageDomain) {
445
510
  requireValue(board.knowledgeRevision === args.expected_revision, '有新线索尚未纳入报告,请重读调查后发布', 'INVESTIGATION_CONFLICT')
446
511
  requireValue(Array.isArray(args.clue_ids) && args.clue_ids.length <= 128, 'clue_ids 必须列出报告使用的线索')
447
512
  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 })
513
+ const sources = p.sources.filter(s => cited.some(c => c.sources.some(ref => ref.source_id === s.id))).map(sourceMetadata)
514
+ const boardSnapshot = boardStateSchema.parse(clone(board))
515
+ boardSnapshot.sources = p.sources.filter(s => board.clues.some(c => c.sources.some(ref => ref.source_id === s.id))
516
+ || board.evidenceInbox.some(item => item.source_id === s.id)).map(sourceMetadata)
449
517
  const report = reportSchema.parse({ id: `report-${randomUUID()}`, version: board.reports.length + 1, title: args.title,
450
518
  summary: args.summary, markdown: args.markdown, publishedAt: now(), runId: args.run_id,
451
- basisKnowledgeRevision: board.knowledgeRevision, clues: cited, sources })
519
+ basisKnowledgeRevision: board.knowledgeRevision, clues: cited, sources, boardSnapshot })
452
520
  board.reports.push(report); board.updatedAt = now()
453
521
  return { board_id: board.id, report_id: report.id, version: report.version, title: report.title, revision: board.knowledgeRevision,
454
522
  message: '调查报告已保存到线索板中央。向用户说明结果即可,不必再次生成一份不同的全文。' }
@@ -533,7 +601,11 @@ export function createInvestigationStore(storageDomain) {
533
601
  },
534
602
  recordSources(sessionId, sources, callId) {
535
603
  if (!sources.length) return Promise.resolve()
536
- const pending = mutate(sessionId, `sources:${callId}`, sources, p => ({ sources: ingestSources(p, sources, callId, true) }))
604
+ const pending = mutate(sessionId, `sources:${callId}`, sources, p => {
605
+ const aliases = ingestSources(p, sources, callId, true)
606
+ if (Object.keys(aliases).length) p.rackRevision++
607
+ return { sources: aliases }
608
+ })
537
609
  const previous = pendingReceipts.get(sessionId) || Promise.resolve()
538
610
  const settled = Promise.allSettled([previous, pending]).then(() => {})
539
611
  pendingReceipts.set(sessionId, settled)