prts-terrarchive 0.2.2 → 0.2.3

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 (40) hide show
  1. package/README.en.md +52 -18
  2. package/README.md +49 -16
  3. package/bin/build-mainline-catalog.py +195 -0
  4. package/contracts/corpus_i18n_v1.schema.json +1 -3
  5. package/contracts/corpus_tools_v1.schema.json +30 -1
  6. package/docs/endfield-mainline-navigation.md +45 -0
  7. package/lib/client.js +154 -27
  8. package/lib/skins/common.css +12 -2
  9. package/lib/skins/endfield-aic.css +1 -1
  10. package/lib/skins/prts-agent.css +9 -7
  11. package/package.json +7 -3
  12. package/resources/endfield-mainline.json +4632 -0
  13. package/skills/prts-investigation/SKILL.md +1 -1
  14. package/skills/prts-investigation/references/board-guide.md +1 -1
  15. package/skills/prts-retrieval/SKILL.md +2 -0
  16. package/skills/prts-retrieval/references/cloud-tools.md +3 -1
  17. package/skills/prts-retrieval/references/local-tools.md +3 -3
  18. package/skills/prts-retrieval/references/module-arknights.md +1 -1
  19. package/skills/prts-retrieval/references/module-dual.md +1 -1
  20. package/skills/prts-retrieval/references/module-endfield.md +3 -0
  21. package/skills/prts-retrieval/references/tools-runtime.md +22 -10
  22. package/src/cloud-projection.js +23 -2
  23. package/src/cloud.js +24 -0
  24. package/src/entity-recognizer.js +26 -5
  25. package/src/entity-routing.js +18 -4
  26. package/src/evidence-state.js +4 -2
  27. package/src/i18n.js +70 -15
  28. package/src/index.js +304 -98
  29. package/src/installer.js +86 -14
  30. package/src/investigation-tools.js +7 -4
  31. package/src/navigation.js +342 -0
  32. package/src/read-args.js +86 -0
  33. package/src/read.js +66 -15
  34. package/src/search-projection.js +154 -19
  35. package/src/search.js +177 -21
  36. package/src/store.js +127 -3
  37. package/src/timeline.js +86 -2
  38. package/src/tool-args.js +53 -0
  39. package/src/ui.js +158 -34
  40. package/src/web-guidance.js +14 -2
package/src/index.js CHANGED
@@ -30,17 +30,21 @@ import { createHash } from 'node:crypto'
30
30
  import { mkdir, realpath } from 'node:fs/promises'
31
31
  import { assertCorpusVersion, corpusVersionSnapshot,
32
32
  CorpusStore, documentGame, documentUid, naturalDocumentTitle, publicStoryStageCode,
33
- publicStoryPart } from './store.js'
33
+ publicStoryPart, DATA_VERSION_REF_PATTERN, dataVersionMatches } from './store.js'
34
34
  import { readCurrentReleasePointer } from './installer.js'
35
35
  import { watchCurrentRelease } from './release-watch.js'
36
36
  import { END_FIELD_STORY_CONTENT_TYPES, executeRead, readContractFromCursor,
37
37
  normalizeReadRequest, projectReadPublic, renderRead } from './read.js'
38
38
  import { executeSearch, renderSearch } from './search.js'
39
+ import { executeNavigation, NAVIGATION_DESCRIPTION, NAVIGATION_PARAMETERS, renderNavigation } from './navigation.js'
39
40
  import { executeI18n, I18N_DESCRIPTION, I18N_PARAMETERS, I18N_OUTPUT_SCHEMA, renderI18n } from './i18n.js'
40
41
  import { executeTimelineSearch, renderTimeline } from './timeline.js'
41
42
  import { AnonymousSessionProvider, CloudRetrievalClient, StaticTokenProvider,
42
43
  createAgentCloudClientRegistry,
43
- cloudErrorResponse, readOrCreateClientId } from './cloud.js'
44
+ cloudFaultError, readOrCreateClientId } from './cloud.js'
45
+ import { dropEmptyPlaceholders } from './tool-args.js'
46
+ import { formatCitation, normalizeReadArguments, parseCitation, readTargets,
47
+ readTargetError, withReadGuidance } from './read-args.js'
44
48
  import { createSharedState } from './state.js'
45
49
  import { applyUi } from './ui.js'
46
50
  import { acquireInvestigationStore } from './investigation-store.js'
@@ -66,7 +70,6 @@ const storesByDirectory = new Map()
66
70
 
67
71
  const LOCAL_CORPUS_MISSING_MESSAGE =
68
72
  '本地数据包暂未安装,请提醒用户前往“设置 → 插件 → PRTS 语料”安装。'
69
- const DATA_VERSION_PATTERN = /^[0-9a-f]{64}$/
70
73
 
71
74
  function sameFilesystemPath(left, right) {
72
75
  return process.platform === 'win32'
@@ -110,9 +113,9 @@ async function requireLocalCorpus(store) {
110
113
  }
111
114
  }
112
115
 
113
- const READ_DESCRIPTION = '读取 PRTS.chat 本地资料,支持行号上下文、Wiki 字段和单篇/合集连续阅读。只选一种主定位方式;续页原样提交 page.continuation。'
116
+ const READ_DESCRIPTION = '读取 PRTS.chat 本地资料,支持行号上下文、Wiki 字段和单篇/合集连续阅读。只选一种主定位方式;续页原样提交 page.continuation。最小 UID 全文调用:{"document_uid":"检索返回的 doc_ 定位","mode":"document"};不要填写不适用的附属参数。'
114
117
 
115
- const SEARCH_DESCRIPTION = '搜索 PRTS.chat 本地语料,返回按文档归并的命中行及上下各一行;省略 query 可列目录。过滤数组内 OR、字段间 AND。'
118
+ const SEARCH_DESCRIPTION = '搜索 PRTS.chat 本地语料,返回按文档归并的命中行及上下各一行;省略 query 可列目录。过滤数组内 OR、字段间 AND。终末地版本、最新主线与章节顺序先用 corpus_navigate 定位。'
116
119
 
117
120
  const TIMELINE_DESCRIPTION = '检索明日方舟活动时间线(PRTS Wiki《泰拉年表》),返回年份、事件与可反查的出处标记。'
118
121
 
@@ -177,16 +180,16 @@ const SEARCH_PARAMETERS = {
177
180
  description: '内容形式' },
178
181
  collection_names: stringList('活动或任务等上级集合展示名'),
179
182
  character_names: stringList('资料归属角色的展示名'),
180
- story_names: stringList('剧情篇章展示名'),
181
- activity_names: stringList('活动展示名'),
183
+ story_names: stringList('单关/单篇剧情名,如“鳞的记忆”;活动名不要填这里,改用 activity_names'),
184
+ activity_names: stringList('活动或主线章节名,如“昨日海”'),
182
185
  wiki_sections: { type: 'array', items: { type: 'string', enum: WIKI_SECTION_VALUES },
183
186
  description: '限定 Wiki 标签字段' },
184
187
  entity_names: stringList('只返回出现指定实体的行'),
185
- speakers: stringList('说话人展示名,仅匹配亲口台词'),
188
+ speakers: stringList('说话人展示名,仅匹配亲口台词;自动展开已登记别名'),
186
189
  match_mode: { type: 'string', enum: ['literal', 'regex'],
187
190
  description: '默认 literal 连续字面匹配;regex 为受限正则' },
188
191
  context_terms: { type: 'array', items: { type: 'string' },
189
- description: '命中附近须同时出现的语境词,最多 8 个' },
192
+ description: '强约束:每个词都须出现在命中行前后 3 行内(非整篇共现),最多 8 个;不确定时省略' },
190
193
  after: { type: 'object', additionalProperties: false,
191
194
  description: '原样复制 page.next_after,保留原 query 和过滤条件;切版后重新搜索',
192
195
  required: ['data_version', 'resource_type', 'title', 'position'], properties: {
@@ -224,6 +227,7 @@ const SEARCH_OUTPUT_SCHEMA = {
224
227
  line_start: { type: 'integer' }, line_end: { type: 'integer' },
225
228
  match_kind: { type: 'string' }, evidence_kind: { type: 'string' },
226
229
  citation: { type: 'string' },
230
+ citation_handle: { type: 'string' },
227
231
  excerpt: { type: 'array', items: { type: 'object', additionalProperties: false,
228
232
  required: ['line', 'role', 'line_type', 'speaker', 'text', 'truncated'],
229
233
  properties: { line: { type: 'integer' },
@@ -259,6 +263,13 @@ const SEARCH_OUTPUT_SCHEMA = {
259
263
  } }, { type: 'null' }] } } },
260
264
  truncated: { type: 'boolean' },
261
265
  truncation_reasons: { type: 'array', items: { type: 'string' } },
266
+ scope: { type: 'object', additionalProperties: false,
267
+ required: ['documents', 'lines'], properties: {
268
+ documents: { type: 'integer' }, lines: { type: 'integer' },
269
+ } },
270
+ by_game: { type: 'object', additionalProperties: false, properties: {
271
+ arknights: { type: 'integer' }, endfield: { type: 'integer' },
272
+ } },
262
273
  warnings: { type: 'array', items: { type: 'object', additionalProperties: false,
263
274
  required: ['code', 'game', 'message'], properties: {
264
275
  code: { type: 'string' }, game: { type: 'string', enum: ['arknights', 'endfield'] },
@@ -276,34 +287,39 @@ const SEARCH_OUTPUT_SCHEMA = {
276
287
  const READ_PARAMETERS = {
277
288
  type: 'object',
278
289
  properties: {
279
- title: { type: 'string', description: '完整展示标题;与 document_uid 互斥,须配 line、section 或 mode=document' },
290
+ title: { type: 'string', description: '展示标题(可省略分隔符与 Wiki 序号后缀,找不到会列出候选);与 document_uid 互斥;可配 line、section 或 mode=document,单独提供时从第 1 行读全文' },
280
291
  document_uid: { type: 'string',
281
- description: '搜索返回的同名消歧定位,替代 title;可配合集 mode' },
292
+ description: '仅复制搜索返回的 doc_ 开头同名消歧定位,替代 title;不能填展示标题,标题请用 title;可配合集 mode' },
282
293
  stage_code: { type: 'string', description: '明日方舟关卡代号,如 15-17、TW-ST-1' },
283
- story_part: { type: 'string', enum: ['before', 'after', 'story'],
284
- description: '多篇时消歧:before=行动前,after=行动后,story=幕间;单篇可省略' },
294
+ story_part: { type: 'string', enum: ['', 'before', 'after', 'story'],
295
+ description: '空或省略=不适用;依赖 stage_code,多篇时消歧:before=行动前,after=行动后,story=幕间;单篇可省略' },
285
296
  character_name: { type: 'string', description: '角色展示名;与 record_name 或 material 配合' },
286
- record_name: { type: 'string', description: '干员密录名;多段时加 segment' },
287
- segment: { type: 'integer', description: '密录段号,从 1 开始' },
288
- material: { type: 'string', enum: ['profile', 'module', 'voice', 'skin', 'recruitment', 'potential'],
289
- description: '资料类别:档案/模组/语音/时装/招聘合同/信物' },
290
- game: { type: 'string', enum: ['arknights', 'endfield'],
291
- description: '仅角色资料同名时限定游戏' },
297
+ record_name: { type: 'string', description: '干员密录名;须配 character_name,多段时加 segment' },
298
+ segment: { type: 'integer', description: '依赖 record_name 的密录段号,从 1 开始;其他定位请省略' },
299
+ material: { type: 'string', enum: ['', 'profile', 'module', 'voice', 'skin', 'recruitment', 'potential'],
300
+ description: '空或省略=不适用;依赖 character_name,读官方角色资料:档案/模组/语音/时装/招聘合同/信物。两款游戏通用(终末地如 {"character_name":"管理员","material":"profile"});活动、组织等实体资料用搜索返回的 title 读取' },
301
+ game: { type: 'string', enum: ['', 'arknights', 'endfield'],
302
+ description: '空或省略=不适用;依赖 character_name+material,仅角色资料同名时限定游戏' },
292
303
  activity_name: { type: 'string', description: '明日方舟活动展示名,配 mode=activity' },
293
304
  collection_name: { type: 'string', description: '终末地任务集合展示名,配 mode=collection' },
294
305
  content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES },
295
306
  description: '终末地集合的内容形式过滤;续页保留' },
296
- line: { type: 'integer', description: '上下文中心行号;mode=document 时为起始行' },
307
+ line: { type: 'integer', description: '上下文中心行号(around 模式,默认前后各 3 行);mode=document 不接受 line,连续读取请用 start_line' },
308
+ start_line: { type: 'integer', description: '连续读取的起始行号,仅配 mode=document;page.continuation 里的续页参数就是它' },
297
309
  position: { type: 'integer', description: '合集续读位置,从 page.continuation 复制' },
298
- mode: { type: 'string', enum: ['document', 'activity', 'collection'],
299
- description: 'document=单篇全文,activity=活动,collection=任务集合;document_uid、关卡、密录和角色资料默认单篇全文' },
300
- section: { type: 'string', enum: WIKI_SECTION_VALUES, description: '读取 Wiki 标签字段' },
310
+ mode: { type: 'string', enum: ['', 'document', 'activity', 'collection'],
311
+ description: '空或省略=不适用;document=单篇全文,activity=活动,collection=任务集合;document_uid、关卡、密录和角色资料默认单篇全文;显式 mode=document 全文优先于合法 section,并返回提示' },
312
+ section: { type: 'string', enum: ['', ...WIKI_SECTION_VALUES], description: '空或省略=不适用;配 title 或 document_uid 读取 Wiki 标签字段;并非每篇都有全部字段,不确定时用 mode=document;显式全文优先于合法 section,并返回提示' },
301
313
  before: { type: 'integer', description: '前文行数,默认 3,上限 100' },
302
314
  after: { type: 'integer', description: '后文行数,默认 3,上限 100' },
303
315
  cursor: { type: 'string', description: '仅兼容旧会话游标' },
304
- data_version: { type: 'string', description: '续页复制 page.continuation.data_version' },
316
+ data_version: { type: 'string', description: '续页调用给出的短版本号;可省略' },
305
317
  max_lines: { type: 'integer', description: '输出行数上限,默认 100,最大 500' },
306
318
  max_chars: { type: 'integer', description: '输出字符上限,默认 12000,最大 100000' },
319
+ citation: { type: 'string',
320
+ description: '检索结果给出的「句柄」,形如 doc_xxxxxxxxxxxxxxxx#L123 或 #L120-135;与 title/document_uid/line 等定位参数互斥,用于回到原文某几行' },
321
+ reuse: { type: 'boolean',
322
+ description: '默认 true:跳过"上文已可见"的行,只返回新增行。长会话或上文被压缩/折叠后看不到原文时传 false,强制重发本页全文' },
307
323
  },
308
324
  additionalProperties: false,
309
325
  }
@@ -369,7 +385,8 @@ const READ_OUTPUT_SCHEMA = {
369
385
  activity_name: { type: 'string' }, collection_name: { type: 'string' },
370
386
  content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES } },
371
387
  mode: { type: 'string', enum: ['document', 'activity', 'collection'] },
372
- line: { type: 'integer' }, position: { type: 'integer' }, data_version: { type: 'string' },
388
+ start_line: { type: 'integer' }, position: { type: 'integer' },
389
+ data_version: { type: 'string' },
373
390
  } }, { type: 'null' }] } } },
374
391
  },
375
392
  }
@@ -383,7 +400,7 @@ function projectReadToolValue(response, store) {
383
400
  && store.requiresDocumentUid?.(response.document?.document_id)) {
384
401
  projected.page.continuation = {
385
402
  document_uid: documentUid(response.document.document_id), mode: 'document',
386
- line: continuation.line, data_version: continuation.data_version,
403
+ start_line: continuation.start_line, data_version: continuation.data_version,
387
404
  }
388
405
  }
389
406
  const uniqueStage = continuation.stage_code && continuation.story_part
@@ -396,7 +413,7 @@ function projectReadToolValue(response, store) {
396
413
  && (continuation.stage_code || continuation.record_name || continuation.material)
397
414
  && !uniqueStage && !uniqueRecord && !uniqueMaterial) {
398
415
  projected.page.continuation = {
399
- title: projected.primary.title, mode: 'document', line: continuation.line,
416
+ title: projected.primary.title, mode: 'document', start_line: continuation.start_line,
400
417
  data_version: continuation.data_version,
401
418
  }
402
419
  }
@@ -473,19 +490,48 @@ const CLOUD_SEARCH_PARAMETERS = {
473
490
  depth: { type: 'string', enum: ['fast', 'standard', 'deep'],
474
491
  description: '兼容字段,不改变检索路线;通常省略' },
475
492
  evidence_policy: { type: 'string', enum: ['mixed', 'original_only'],
476
- description: '默认 mixed 混合检索;original_only 仅官方剧情原文' },
493
+ description: '默认 mixed 混合检索,通常省略;original_only 仅检索官方剧情原文' },
477
494
  options: {
478
495
  type: 'object', additionalProperties: false,
479
496
  properties: {
480
497
  search_intent: {
481
498
  type: 'string', enum: ['single_sentence_search'],
482
- description: '仅用户记得一句官方原文大意、措辞不准时使用',
499
+ description: '仅用于凭记忆找某一句官方原文出处;会关闭档案/Wiki/图谱/年表,只检索原文。综合分析、人物关系等问题不要填写',
483
500
  },
484
501
  },
485
502
  },
486
503
  },
487
504
  }
488
505
 
506
+ // 与后端 models/schemas.py SourceType、models/retrieval.py CandidateStatus 保持一致。
507
+ const CLOUD_SOURCE_TYPES = ['entity_profile', 'entity_attachment', 'graph_entity', 'graph_relation',
508
+ 'graph_event', 'csv_archive', 'csv_activity', 'csv_record', 'vector_original', 'vector_scene',
509
+ 'vector_wiki', 'entity_attribute', 'travel_notes', 'classic_hit', 'timeline']
510
+ const CLOUD_CANDIDATE_STATUSES = ['retrieved', 'kept', 'filtered', 'discarded', 'error']
511
+
512
+ // 从 1 开始计数的整数字段:0 只可能是占位,按“省略”处理。
513
+ const TIMELINE_ZERO_FIELDS = ['max_results']
514
+ const CLOUD_INSPECT_ZERO_FIELDS = ['limit', 'event_sequence_from', 'event_sequence_to', 'content_max_chars']
515
+
516
+ // 只接受最无歧义的形式(T 分隔、Z 或 ±HH:MM 时区),避免本地放行而后端 422。
517
+ const AWARE_ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/u
518
+
519
+ /** cloud_inspect 本地预检:把后端 422 会拒绝的常见误用提前转成可执行的提示。 */
520
+ function validateCloudInspectArgs(args) {
521
+ for (const field of ['event_time_from', 'event_time_to']) {
522
+ if (args[field] !== undefined && !AWARE_ISO_DATETIME.test(String(args[field]).trim())) {
523
+ throw Object.assign(new Error(`${field} 必须是带时区的 ISO 8601 时间,例如 2025-01-01T08:00:00+08:00;不需要按时间过滤时请省略该字段`),
524
+ { code: 'INVALID_REQUEST', retryable: false })
525
+ }
526
+ }
527
+ for (const field of ['event_sequence_from', 'event_sequence_to', 'limit', 'cursor', 'content_max_chars']) {
528
+ if (args[field] !== undefined && (!Number.isInteger(args[field]) || args[field] < (field === 'cursor' ? 0 : 1))) {
529
+ throw Object.assign(new Error(`${field} 必须是${field === 'cursor' ? '非负' : '正'}整数;不需要时请省略该字段`),
530
+ { code: 'INVALID_REQUEST', retryable: false })
531
+ }
532
+ }
533
+ }
534
+
489
535
  const CLOUD_INSPECT_PARAMETERS = {
490
536
  type: 'object', additionalProperties: false,
491
537
  properties: {
@@ -493,38 +539,76 @@ const CLOUD_INSPECT_PARAMETERS = {
493
539
  description: '游戏过滤;省略时查看全部启用游戏' },
494
540
  section: { type: 'string', enum: ['summary', 'candidates', 'selected_sources', 'events', 'trace_steps', 'answer_context'],
495
541
  description: '默认 summary' },
496
- cursor: { type: 'integer', description: '复制返回的 next_cursor' },
497
- limit: { type: 'integer', description: '本页条数' },
498
- channels: stringList('按渠道过滤(candidates/events)'),
499
- query_variants: stringList('按查询变体过滤'),
500
- retrievers: stringList('按检索器过滤'),
501
- source_types: { type: 'array', items: { type: 'string' }, description: '按来源类型过滤' },
502
- statuses: { type: 'array', items: { type: 'string' }, description: '按候选状态过滤' },
503
- stages: stringList('按处理阶段过滤'),
504
- candidate_ids: stringList('按候选 ID 精确过滤'),
505
- evidence_ids: stringList('按证据 ID 精确过滤'),
506
- event_sequence_from: { type: 'integer', description: '事件序号下界(含)' },
507
- event_sequence_to: { type: 'integer', description: '事件序号上界(含)' },
508
- event_time_from: { type: 'string', description: '事件时间下界' },
509
- event_time_to: { type: 'string', description: '事件时间上界' },
542
+ cursor: { type: 'integer', description: '续页时复制返回的 next_cursor;首页省略' },
543
+ limit: { type: 'integer', description: '本页条数 1–200;通常省略' },
544
+ channels: stringList('按渠道过滤(candidates/events);通常省略'),
545
+ query_variants: stringList('按查询变体过滤;通常省略'),
546
+ retrievers: stringList('按检索器过滤;通常省略'),
547
+ source_types: { type: 'array', items: { type: 'string', enum: CLOUD_SOURCE_TYPES },
548
+ description: '按来源类型过滤;通常省略' },
549
+ statuses: { type: 'array', items: { type: 'string', enum: CLOUD_CANDIDATE_STATUSES },
550
+ description: '按候选状态过滤;通常省略' },
551
+ stages: stringList('按处理阶段过滤;通常省略'),
552
+ event_sequence_from: { type: 'integer', description: '仅 section=events:事件序号下界(含),从 1 开始' },
553
+ event_sequence_to: { type: 'integer', description: '仅 section=events:事件序号上界(含),从 1 开始' },
554
+ event_time_from: { type: 'string', description: '仅 section=events:带时区的 ISO 8601 时间,如 2025-01-01T08:00:00+08:00' },
555
+ event_time_to: { type: 'string', description: '仅 section=events:带时区的 ISO 8601 时间' },
510
556
  content_mode: { type: 'string', enum: ['none', 'preview', 'full'],
511
- description: 'none=无正文,preview=摘要,full=本页全文' },
512
- content_max_chars: { type: 'integer', description: '正文字符上限' },
557
+ description: 'none=无正文,preview=摘要(默认),full=本页全文' },
558
+ content_max_chars: { type: 'integer', description: '正文字符上限 100–100000;通常省略' },
513
559
  },
514
560
  }
515
561
 
516
562
  /** ---- 模型扁平参数 → 版本化 wire contract ---- */
517
563
 
518
564
  /** corpus_read:将模型使用的自然定位器转换为版本化读取契约。 */
565
+ /** modelReadToContract 推断出的默认值说明;契约对象本身不允许携带额外字段。 */
566
+ const contractNotes = new WeakMap()
567
+
568
+ function withContractNote(contract, notes) {
569
+ if (notes.length) contractNotes.set(contract, notes.join('\n'))
570
+ return contract
571
+ }
572
+
573
+ /**
574
+ * 明日方舟“活动”(activity_name)与终末地“任务集合”(collection_name)是同一种连续
575
+ * 阅读入口,只是两款游戏的叫法不同。字段填反时,若名称只在另一款游戏中存在,就按
576
+ * 实际资料改道并说明,而不是报“找不到”。
577
+ */
578
+ function streamExists(lookup) {
579
+ try { return lookup().length > 0 } catch { return true }
580
+ }
581
+ function rerouteStreamLocator(args, store, enabledGames, notes) {
582
+ const swap = (from, to, mode, game) => {
583
+ const name = String(args[from] ?? '').trim()
584
+ if (!name || args[to] !== undefined || args.document_uid !== undefined || !enabledGames.includes(game)) return null
585
+ const own = from === 'activity_name'
586
+ ? enabledGames.includes('arknights') && streamExists(() => store.activityStoryDocuments({ activityName: name }))
587
+ : enabledGames.includes('endfield') && streamExists(() => store.endfieldCollectionDocuments({ collectionName: name }))
588
+ if (own) return null
589
+ const other = to === 'collection_name'
590
+ ? streamExists(() => store.endfieldCollectionDocuments({ collectionName: name }))
591
+ : streamExists(() => store.activityStoryDocuments({ activityName: name }))
592
+ if (!other) return null
593
+ const { [from]: _drop, ...rest } = args
594
+ notes.push(`定位改道:“${name}”是${game === 'endfield' ? '终末地任务集合' : '明日方舟活动'},已按 ${to}+mode="${mode}" 读取。`)
595
+ return { ...rest, [to]: name, ...(args.mode !== undefined ? { mode } : {}) }
596
+ }
597
+ return swap('activity_name', 'collection_name', 'collection', 'endfield')
598
+ || swap('collection_name', 'activity_name', 'activity', 'arknights')
599
+ || args
600
+ }
601
+
519
602
  async function modelReadToContract(args = {}, store, enabledGames = ['arknights', 'endfield']) {
603
+ const notes = []
520
604
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
521
605
  throw Object.assign(new Error('读取参数必须是对象'), { code: 'INVALID_REQUEST' })
522
606
  }
523
607
  let expectedDataVersion
524
608
  if (args.data_version !== undefined) {
525
609
  expectedDataVersion = String(args.data_version)
526
- if (!DATA_VERSION_PATTERN.test(expectedDataVersion)) {
527
- throw Object.assign(new Error('data_version 必须是续页结果给出的 64 位小写 SHA-256'),
610
+ if (!DATA_VERSION_REF_PATTERN.test(expectedDataVersion)) {
611
+ throw Object.assign(new Error('data_version 必须是续页结果给出的资料版本(至少 8 位小写十六进制,可省略)'),
528
612
  { code: 'INVALID_REQUEST' })
529
613
  }
530
614
  }
@@ -590,24 +674,66 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
590
674
  }
591
675
  const allowed = new Set(['title', 'document_uid', 'stage_code', 'story_part', 'character_name', 'record_name',
592
676
  'segment', 'material', 'game', 'activity_name', 'collection_name', 'content_types',
593
- 'line', 'position', 'mode', 'section', 'before', 'after', 'max_lines', 'max_chars',
594
- 'data_version'])
677
+ 'line', 'start_line', 'position', 'mode', 'section', 'before', 'after', 'max_lines', 'max_chars',
678
+ 'reuse', 'citation', 'data_version'])
595
679
  if (Object.keys(args).some((key) => !allowed.has(key))) {
596
680
  throw Object.assign(new Error('corpus_read 包含不支持的参数'), { code: 'INVALID_REQUEST' })
597
681
  }
682
+ args = rerouteStreamLocator(args, store, enabledGames, notes)
683
+
684
+ // 句柄是检索结果发出的重新取用凭据:展开成 document_uid + 起始行 + 行数,
685
+ // 不让 Agent 为了拿 uid 先跑一次搜索。
686
+ if (args.citation !== undefined) {
687
+ const parsed = parseCitation(args.citation)
688
+ if (!parsed) {
689
+ throw Object.assign(new Error(
690
+ 'citation 格式应为 doc_<16位定位>#L<行号> 或 doc_<16位定位>#L<起始行>-<结束行>;请原样复制检索结果里的「句柄」。',
691
+ ), { code: 'INVALID_REQUEST' })
692
+ }
693
+ const conflicts = ['title', 'document_uid', 'line', 'start_line', 'stage_code', 'story_part',
694
+ 'activity_name', 'collection_name', 'character_name', 'record_name', 'material']
695
+ .filter((key) => args[key] !== undefined)
696
+ if (conflicts.length) {
697
+ throw Object.assign(new Error(
698
+ `citation 已包含篇章与行号,不能再与 ${conflicts.join('、')} 同时提交。`,
699
+ ), { code: 'INVALID_REQUEST' })
700
+ }
701
+ delete args.citation
702
+ args.document_uid = parsed.documentUid
703
+ args.mode = 'document'
704
+ args.start_line = parsed.startLine
705
+ args.max_lines = parsed.endLine - parsed.startLine + 1
706
+ }
598
707
 
599
708
  const hasTitle = args.title !== undefined
600
709
  const hasDocumentUid = args.document_uid !== undefined
601
- const hasStageLocator = args.stage_code !== undefined || args.story_part !== undefined
602
- const hasRecordLocator = args.record_name !== undefined || args.segment !== undefined
603
- const hasMaterialLocator = args.material !== undefined
710
+ const hasStageLocator = args.stage_code !== undefined
711
+ const hasRecordLocator = args.record_name !== undefined
712
+ const hasMaterialLocator = args.character_name !== undefined && args.material !== undefined
604
713
  const hasActivityLocator = args.activity_name !== undefined
605
714
  const hasCollectionLocator = args.collection_name !== undefined
606
- const locatorCount = [hasTitle, hasDocumentUid, hasStageLocator, hasRecordLocator, hasMaterialLocator,
607
- hasActivityLocator, hasCollectionLocator].filter(Boolean).length
715
+ const locatorCount = readTargets(args).length
608
716
  if (locatorCount !== 1) {
717
+ throw Object.assign(new Error(readTargetError(args)), { code: 'INVALID_REQUEST' })
718
+ }
719
+ if (args.story_part !== undefined && !hasStageLocator) {
720
+ throw Object.assign(new Error('附属参数 story_part 依赖 stage_code;关卡读取请仅提交 {"stage_code":"15-17","story_part":"after"},其他定位请省略 story_part'),
721
+ { code: 'INVALID_REQUEST' })
722
+ }
723
+ if (args.segment !== undefined && !hasRecordLocator) {
724
+ throw Object.assign(new Error('附属参数 segment 依赖 record_name;密录读取请仅提交 {"character_name":"角色名","record_name":"密录名","segment":1},其他定位请省略 segment'),
725
+ { code: 'INVALID_REQUEST' })
726
+ }
727
+ if (args.material !== undefined && args.character_name === undefined) {
728
+ throw Object.assign(new Error('附属参数 material 依赖 character_name;角色资料读取请仅提交 {"character_name":"角色名","material":"profile"},其他定位请省略 material'),
729
+ { code: 'INVALID_REQUEST' })
730
+ }
731
+ // Validate before either the single-document or collection lookup, so a
732
+ // display title in this field is not mistaken for missing corpus data.
733
+ if (hasDocumentUid && (typeof args.document_uid !== 'string'
734
+ || !/^doc_[A-Za-z0-9_-]{16}$/u.test(args.document_uid.trim()))) {
609
735
  throw Object.assign(new Error(
610
- '必须且只能提供一种定位方式:title、document_uid、stage_code、角色密录、角色资料、activity_name 或 collection_name;document_uid 会替代 title,不要同时提交二者',
736
+ 'document_uid 必须是检索结果返回的 doc_ 定位(doc_ 后为 16 位字母、数字、_ 或 -),不能填写展示标题;展示标题请改填 title,例如 {"title":"完整展示标题","mode":"document"}。不要同时提交 title 和 document_uid。',
611
737
  ), { code: 'INVALID_REQUEST' })
612
738
  }
613
739
  if (args.character_name !== undefined && !hasRecordLocator && !hasMaterialLocator) {
@@ -638,8 +764,11 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
638
764
  const expectedMode = hasActivityLocator ? 'activity'
639
765
  : hasCollectionLocator ? 'collection' : args.mode
640
766
  if (args.mode !== undefined && args.mode !== expectedMode) {
641
- throw Object.assign(new Error(`${hasActivityLocator ? 'activity_name' : 'collection_name'} 必须使用 mode="${expectedMode}"`),
642
- { code: 'INVALID_REQUEST' })
767
+ if (uidStreamMode || args.mode !== (expectedMode === 'activity' ? 'collection' : 'activity')) {
768
+ throw Object.assign(new Error(`${hasActivityLocator ? 'activity_name' : 'collection_name'} 必须使用 mode="${expectedMode}"`),
769
+ { code: 'INVALID_REQUEST' })
770
+ }
771
+ notes.push(`读取方式:${hasActivityLocator ? 'activity_name' : 'collection_name'} 对应 mode="${expectedMode}",已自动改用。`)
643
772
  }
644
773
  if (args.line !== undefined || args.section !== undefined || args.before !== undefined
645
774
  || args.after !== undefined) {
@@ -680,7 +809,7 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
680
809
  } catch (error) {
681
810
  throw Object.assign(new Error(error.message), { code: error.code || 'DOCUMENT_AMBIGUOUS' })
682
811
  }
683
- return {
812
+ return withContractNote({
684
813
  locator: hasDocumentUid ? { document_uid: String(args.document_uid).trim() }
685
814
  : { [hasActivityLocator ? 'activity_name' : 'collection_name']: name },
686
815
  selection: { mode: expectedMode, cursor: null,
@@ -689,7 +818,7 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
689
818
  ...(expectedDataVersion ? { expected_data_version: expectedDataVersion } : {}),
690
819
  limits: { ...(args.max_lines !== undefined ? { max_lines: args.max_lines } : {}),
691
820
  ...(args.max_chars !== undefined ? { max_chars: args.max_chars } : {}) },
692
- }
821
+ }, notes)
693
822
  }
694
823
 
695
824
  let locator
@@ -716,8 +845,35 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
716
845
  try {
717
846
  record = await store.getDocumentByStoryStage(stageCode, storyPart)
718
847
  } catch (error) {
719
- throw Object.assign(new Error(error.message),
720
- { code: error.code || 'DOCUMENT_AMBIGUOUS' })
848
+ // 同一关卡有行动前/行动后两篇时,未指定 story_part 默认先读行动前,
849
+ // 并提示另一篇的读法,而不是直接报错让调用方重试。
850
+ let fallback = null
851
+ if (!storyPart && error.code === 'DOCUMENT_AMBIGUOUS') {
852
+ for (const part of ['before', 'story', 'after']) {
853
+ try {
854
+ fallback = await store.getDocumentByStoryStage(stageCode, part)
855
+ } catch {
856
+ fallback = null
857
+ }
858
+ if (fallback) {
859
+ const others = []
860
+ for (const other of ['before', 'after', 'story'].filter((item) => item !== part)) {
861
+ try {
862
+ if (await store.getDocumentByStoryStage(stageCode, other)) others.push(other)
863
+ } catch { /* 其余部分仍有歧义时不提示 */ }
864
+ }
865
+ const labels = { before: '行动前', after: '行动后', story: '纯剧情/幕间' }
866
+ notes.push(`关卡定位:${stageCode} 有多篇剧情,未指定 story_part,已默认读取${labels[part]}。`
867
+ + (others.length ? `另有${others.map((item) => `${labels[item]}(story_part="${item}")`).join('、')}。` : ''))
868
+ break
869
+ }
870
+ }
871
+ }
872
+ if (!fallback) {
873
+ throw Object.assign(new Error(error.message),
874
+ { code: error.code || 'DOCUMENT_AMBIGUOUS' })
875
+ }
876
+ record = fallback
721
877
  }
722
878
  if (!record) {
723
879
  const partLabel = storyPart
@@ -783,15 +939,24 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
783
939
  if (!title) throw Object.assign(new Error('title 不能为空'), { code: 'INVALID_REQUEST' })
784
940
  const record = await store.getDocumentByTitle(title)
785
941
  requireEnabled(record)
942
+ if (record) {
943
+ const resolvedTitle = naturalDocumentTitle(record.record.document)
944
+ const exact = (store.naturalTitleIndex?.get(title)?.length || store.titleIndex?.get(title)?.length)
945
+ if (!exact && resolvedTitle && resolvedTitle !== title) {
946
+ notes.push(`标题匹配:未找到完全一致的标题,已按宽松匹配读取《${resolvedTitle}》。`)
947
+ }
948
+ }
786
949
  locator = { display_title: title }
787
950
  }
788
951
  const section = String(args.section ?? '').trim()
789
- if (section && !hasTitle) {
790
- throw Object.assign(new Error('section 只能与 title 定位器一起使用'), { code: 'INVALID_REQUEST' })
952
+ if (section && !hasTitle && !hasDocumentUid) {
953
+ throw Object.assign(new Error('section 只能与 title 或 document_uid 定位器一起使用'), { code: 'INVALID_REQUEST' })
791
954
  }
792
955
  const naturalDocumentLocator = hasDocumentUid || hasStageLocator || hasRecordLocator || hasMaterialLocator
956
+ // 只给 title 时默认从头读全文(受 max_lines/max_chars 限制并给出续页),
957
+ // 与关卡/密录/角色资料等其他直达定位保持一致。
793
958
  const mode = args.mode || (section ? 'section' : Number.isInteger(args.line) ? 'around'
794
- : naturalDocumentLocator ? 'document' : '')
959
+ : naturalDocumentLocator || hasTitle ? 'document' : '')
795
960
  if (!mode) throw Object.assign(new Error(
796
961
  '请提供 line、section 或 mode="document";max_lines/max_chars 只限制输出量,不能代替读取方式',
797
962
  ), { code: 'INVALID_REQUEST' })
@@ -808,34 +973,42 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
808
973
  if (section && mode !== 'section') {
809
974
  throw Object.assign(new Error('section 只能与 mode=section 一起使用'), { code: 'INVALID_REQUEST' })
810
975
  }
976
+ // line 只表示"中心行"(around ±3)。它与全文起点共用过同一字段,导致照着续读提示
977
+ // 传 line 时落回 ±3 窗口、永远前进不到下一页;这里把歧义变成显式失败。
978
+ // 例外:line=1 与"从第 1 行读起"同义,是客户端补齐默认值时常见的无害占位,放行。
979
+ if (mode === 'document' && args.line !== undefined && Number(args.line) > 1) {
980
+ throw Object.assign(new Error(
981
+ 'mode="document" 不接受 line(line 是上下文中心行号,around 模式默认前后各 3 行)。'
982
+ + `连续读取请改用 start_line,例如 ${JSON.stringify({ start_line: args.line, mode: 'document' })}。`,
983
+ ), { code: 'INVALID_REQUEST' })
984
+ }
985
+ if (mode !== 'document' && args.start_line !== undefined) {
986
+ throw Object.assign(new Error(
987
+ 'start_line 只用于 mode="document" 的连续读取;查看某一行上下文请改用 line(around 模式)。',
988
+ ), { code: 'INVALID_REQUEST' })
989
+ }
811
990
  const selection = mode === 'document'
812
- ? { mode, cursor: null, ...(args.line !== undefined ? { start_line: args.line } : {}) }
991
+ ? { mode, cursor: null, ...(args.start_line !== undefined ? { start_line: args.start_line } : {}) }
813
992
  : mode === 'section'
814
993
  ? { mode, section }
815
994
  : { mode: 'around', center_line: args.line,
816
995
  ...(args.before !== undefined ? { before_lines: args.before } : {}),
817
996
  ...(args.after !== undefined ? { after_lines: args.after } : {}) }
818
- return {
997
+ return withContractNote({
819
998
  locator, selection,
820
999
  ...(expectedDataVersion ? { expected_data_version: expectedDataVersion } : {}),
821
1000
  limits: { ...(args.max_lines !== undefined ? { max_lines: args.max_lines } : {}),
822
1001
  ...(args.max_chars !== undefined ? { max_chars: args.max_chars } : {}) },
823
- }
1002
+ }, notes)
824
1003
  }
825
1004
 
826
1005
  /** ---- 云端渲染(output.render 用,压缩为模型可读文本) ---- */
827
1006
 
828
- function renderCloudSearch(_args, value) {
829
- if (value.status === 'error') {
830
- return [{ type: 'text', text: `[cloud_search:error] ${value.error.code}: ${value.error.message}` }]
831
- }
832
- return [{ type: 'text', text: projectCloudSearch(value) }]
1007
+ function renderCloudSearch(args, value) {
1008
+ return [{ type: 'text', text: projectCloudSearch(value, { args }) }]
833
1009
  }
834
1010
 
835
1011
  function renderCloudInspect(_args, value) {
836
- if (value.status === 'error') {
837
- return [{ type: 'text', text: `[cloud_inspect:error] ${value.error.code}: ${value.error.message}` }]
838
- }
839
1012
  return [{ type: 'text', text: JSON.stringify(projectCloudInspect(value)) }]
840
1013
  }
841
1014
 
@@ -936,6 +1109,25 @@ export async function apply(ctx, config = {}) {
936
1109
  installArchivePtcReceipts(toolCtx, readPresentationMeta)
937
1110
  // Host 的 preset 准入已经完成初始化;工具挂载不再触发下载或全量扫描。
938
1111
  applyEntityRecognition(toolCtx, store, shared)
1112
+ tools.register({
1113
+ name: 'corpus_navigate',
1114
+ description: NAVIGATION_DESCRIPTION,
1115
+ parameters: NAVIGATION_PARAMETERS,
1116
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderNavigation },
1117
+ timeoutMs: 60_000,
1118
+ isConcurrencySafe: () => true,
1119
+ presentCall: (args) => ({ card: 'generic', kind: 'search',
1120
+ title: `终末地主线导航 · ${compactToolTitle(args?.query || '主线目录')}` }),
1121
+ execute: async (rawArgs, exec) => {
1122
+ await requireLocalCorpus(store)
1123
+ const response = await executeNavigation(store, dropEmptyPlaceholders(rawArgs), {
1124
+ signal: exec?.signal, allowedGames: shared.effective().enabledGames,
1125
+ })
1126
+ if (response.error) throw Object.assign(new Error(response.error.message), response.error)
1127
+ return response
1128
+ },
1129
+ })
1130
+
939
1131
  tools.register({
940
1132
  name: 'corpus_search',
941
1133
  description: SEARCH_DESCRIPTION,
@@ -945,10 +1137,11 @@ export async function apply(ctx, config = {}) {
945
1137
  isConcurrencySafe: () => true,
946
1138
  presentCall: (args) => searchCallView('PRTS 本地资料', args,
947
1139
  shared.effective().enabledGames),
948
- execute: async (args, exec) => {
1140
+ execute: async (rawArgs, exec) => {
949
1141
  await requireLocalCorpus(store)
1142
+ const args = dropEmptyPlaceholders(rawArgs)
950
1143
  const snapshot = corpusVersionSnapshot(store)
951
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1144
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
952
1145
  const completedSearchCalls = evidenceState.completedSearchCalls
953
1146
  const callId = String(exec?.callId || '')
954
1147
  // 先做对象校验再做幂等哈希:JSON.stringify(undefined) 返回 undefined,
@@ -971,10 +1164,9 @@ export async function apply(ctx, config = {}) {
971
1164
  const requestHash = createHash('sha256').update(JSON.stringify(scopedArgs)).digest('hex')
972
1165
  if (callId && completedSearchCalls.has(callId)) {
973
1166
  const cached = completedSearchCalls.get(callId)
974
- if (cached.requestHash !== requestHash) return {
975
- contract_version: 'prts-corpus-tools-v1', status: 'error', request_id: callId,
976
- data_version: store.dataVersion ?? null,
977
- error: { code: 'INVALID_REQUEST', message: 'callId 已绑定到另一个搜索请求', retryable: false },
1167
+ if (cached.requestHash !== requestHash) {
1168
+ throw Object.assign(new Error('callId 已绑定到另一个搜索请求'),
1169
+ { code: 'INVALID_REQUEST', retryable: false })
978
1170
  }
979
1171
  assertCorpusVersion(store, snapshot)
980
1172
  const replay = structuredClone(cached.response)
@@ -1010,23 +1202,29 @@ export async function apply(ctx, config = {}) {
1010
1202
  // 模型调用顺序独占执行并逐个提交结果。
1011
1203
  isConcurrencySafe: () => false,
1012
1204
  presentCall: (args) => readCallView(args, shared.effective().enabledGames),
1013
- execute: async (args, exec) => {
1205
+ execute: async (rawArgs, exec) => {
1014
1206
  await requireLocalCorpus(store)
1207
+ let { args, guidance: argumentGuidance } = normalizeReadArguments(rawArgs,
1208
+ { sections: WIKI_SECTION_VALUES })
1015
1209
  const snapshot = corpusVersionSnapshot(store)
1016
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1210
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
1017
1211
  let contract
1018
1212
  try {
1019
1213
  contract = await modelReadToContract(args, store, shared.effective().enabledGames)
1020
1214
  assertCorpusVersion(store, snapshot)
1215
+ const inferred = contractNotes.get(contract)
1216
+ if (inferred) argumentGuidance = [argumentGuidance, inferred].filter(Boolean).join('\n')
1021
1217
  } catch (error) {
1022
1218
  assertCorpusVersion(store, snapshot)
1023
1219
  throw Object.assign(new Error(error.message),
1024
1220
  { code: error.code || 'INVALID_REQUEST', retryable: error.retryable ?? false })
1025
1221
  }
1026
- if (contract.expected_data_version !== undefined
1027
- && contract.expected_data_version !== store.dataVersion) {
1028
- throw Object.assign(new Error('续页所属资料版本与当前激活版本不一致,请重新读取'),
1029
- { code: 'PACKAGE_VERSION_MISMATCH', retryable: true })
1222
+ if (contract.expected_data_version !== undefined) {
1223
+ if (!dataVersionMatches(contract.expected_data_version, store.dataVersion)) {
1224
+ throw Object.assign(new Error('续页所属资料版本与当前激活版本不一致,请重新读取'),
1225
+ { code: 'PACKAGE_VERSION_MISMATCH', retryable: true })
1226
+ }
1227
+ contract.expected_data_version = store.dataVersion
1030
1228
  }
1031
1229
  // 证据覆盖按 Harness Agent 隔离;模型不感知 intent_id。
1032
1230
  contract.intent_id = evidenceState.intentId
@@ -1034,12 +1232,14 @@ export async function apply(ctx, config = {}) {
1034
1232
  normalizeReadRequest(contract)
1035
1233
  const requested = await resolveReadWindow(store, contract)
1036
1234
  assertCorpusVersion(store, snapshot)
1037
- const visibleResults = visibleToolResults(exec?.agent)
1235
+ // reuse:false 时不参与"上文已可见"的判定:宿主 surface 与模型实际收到的内容
1236
+ // 一旦不一致(外部压缩、跨请求折叠),去重就会静默少给行,宁可重发。
1237
+ const visibleResults = args.reuse === false ? new Map() : visibleToolResults(exec?.agent)
1038
1238
  if (coveredRead(evidenceState, requested, visibleResults)) {
1039
1239
  const replay = replayCoveredRead(evidenceState, requested, contract)
1040
1240
  if (replay) {
1041
1241
  assertCorpusVersion(store, snapshot)
1042
- return projectReadToolValue(replay, store)
1242
+ return withReadGuidance(projectReadToolValue(replay, store), argumentGuidance)
1043
1243
  }
1044
1244
  }
1045
1245
  const response = await executeRead(store, contract, { signal: exec?.signal })
@@ -1051,7 +1251,7 @@ export async function apply(ctx, config = {}) {
1051
1251
  }, visibleResults) : null
1052
1252
  const delivered = reuseReadCoverage(response, coveragePlan)
1053
1253
  rememberRead(evidenceState, delivered, { callId: String(exec?.callId || ''), store })
1054
- return projectReadToolValue(delivered, store)
1254
+ return withReadGuidance(projectReadToolValue(delivered, store), argumentGuidance)
1055
1255
  },
1056
1256
  })
1057
1257
 
@@ -1090,7 +1290,8 @@ export async function apply(ctx, config = {}) {
1090
1290
  throw Object.assign(new Error('明日方舟资料当前未启用'), { code: 'INVALID_REQUEST' })
1091
1291
  }
1092
1292
  const snapshot = corpusVersionSnapshot(store)
1093
- const response = await executeTimelineSearch(store, args, { signal: exec?.signal })
1293
+ const response = await executeTimelineSearch(store,
1294
+ dropEmptyPlaceholders(args, { zeroFields: TIMELINE_ZERO_FIELDS }), { signal: exec?.signal })
1094
1295
  assertCorpusVersion(store, snapshot)
1095
1296
  return rememberArchivePresentation(response, timelineArchivePresentation(response))
1096
1297
  },
@@ -1124,9 +1325,10 @@ export async function apply(ctx, config = {}) {
1124
1325
  timeoutMs: 180_000,
1125
1326
  isConcurrencySafe: () => true,
1126
1327
  presentCall: (args) => searchCallView('PRTS 云端资料', args, c.enabledGames),
1127
- execute: async (args, exec) => {
1328
+ execute: async (rawArgs, exec) => {
1128
1329
  try {
1129
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1330
+ const args = dropEmptyPlaceholders(rawArgs)
1331
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
1130
1332
  const cloud = cloudClients.forExecution(exec)
1131
1333
  await cloud.capabilities({ signal: exec?.signal })
1132
1334
  const requestedGames = Array.isArray(args.games) ? args.games : c.enabledGames
@@ -1142,7 +1344,8 @@ export async function apply(ctx, config = {}) {
1142
1344
  rememberCloudMappings(evidenceState, enriched)
1143
1345
  return rememberArchivePresentation(enriched, cloudArchivePresentation(enriched, { dataVersion }))
1144
1346
  } catch (error) {
1145
- return cloudErrorResponse(error)
1347
+ // 抛出交给宿主标记 isError,让 Agent 明确知道本次调用失败。
1348
+ throw cloudFaultError(error, 'cloud_search')
1146
1349
  }
1147
1350
  },
1148
1351
  })
@@ -1158,9 +1361,11 @@ export async function apply(ctx, config = {}) {
1158
1361
  presentCall: (args) => ({ card: 'generic',
1159
1362
  title: `查看 PRTS 云端检索详情 · ${presentedGames(args, c.enabledGames)}`,
1160
1363
  kind: 'read', rawInput: compactToolTitle(args?.section || 'summary') }),
1161
- execute: async (args, exec) => {
1364
+ execute: async (rawArgs, exec) => {
1162
1365
  try {
1163
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1366
+ const args = dropEmptyPlaceholders(rawArgs, { zeroFields: CLOUD_INSPECT_ZERO_FIELDS })
1367
+ validateCloudInspectArgs(args)
1368
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
1164
1369
  const cloud = cloudClients.forExecution(exec)
1165
1370
  await cloud.capabilities({ signal: exec?.signal })
1166
1371
  const payload = { ...args,
@@ -1173,7 +1378,7 @@ export async function apply(ctx, config = {}) {
1173
1378
  inspect: true, dataVersion: store.dataVersion,
1174
1379
  }))
1175
1380
  } catch (error) {
1176
- return cloudErrorResponse(error)
1381
+ throw cloudFaultError(error, 'cloud_inspect')
1177
1382
  }
1178
1383
  },
1179
1384
  })
@@ -1211,7 +1416,8 @@ export async function apply(ctx, config = {}) {
1211
1416
  storeEntry.releaseTimer = null
1212
1417
  try {
1213
1418
  const pointer = await readCurrentReleasePointer(releasesDir)
1214
- if (store.loaded && store.releaseId === pointer.release_id) return
1419
+ if (store.loaded && store.releaseId === pointer.release_id
1420
+ && store.installationId === (pointer.installation_id ?? null)) return
1215
1421
  } catch { /* 让 ready() 输出真实指针错误 */ }
1216
1422
  store.reset()
1217
1423
  prepareEntityRecognition(store).catch((error) => {