prts-terrarchive 0.2.1 → 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 (45) hide show
  1. package/README.en.md +100 -23
  2. package/README.md +82 -18
  3. package/bin/build-mainline-catalog.py +195 -0
  4. package/contracts/corpus_i18n_v1.schema.json +10 -12
  5. package/contracts/corpus_tools_v1.schema.json +30 -1
  6. package/docs/endfield-mainline-navigation.md +45 -0
  7. package/lib/client.js +157 -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/presets/definition.js +1 -1
  13. package/presets/prts/preset.yml +1 -1
  14. package/resources/endfield-mainline.json +4632 -0
  15. package/skills/prts-composition/SKILL.md +3 -1
  16. package/skills/prts-investigation/SKILL.md +19 -113
  17. package/skills/prts-investigation/references/board-guide.md +131 -0
  18. package/skills/prts-retrieval/SKILL.md +4 -2
  19. package/skills/prts-retrieval/references/cloud-tools.md +3 -1
  20. package/skills/prts-retrieval/references/local-tools.md +3 -3
  21. package/skills/prts-retrieval/references/module-arknights.md +1 -1
  22. package/skills/prts-retrieval/references/module-dual.md +1 -1
  23. package/skills/prts-retrieval/references/module-endfield.md +3 -0
  24. package/skills/prts-retrieval/references/tools-runtime.md +22 -10
  25. package/src/cloud-projection.js +23 -2
  26. package/src/cloud.js +24 -0
  27. package/src/entity-recognizer.js +26 -5
  28. package/src/entity-routing.js +18 -4
  29. package/src/evidence-state.js +4 -2
  30. package/src/i18n.js +79 -30
  31. package/src/index.js +346 -162
  32. package/src/installer.js +86 -14
  33. package/src/investigation-tools.js +40 -17
  34. package/src/navigation.js +342 -0
  35. package/src/read-args.js +86 -0
  36. package/src/read.js +66 -15
  37. package/src/search-projection.js +154 -19
  38. package/src/search.js +177 -21
  39. package/src/skill.js +25 -16
  40. package/src/skin-capabilities.js +28 -0
  41. package/src/store.js +127 -3
  42. package/src/timeline.js +86 -2
  43. package/src/tool-args.js +53 -0
  44. package/src/ui.js +158 -34
  45. 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,35 +113,15 @@ async function requireLocalCorpus(store) {
110
113
  }
111
114
  }
112
115
 
113
- const READ_DESCRIPTION = [
114
- '读取 PRTS.chat 本地资料。明日方舟关卡用 stage_code,只有多篇时才填 story_part;干员密录用 character_name + record_name + segment;角色资料用 character_name + material。',
115
- '整个明日方舟活动用 activity_name + mode=activity,终末地任务用 collection_name + mode=collection;合集续页原样提交 page.continuation 的 position。其他资料使用完整 title。',
116
- '搜索结果若给出 document_uid,说明标题或合集同名:它替代 title,读取时只提交 document_uid,不得同时提交 title;单篇可配 line 或 mode=document,所属合集可配 mode=activity/collection。',
117
- 'line 扩大单篇原文上下文,section 读取 Wiki 字段,mode=document 分页全文。所有续页都原样提交 page.continuation;旧会话里的 cursor 仅用于兼容。',
118
- '引用原文使用“《篇章名》第 N 行”;同名结果保留工具给出的 document_uid 作为唯一定位器。不要使用内部代号、路径或自造篇章名。',
119
- ].join(' ')
120
-
121
- const SEARCH_DESCRIPTION = [
122
- '像 grep 一样搜索 PRTS.chat 本地语料;命中立即返回原行及上下各一行,并按文档归并。',
123
- 'query 使用短实体名、篇章展示名或原句片段;也可省略 query,仅按过滤条件列出资料入口。',
124
- '角色个人页用 character_wiki;活动/密录整理页用 story_wiki;角色在单个活动中的辅助整理用 character_activity_wiki。wiki_sections 可精确限定相关活动、相关角色、剧情总结、角色剧情概括等标签字段。',
125
- 'literal 是默认连续字面匹配;只有特殊模式才使用受限 regex。新查询的下一页保留原搜索条件,并把返回的 next_after 原样放入 after;锚点由完整资料版本、资料类型与自然标题组成,不再暴露内部 cursor。旧会话的 cursor 链仅按返回的 next_cursor 继续。',
126
- ].join(' ')
127
-
128
- const TIMELINE_DESCRIPTION = [
129
- '按活动名、年份及自动展开的实体别名检索活动时间线(PRTS Wiki《泰拉年表》本地投影)。',
130
- '人物放 entity_names 以自动裂变别名;结果只给时间、事件正文和“年表出处”标记,把标记原样传回 source_marker 可反查完整来源。',
131
- ].join(' ')
132
-
133
- const CLOUD_SEARCH_DESCRIPTION = [
134
- '用一次自然语言请求同时查询 PRTS.chat 云端的明日方舟与终末地图谱、档案、原文、自建 Wiki 和时间线组合索引;只有用户明确限定游戏时才使用 games 收窄。',
135
- '返回末尾的「## 可读取原文」列出已映射到本地篇章的完整自然语言标题与行号;有同名消歧标记时按 document_uid + line 调用,否则按 title + line。',
136
- ].join(' ')
137
-
138
- const CLOUD_INSPECT_DESCRIPTION = [
139
- '按 section 和过滤条件读取最近一次云端检索状态(request_id 由运行时自动注入)。',
140
- '用于定点读取回答材料、候选状态或诊断记录,并支持按 next_cursor 分页。',
141
- ].join(' ')
116
+ const READ_DESCRIPTION = '读取 PRTS.chat 本地资料,支持行号上下文、Wiki 字段和单篇/合集连续阅读。只选一种主定位方式;续页原样提交 page.continuation。最小 UID 全文调用:{"document_uid":"检索返回的 doc_ 定位","mode":"document"};不要填写不适用的附属参数。'
117
+
118
+ const SEARCH_DESCRIPTION = '搜索 PRTS.chat 本地语料,返回按文档归并的命中行及上下各一行;省略 query 可列目录。过滤数组内 OR、字段间 AND。终末地版本、最新主线与章节顺序先用 corpus_navigate 定位。'
119
+
120
+ const TIMELINE_DESCRIPTION = '检索明日方舟活动时间线(PRTS Wiki《泰拉年表》),返回年份、事件与可反查的出处标记。'
121
+
122
+ const CLOUD_SEARCH_DESCRIPTION = '用自然语言检索 PRTS.chat 云端图谱、档案、原文、Wiki 和时间线。返回的「可读取原文」定位可交给 corpus_read 核验。'
123
+
124
+ const CLOUD_INSPECT_DESCRIPTION = '读取最近一次云端检索的回答材料、候选或诊断记录;request_id 由运行时注入。'
142
125
 
143
126
  const TOOL_GAME_LABELS = Object.freeze({ arknights: '明日方舟', endfield: '终末地' })
144
127
 
@@ -187,34 +170,34 @@ const stringList = (description) => ({ type: 'array', items: { type: 'string' },
187
170
  const SEARCH_PARAMETERS = {
188
171
  type: 'object', additionalProperties: false,
189
172
  properties: {
190
- cursor: { type: 'string', description: '仅兼容旧会话:单独提交旧 next_cursor;新搜索使用 after' },
191
- query: { type: 'string', description: '短搜索词:实体名、篇章展示名、活动名或原句片段;不要直接提交整句研究问题' },
173
+ cursor: { type: 'string', description: '旧会话 next_cursor,单独提交;新查询用 after' },
174
+ query: { type: 'string', description: '短实体名、标题或原句片段;完整研究问题用 cloud_search' },
192
175
  resource_types: { type: 'array', items: { type: 'string', enum: RESOURCE_TYPES },
193
- description: '资料类型;character_bundle 可一次查看角色档案、模组、语音和密录' },
176
+ description: '资料类型;character_bundle 聚合角色资料' },
194
177
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
195
- description: '可选游戏过滤;省略时在同一次调用中同时检索明日方舟与终末地' },
178
+ description: '游戏范围;省略时查询全部启用游戏' },
196
179
  content_types: { type: 'array', items: { type: 'string', enum: CONTENT_TYPES },
197
- description: '统一内容形式,例如 dialogue、cutscene、radio、sns_chat;两款游戏使用相同参数' },
198
- collection_names: stringList('上级资料集合展示名;明日方舟活动与终末地任务都使用此字段'),
199
- character_names: stringList('角色展示名,如“凯尔希”'),
200
- story_names: stringList('剧情篇章的展示名,如“晶簇之内”;不要填写内部 story_id 或路径'),
201
- activity_names: stringList('活动展示名'),
180
+ description: '内容形式' },
181
+ collection_names: stringList('活动或任务等上级集合展示名'),
182
+ character_names: stringList('资料归属角色的展示名'),
183
+ story_names: stringList('单关/单篇剧情名,如“鳞的记忆”;活动名不要填这里,改用 activity_names'),
184
+ activity_names: stringList('活动或主线章节名,如“昨日海”'),
202
185
  wiki_sections: { type: 'array', items: { type: 'string', enum: WIKI_SECTION_VALUES },
203
- description: 'Wiki 标签字段,可与资料类型、角色、活动和 query 组合;角色页常用相关活动/相关角色/剧情高光,活动页常用剧情总结/关键人物/角色剧情概括' },
186
+ description: '限定 Wiki 标签字段' },
204
187
  entity_names: stringList('只返回出现指定实体的行'),
205
- speakers: stringList('结构化说话人展示名,只匹配亲口台词;适合查某人亲口说过什么'),
188
+ speakers: stringList('说话人展示名,仅匹配亲口台词;自动展开已登记别名'),
206
189
  match_mode: { type: 'string', enum: ['literal', 'regex'],
207
- description: '默认 literal 连续字面匹配;除非必须,不使用受限 regex' },
190
+ description: '默认 literal 连续字面匹配;regex 为受限正则' },
208
191
  context_terms: { type: 'array', items: { type: 'string' },
209
- description: '要求命中附近同时出现的语境词(最多 8 个)' },
192
+ description: '强约束:每个词都须出现在命中行前后 3 行内(非整篇共现),最多 8 个;不确定时省略' },
210
193
  after: { type: 'object', additionalProperties: false,
211
- description: '版本绑定的下一页锚点;与原 query 和过滤条件一起原样提交上次返回的 next_after',
194
+ description: '原样复制 page.next_after,保留原 query 和过滤条件;切版后重新搜索',
212
195
  required: ['data_version', 'resource_type', 'title', 'position'], properties: {
213
196
  data_version: { type: 'string',
214
- description: '上一页所用资料包的完整 SHA-256 版本;版本切换后必须重新搜索' },
197
+ description: '上一页的完整 SHA-256 资料版本' },
215
198
  resource_type: { type: 'string', enum: RESOURCE_TYPES },
216
- title: { type: 'string', description: '上一页扫描到的资料自然标题' },
217
- position: { type: 'integer', description: '该标题在当前资料版本中的顺序位置(0..10000000)' },
199
+ title: { type: 'string' },
200
+ position: { type: 'integer' },
218
201
  } },
219
202
  },
220
203
  }
@@ -244,6 +227,7 @@ const SEARCH_OUTPUT_SCHEMA = {
244
227
  line_start: { type: 'integer' }, line_end: { type: 'integer' },
245
228
  match_kind: { type: 'string' }, evidence_kind: { type: 'string' },
246
229
  citation: { type: 'string' },
230
+ citation_handle: { type: 'string' },
247
231
  excerpt: { type: 'array', items: { type: 'object', additionalProperties: false,
248
232
  required: ['line', 'role', 'line_type', 'speaker', 'text', 'truncated'],
249
233
  properties: { line: { type: 'integer' },
@@ -279,6 +263,13 @@ const SEARCH_OUTPUT_SCHEMA = {
279
263
  } }, { type: 'null' }] } } },
280
264
  truncated: { type: 'boolean' },
281
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
+ } },
282
273
  warnings: { type: 'array', items: { type: 'object', additionalProperties: false,
283
274
  required: ['code', 'game', 'message'], properties: {
284
275
  code: { type: 'string' }, game: { type: 'string', enum: ['arknights', 'endfield'] },
@@ -296,34 +287,39 @@ const SEARCH_OUTPUT_SCHEMA = {
296
287
  const READ_PARAMETERS = {
297
288
  type: 'object',
298
289
  properties: {
299
- title: { type: 'string', description: '未使用其他定位器时,填写资料完整展示标题;不得与 document_uid 同时提交' },
290
+ title: { type: 'string', description: '展示标题(可省略分隔符与 Wiki 序号后缀,找不到会列出候选);与 document_uid 互斥;可配 line、section 或 mode=document,单独提供时从第 1 行读全文' },
300
291
  document_uid: { type: 'string',
301
- description: '仅在搜索结果提示同名歧义时原样复制 doc_ 开头的稳定定位;它替代 title,不得与 title 同时提交;可配合 mode=activity/collection 选择所属合集' },
302
- stage_code: { type: 'string', description: '明日方舟游戏内关卡代号,如 15-17、GT-3、TW-ST-1' },
303
- story_part: { type: 'string', enum: ['before', 'after', 'story'],
304
- description: '关卡存在多篇剧情时用于消歧:before=行动前,after=行动后,story=纯剧情/幕间;单篇关卡可省略' },
292
+ description: '仅复制搜索返回的 doc_ 开头同名消歧定位,替代 title;不能填展示标题,标题请用 title;可配合集 mode' },
293
+ stage_code: { type: 'string', description: '明日方舟关卡代号,如 15-17、TW-ST-1' },
294
+ story_part: { type: 'string', enum: ['', 'before', 'after', 'story'],
295
+ description: '空或省略=不适用;依赖 stage_code,多篇时消歧:before=行动前,after=行动后,story=幕间;单篇可省略' },
305
296
  character_name: { type: 'string', description: '角色展示名;与 record_name 或 material 配合' },
306
- record_name: { type: 'string', description: '明日方舟干员密录名称;多段密录再提供 segment' },
307
- segment: { type: 'integer', description: '干员密录段号,如 1、2' },
308
- material: { type: 'string', enum: ['profile', 'module', 'voice', 'skin', 'recruitment', 'potential'],
309
- description: '角色资料类别;profile=档案、module=模组、voice=语音、skin=时装' },
310
- game: { type: 'string', enum: ['arknights', 'endfield'],
311
- description: '角色资料在双模块同名时用于消歧;其他定位器不要填写' },
312
- activity_name: { type: 'string', description: '明日方舟活动展示名;按活动连续阅读全部剧情' },
313
- collection_name: { type: 'string', 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,仅角色资料同名时限定游戏' },
303
+ activity_name: { type: 'string', description: '明日方舟活动展示名,配 mode=activity' },
304
+ collection_name: { type: 'string', description: '终末地任务集合展示名,配 mode=collection' },
314
305
  content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES },
315
- description: '终末地集合可选内容形式过滤;续页时原样保留' },
316
- line: { type: 'integer', description: 'around 的中心官方行号;与 mode=document 同用时表示续读起始行' },
317
- position: { type: 'integer', description: '活动/任务连续阅读的下一位置;只从 page.continuation 原样复制' },
318
- mode: { type: 'string', enum: ['document', 'activity', 'collection'],
319
- description: '单篇全文用 document;活动用 activity;终末地任务集合用 collection。title 不会自动推断,必须配 line、section 或 mode=document;document_uid、关卡、密录和角色资料可自动推断单篇全文' },
320
- section: { type: 'string', enum: WIKI_SECTION_VALUES, description: '读取 Wiki 标签字段' },
321
- before: { type: 'integer', description: 'around 前文行数,默认 3,上限 100' },
322
- after: { type: 'integer', description: 'around 后文行数,默认 3,上限 100' },
323
- cursor: { type: 'string', description: '仅兼容旧会话中的不透明游标;新调用应原样提交上次结果的 page.continuation' },
324
- data_version: { type: 'string', description: '续页时原样提交 page.continuation.data_version,防止版本切换后混读' },
325
- max_lines: { type: 'integer', description: '最多返回行数,默认 100,上限 500;只限制输出量,不能代替 line、section 或 mode' },
326
- max_chars: { type: 'integer', description: '最多返回字符数,默认 12000,上限 100000;只限制输出量,不能代替 line、section 或 mode' },
306
+ description: '终末地集合的内容形式过滤;续页保留' },
307
+ line: { type: 'integer', description: '上下文中心行号(around 模式,默认前后各 3 行);mode=document 不接受 line,连续读取请用 start_line' },
308
+ start_line: { type: 'integer', description: '连续读取的起始行号,仅配 mode=document;page.continuation 里的续页参数就是它' },
309
+ position: { type: 'integer', description: '合集续读位置,从 page.continuation 复制' },
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,并返回提示' },
313
+ before: { type: 'integer', description: '前文行数,默认 3,上限 100' },
314
+ after: { type: 'integer', description: '后文行数,默认 3,上限 100' },
315
+ cursor: { type: 'string', description: '仅兼容旧会话游标' },
316
+ data_version: { type: 'string', description: '续页调用给出的短版本号;可省略' },
317
+ max_lines: { type: 'integer', description: '输出行数上限,默认 100,最大 500' },
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,强制重发本页全文' },
327
323
  },
328
324
  additionalProperties: false,
329
325
  }
@@ -389,7 +385,8 @@ const READ_OUTPUT_SCHEMA = {
389
385
  activity_name: { type: 'string' }, collection_name: { type: 'string' },
390
386
  content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES } },
391
387
  mode: { type: 'string', enum: ['document', 'activity', 'collection'] },
392
- line: { type: 'integer' }, position: { type: 'integer' }, data_version: { type: 'string' },
388
+ start_line: { type: 'integer' }, position: { type: 'integer' },
389
+ data_version: { type: 'string' },
393
390
  } }, { type: 'null' }] } } },
394
391
  },
395
392
  }
@@ -403,7 +400,7 @@ function projectReadToolValue(response, store) {
403
400
  && store.requiresDocumentUid?.(response.document?.document_id)) {
404
401
  projected.page.continuation = {
405
402
  document_uid: documentUid(response.document.document_id), mode: 'document',
406
- line: continuation.line, data_version: continuation.data_version,
403
+ start_line: continuation.start_line, data_version: continuation.data_version,
407
404
  }
408
405
  }
409
406
  const uniqueStage = continuation.stage_code && continuation.story_part
@@ -416,7 +413,7 @@ function projectReadToolValue(response, store) {
416
413
  && (continuation.stage_code || continuation.record_name || continuation.material)
417
414
  && !uniqueStage && !uniqueRecord && !uniqueMaterial) {
418
415
  projected.page.continuation = {
419
- title: projected.primary.title, mode: 'document', line: continuation.line,
416
+ title: projected.primary.title, mode: 'document', start_line: continuation.start_line,
420
417
  data_version: continuation.data_version,
421
418
  }
422
419
  }
@@ -474,12 +471,12 @@ function readPresentationMeta(_args, value) {
474
471
  const TIMELINE_PARAMETERS = {
475
472
  type: 'object', additionalProperties: false,
476
473
  properties: {
477
- query: { type: 'string', description: '可选的事件正文短语(≤200 字符)。人物应优先放入 entity_names 以自动展开别名' },
478
- activity_names: { type: 'array', items: { type: 'string' }, description: '活动展示名,例如“孤星”(≤20 项)' },
479
- entity_names: { type: 'array', items: { type: 'string' }, description: '角色或实体展示名;工具会用别名图鉴自动裂变后检索(≤20 项)' },
480
- year_start: { type: 'integer', description: '起始年份(含);可单独使用' },
481
- year_end: { type: 'integer', description: '结束年份(含);可单独使用' },
482
- source_marker: { type: 'string', description: '反查模式:原样复制时间线结果方括号内的年表出处标记(年表出处:tle_ 开头)' },
474
+ query: { type: 'string', description: '事件正文短语,最多 200 字符' },
475
+ activity_names: { type: 'array', items: { type: 'string' }, description: '活动展示名,最多 20 项' },
476
+ entity_names: { type: 'array', items: { type: 'string' }, description: '实体展示名,自动展开别名,最多 20 项' },
477
+ year_start: { type: 'integer', description: '起始年份,含边界' },
478
+ year_end: { type: 'integer', description: '结束年份,含边界' },
479
+ source_marker: { type: 'string', description: '反查来源时复制返回的年表出处标记' },
483
480
  max_results: { type: 'integer', description: '最多返回事件数,默认 20,上限 100' },
484
481
  },
485
482
  }
@@ -487,66 +484,131 @@ const TIMELINE_PARAMETERS = {
487
484
  const CLOUD_SEARCH_PARAMETERS = {
488
485
  type: 'object', additionalProperties: false, required: ['query'],
489
486
  properties: {
490
- query: { type: 'string', description: '改写为语义完整、适合向量检索的自然语言' },
487
+ query: { type: 'string', description: '包含主体、事件和所求关系的完整问题' },
491
488
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
492
- description: '可选游戏范围;省略时同一次调用同时检索明日方舟与终末地' },
489
+ description: '游戏范围;省略时查询全部启用游戏' },
493
490
  depth: { type: 'string', enum: ['fast', 'standard', 'deep'],
494
- description: '兼容字段;不会改变云端主站的检索路由。回答深度由 Agent 自己的运行模式控制,通常省略' },
491
+ description: '兼容字段,不改变检索路线;通常省略' },
495
492
  evidence_policy: { type: 'string', enum: ['mixed', 'original_only'],
496
- description: 'mixed=完整复用 PRTS.chat 主站检索、审核与 Cleaner;original_only=只运行官方剧情原文向量路线' },
493
+ description: '默认 mixed 混合检索,通常省略;original_only 仅检索官方剧情原文' },
497
494
  options: {
498
495
  type: 'object', additionalProperties: false,
499
- description: '通常省略。只有用户记得一句原文大意且措辞可能不准时,选择官方剧情单句向量路线',
500
496
  properties: {
501
497
  search_intent: {
502
498
  type: 'string', enum: ['single_sentence_search'],
503
- description: '只检索官方剧情单句向量表,并执行原有 LLM 验证;其他问题使用默认主站路线',
499
+ description: '仅用于凭记忆找某一句官方原文出处;会关闭档案/Wiki/图谱/年表,只检索原文。综合分析、人物关系等问题不要填写',
504
500
  },
505
501
  },
506
502
  },
507
503
  },
508
504
  }
509
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
+
510
535
  const CLOUD_INSPECT_PARAMETERS = {
511
536
  type: 'object', additionalProperties: false,
512
- description: '查看最近一次云端检索的指定区段;request_id 由运行时自动注入',
513
537
  properties: {
514
538
  games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
515
- description: '只查看指定游戏的候选、来源或事件;省略时查看联合状态' },
539
+ description: '游戏过滤;省略时查看全部启用游戏' },
516
540
  section: { type: 'string', enum: ['summary', 'candidates', 'selected_sources', 'events', 'trace_steps', 'answer_context'],
517
541
  description: '默认 summary' },
518
- cursor: { type: 'integer', description: '分页游标:原样复制上次返回的 next_cursor' },
519
- limit: { type: 'integer', description: '本页条数' },
520
- channels: stringList('按渠道过滤(candidates/events)'),
521
- query_variants: stringList('按查询变体过滤'),
522
- retrievers: stringList('按检索器过滤'),
523
- source_types: { type: 'array', items: { type: 'string' }, description: '按来源类型过滤' },
524
- statuses: { type: 'array', items: { type: 'string' }, description: '按候选状态过滤' },
525
- stages: stringList('按处理阶段过滤'),
526
- candidate_ids: stringList('按候选 ID 精确过滤'),
527
- evidence_ids: stringList('按证据 ID 精确过滤'),
528
- event_sequence_from: { type: 'integer', description: '事件序号下界(含)' },
529
- event_sequence_to: { type: 'integer', description: '事件序号上界(含)' },
530
- event_time_from: { type: 'string', description: '事件时间下界' },
531
- 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 时间' },
532
556
  content_mode: { type: 'string', enum: ['none', 'preview', 'full'],
533
- description: 'none 只取结构字段;preview 返回受限正文;full 返回该页完整正文' },
534
- content_max_chars: { type: 'integer', description: '正文字符上限' },
557
+ description: 'none=无正文,preview=摘要(默认),full=本页全文' },
558
+ content_max_chars: { type: 'integer', description: '正文字符上限 100–100000;通常省略' },
535
559
  },
536
560
  }
537
561
 
538
562
  /** ---- 模型扁平参数 → 版本化 wire contract ---- */
539
563
 
540
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
+
541
602
  async function modelReadToContract(args = {}, store, enabledGames = ['arknights', 'endfield']) {
603
+ const notes = []
542
604
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
543
605
  throw Object.assign(new Error('读取参数必须是对象'), { code: 'INVALID_REQUEST' })
544
606
  }
545
607
  let expectedDataVersion
546
608
  if (args.data_version !== undefined) {
547
609
  expectedDataVersion = String(args.data_version)
548
- if (!DATA_VERSION_PATTERN.test(expectedDataVersion)) {
549
- 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 位小写十六进制,可省略)'),
550
612
  { code: 'INVALID_REQUEST' })
551
613
  }
552
614
  }
@@ -612,24 +674,66 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
612
674
  }
613
675
  const allowed = new Set(['title', 'document_uid', 'stage_code', 'story_part', 'character_name', 'record_name',
614
676
  'segment', 'material', 'game', 'activity_name', 'collection_name', 'content_types',
615
- 'line', 'position', 'mode', 'section', 'before', 'after', 'max_lines', 'max_chars',
616
- 'data_version'])
677
+ 'line', 'start_line', 'position', 'mode', 'section', 'before', 'after', 'max_lines', 'max_chars',
678
+ 'reuse', 'citation', 'data_version'])
617
679
  if (Object.keys(args).some((key) => !allowed.has(key))) {
618
680
  throw Object.assign(new Error('corpus_read 包含不支持的参数'), { code: 'INVALID_REQUEST' })
619
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
+ }
620
707
 
621
708
  const hasTitle = args.title !== undefined
622
709
  const hasDocumentUid = args.document_uid !== undefined
623
- const hasStageLocator = args.stage_code !== undefined || args.story_part !== undefined
624
- const hasRecordLocator = args.record_name !== undefined || args.segment !== undefined
625
- 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
626
713
  const hasActivityLocator = args.activity_name !== undefined
627
714
  const hasCollectionLocator = args.collection_name !== undefined
628
- const locatorCount = [hasTitle, hasDocumentUid, hasStageLocator, hasRecordLocator, hasMaterialLocator,
629
- hasActivityLocator, hasCollectionLocator].filter(Boolean).length
715
+ const locatorCount = readTargets(args).length
630
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()))) {
631
735
  throw Object.assign(new Error(
632
- '必须且只能提供一种定位方式: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。',
633
737
  ), { code: 'INVALID_REQUEST' })
634
738
  }
635
739
  if (args.character_name !== undefined && !hasRecordLocator && !hasMaterialLocator) {
@@ -660,8 +764,11 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
660
764
  const expectedMode = hasActivityLocator ? 'activity'
661
765
  : hasCollectionLocator ? 'collection' : args.mode
662
766
  if (args.mode !== undefined && args.mode !== expectedMode) {
663
- throw Object.assign(new Error(`${hasActivityLocator ? 'activity_name' : 'collection_name'} 必须使用 mode="${expectedMode}"`),
664
- { 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}",已自动改用。`)
665
772
  }
666
773
  if (args.line !== undefined || args.section !== undefined || args.before !== undefined
667
774
  || args.after !== undefined) {
@@ -702,7 +809,7 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
702
809
  } catch (error) {
703
810
  throw Object.assign(new Error(error.message), { code: error.code || 'DOCUMENT_AMBIGUOUS' })
704
811
  }
705
- return {
812
+ return withContractNote({
706
813
  locator: hasDocumentUid ? { document_uid: String(args.document_uid).trim() }
707
814
  : { [hasActivityLocator ? 'activity_name' : 'collection_name']: name },
708
815
  selection: { mode: expectedMode, cursor: null,
@@ -711,7 +818,7 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
711
818
  ...(expectedDataVersion ? { expected_data_version: expectedDataVersion } : {}),
712
819
  limits: { ...(args.max_lines !== undefined ? { max_lines: args.max_lines } : {}),
713
820
  ...(args.max_chars !== undefined ? { max_chars: args.max_chars } : {}) },
714
- }
821
+ }, notes)
715
822
  }
716
823
 
717
824
  let locator
@@ -738,8 +845,35 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
738
845
  try {
739
846
  record = await store.getDocumentByStoryStage(stageCode, storyPart)
740
847
  } catch (error) {
741
- throw Object.assign(new Error(error.message),
742
- { 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
743
877
  }
744
878
  if (!record) {
745
879
  const partLabel = storyPart
@@ -805,15 +939,24 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
805
939
  if (!title) throw Object.assign(new Error('title 不能为空'), { code: 'INVALID_REQUEST' })
806
940
  const record = await store.getDocumentByTitle(title)
807
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
+ }
808
949
  locator = { display_title: title }
809
950
  }
810
951
  const section = String(args.section ?? '').trim()
811
- if (section && !hasTitle) {
812
- 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' })
813
954
  }
814
955
  const naturalDocumentLocator = hasDocumentUid || hasStageLocator || hasRecordLocator || hasMaterialLocator
956
+ // 只给 title 时默认从头读全文(受 max_lines/max_chars 限制并给出续页),
957
+ // 与关卡/密录/角色资料等其他直达定位保持一致。
815
958
  const mode = args.mode || (section ? 'section' : Number.isInteger(args.line) ? 'around'
816
- : naturalDocumentLocator ? 'document' : '')
959
+ : naturalDocumentLocator || hasTitle ? 'document' : '')
817
960
  if (!mode) throw Object.assign(new Error(
818
961
  '请提供 line、section 或 mode="document";max_lines/max_chars 只限制输出量,不能代替读取方式',
819
962
  ), { code: 'INVALID_REQUEST' })
@@ -830,34 +973,42 @@ async function modelReadToContract(args = {}, store, enabledGames = ['arknights'
830
973
  if (section && mode !== 'section') {
831
974
  throw Object.assign(new Error('section 只能与 mode=section 一起使用'), { code: 'INVALID_REQUEST' })
832
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
+ }
833
990
  const selection = mode === 'document'
834
- ? { mode, cursor: null, ...(args.line !== undefined ? { start_line: args.line } : {}) }
991
+ ? { mode, cursor: null, ...(args.start_line !== undefined ? { start_line: args.start_line } : {}) }
835
992
  : mode === 'section'
836
993
  ? { mode, section }
837
994
  : { mode: 'around', center_line: args.line,
838
995
  ...(args.before !== undefined ? { before_lines: args.before } : {}),
839
996
  ...(args.after !== undefined ? { after_lines: args.after } : {}) }
840
- return {
997
+ return withContractNote({
841
998
  locator, selection,
842
999
  ...(expectedDataVersion ? { expected_data_version: expectedDataVersion } : {}),
843
1000
  limits: { ...(args.max_lines !== undefined ? { max_lines: args.max_lines } : {}),
844
1001
  ...(args.max_chars !== undefined ? { max_chars: args.max_chars } : {}) },
845
- }
1002
+ }, notes)
846
1003
  }
847
1004
 
848
1005
  /** ---- 云端渲染(output.render 用,压缩为模型可读文本) ---- */
849
1006
 
850
- function renderCloudSearch(_args, value) {
851
- if (value.status === 'error') {
852
- return [{ type: 'text', text: `[cloud_search:error] ${value.error.code}: ${value.error.message}` }]
853
- }
854
- return [{ type: 'text', text: projectCloudSearch(value) }]
1007
+ function renderCloudSearch(args, value) {
1008
+ return [{ type: 'text', text: projectCloudSearch(value, { args }) }]
855
1009
  }
856
1010
 
857
1011
  function renderCloudInspect(_args, value) {
858
- if (value.status === 'error') {
859
- return [{ type: 'text', text: `[cloud_inspect:error] ${value.error.code}: ${value.error.message}` }]
860
- }
861
1012
  return [{ type: 'text', text: JSON.stringify(projectCloudInspect(value)) }]
862
1013
  }
863
1014
 
@@ -868,7 +1019,7 @@ function renderCloudInspect(_args, value) {
868
1019
  * @param {{
869
1020
  * releasesDir?: string,
870
1021
  * cacheShards?: number,
871
- * uiSkin?: 'harness' | 'prts-agent',
1022
+ * uiSkin?: 'harness' | 'prts-agent' | 'endfield-aic' | 'rhine-lab',
872
1023
  * download?: { order?: ('modelscope'|'site')[], siteBaseUrl?: string },
873
1024
  * cloud?: { baseUrl?: string, game?: 'arknights' | 'endfield', userId?: string, token?: string, timeoutMs?: number, maxResponseBytes?: number },
874
1025
  * }} [config]
@@ -958,6 +1109,25 @@ export async function apply(ctx, config = {}) {
958
1109
  installArchivePtcReceipts(toolCtx, readPresentationMeta)
959
1110
  // Host 的 preset 准入已经完成初始化;工具挂载不再触发下载或全量扫描。
960
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
+
961
1131
  tools.register({
962
1132
  name: 'corpus_search',
963
1133
  description: SEARCH_DESCRIPTION,
@@ -967,10 +1137,11 @@ export async function apply(ctx, config = {}) {
967
1137
  isConcurrencySafe: () => true,
968
1138
  presentCall: (args) => searchCallView('PRTS 本地资料', args,
969
1139
  shared.effective().enabledGames),
970
- execute: async (args, exec) => {
1140
+ execute: async (rawArgs, exec) => {
971
1141
  await requireLocalCorpus(store)
1142
+ const args = dropEmptyPlaceholders(rawArgs)
972
1143
  const snapshot = corpusVersionSnapshot(store)
973
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1144
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
974
1145
  const completedSearchCalls = evidenceState.completedSearchCalls
975
1146
  const callId = String(exec?.callId || '')
976
1147
  // 先做对象校验再做幂等哈希:JSON.stringify(undefined) 返回 undefined,
@@ -993,10 +1164,9 @@ export async function apply(ctx, config = {}) {
993
1164
  const requestHash = createHash('sha256').update(JSON.stringify(scopedArgs)).digest('hex')
994
1165
  if (callId && completedSearchCalls.has(callId)) {
995
1166
  const cached = completedSearchCalls.get(callId)
996
- if (cached.requestHash !== requestHash) return {
997
- contract_version: 'prts-corpus-tools-v1', status: 'error', request_id: callId,
998
- data_version: store.dataVersion ?? null,
999
- 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 })
1000
1170
  }
1001
1171
  assertCorpusVersion(store, snapshot)
1002
1172
  const replay = structuredClone(cached.response)
@@ -1032,23 +1202,29 @@ export async function apply(ctx, config = {}) {
1032
1202
  // 模型调用顺序独占执行并逐个提交结果。
1033
1203
  isConcurrencySafe: () => false,
1034
1204
  presentCall: (args) => readCallView(args, shared.effective().enabledGames),
1035
- execute: async (args, exec) => {
1205
+ execute: async (rawArgs, exec) => {
1036
1206
  await requireLocalCorpus(store)
1207
+ let { args, guidance: argumentGuidance } = normalizeReadArguments(rawArgs,
1208
+ { sections: WIKI_SECTION_VALUES })
1037
1209
  const snapshot = corpusVersionSnapshot(store)
1038
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1210
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
1039
1211
  let contract
1040
1212
  try {
1041
1213
  contract = await modelReadToContract(args, store, shared.effective().enabledGames)
1042
1214
  assertCorpusVersion(store, snapshot)
1215
+ const inferred = contractNotes.get(contract)
1216
+ if (inferred) argumentGuidance = [argumentGuidance, inferred].filter(Boolean).join('\n')
1043
1217
  } catch (error) {
1044
1218
  assertCorpusVersion(store, snapshot)
1045
1219
  throw Object.assign(new Error(error.message),
1046
1220
  { code: error.code || 'INVALID_REQUEST', retryable: error.retryable ?? false })
1047
1221
  }
1048
- if (contract.expected_data_version !== undefined
1049
- && contract.expected_data_version !== store.dataVersion) {
1050
- throw Object.assign(new Error('续页所属资料版本与当前激活版本不一致,请重新读取'),
1051
- { 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
1052
1228
  }
1053
1229
  // 证据覆盖按 Harness Agent 隔离;模型不感知 intent_id。
1054
1230
  contract.intent_id = evidenceState.intentId
@@ -1056,12 +1232,14 @@ export async function apply(ctx, config = {}) {
1056
1232
  normalizeReadRequest(contract)
1057
1233
  const requested = await resolveReadWindow(store, contract)
1058
1234
  assertCorpusVersion(store, snapshot)
1059
- const visibleResults = visibleToolResults(exec?.agent)
1235
+ // reuse:false 时不参与"上文已可见"的判定:宿主 surface 与模型实际收到的内容
1236
+ // 一旦不一致(外部压缩、跨请求折叠),去重就会静默少给行,宁可重发。
1237
+ const visibleResults = args.reuse === false ? new Map() : visibleToolResults(exec?.agent)
1060
1238
  if (coveredRead(evidenceState, requested, visibleResults)) {
1061
1239
  const replay = replayCoveredRead(evidenceState, requested, contract)
1062
1240
  if (replay) {
1063
1241
  assertCorpusVersion(store, snapshot)
1064
- return projectReadToolValue(replay, store)
1242
+ return withReadGuidance(projectReadToolValue(replay, store), argumentGuidance)
1065
1243
  }
1066
1244
  }
1067
1245
  const response = await executeRead(store, contract, { signal: exec?.signal })
@@ -1073,7 +1251,7 @@ export async function apply(ctx, config = {}) {
1073
1251
  }, visibleResults) : null
1074
1252
  const delivered = reuseReadCoverage(response, coveragePlan)
1075
1253
  rememberRead(evidenceState, delivered, { callId: String(exec?.callId || ''), store })
1076
- return projectReadToolValue(delivered, store)
1254
+ return withReadGuidance(projectReadToolValue(delivered, store), argumentGuidance)
1077
1255
  },
1078
1256
  })
1079
1257
 
@@ -1112,7 +1290,8 @@ export async function apply(ctx, config = {}) {
1112
1290
  throw Object.assign(new Error('明日方舟资料当前未启用'), { code: 'INVALID_REQUEST' })
1113
1291
  }
1114
1292
  const snapshot = corpusVersionSnapshot(store)
1115
- 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 })
1116
1295
  assertCorpusVersion(store, snapshot)
1117
1296
  return rememberArchivePresentation(response, timelineArchivePresentation(response))
1118
1297
  },
@@ -1146,9 +1325,10 @@ export async function apply(ctx, config = {}) {
1146
1325
  timeoutMs: 180_000,
1147
1326
  isConcurrencySafe: () => true,
1148
1327
  presentCall: (args) => searchCallView('PRTS 云端资料', args, c.enabledGames),
1149
- execute: async (args, exec) => {
1328
+ execute: async (rawArgs, exec) => {
1150
1329
  try {
1151
- const evidenceState = evidenceStates.forExecution(exec, store.dataVersion)
1330
+ const args = dropEmptyPlaceholders(rawArgs)
1331
+ const evidenceState = evidenceStates.forExecution(exec, store.scopeFingerprint ?? store.dataVersion)
1152
1332
  const cloud = cloudClients.forExecution(exec)
1153
1333
  await cloud.capabilities({ signal: exec?.signal })
1154
1334
  const requestedGames = Array.isArray(args.games) ? args.games : c.enabledGames
@@ -1164,7 +1344,8 @@ export async function apply(ctx, config = {}) {
1164
1344
  rememberCloudMappings(evidenceState, enriched)
1165
1345
  return rememberArchivePresentation(enriched, cloudArchivePresentation(enriched, { dataVersion }))
1166
1346
  } catch (error) {
1167
- return cloudErrorResponse(error)
1347
+ // 抛出交给宿主标记 isError,让 Agent 明确知道本次调用失败。
1348
+ throw cloudFaultError(error, 'cloud_search')
1168
1349
  }
1169
1350
  },
1170
1351
  })
@@ -1180,9 +1361,11 @@ export async function apply(ctx, config = {}) {
1180
1361
  presentCall: (args) => ({ card: 'generic',
1181
1362
  title: `查看 PRTS 云端检索详情 · ${presentedGames(args, c.enabledGames)}`,
1182
1363
  kind: 'read', rawInput: compactToolTitle(args?.section || 'summary') }),
1183
- execute: async (args, exec) => {
1364
+ execute: async (rawArgs, exec) => {
1184
1365
  try {
1185
- 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)
1186
1369
  const cloud = cloudClients.forExecution(exec)
1187
1370
  await cloud.capabilities({ signal: exec?.signal })
1188
1371
  const payload = { ...args,
@@ -1195,7 +1378,7 @@ export async function apply(ctx, config = {}) {
1195
1378
  inspect: true, dataVersion: store.dataVersion,
1196
1379
  }))
1197
1380
  } catch (error) {
1198
- return cloudErrorResponse(error)
1381
+ throw cloudFaultError(error, 'cloud_inspect')
1199
1382
  }
1200
1383
  },
1201
1384
  })
@@ -1217,7 +1400,7 @@ export async function apply(ctx, config = {}) {
1217
1400
  const handle = acquireInvestigationStore(investigationCtx.storageDomain)
1218
1401
  shared.investigations = handle.service
1219
1402
  investigationCtx.effect(() => () => { if (shared.investigations === handle.service) shared.investigations = null; return handle.release() }, 'prts: investigations')
1220
- if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service))
1403
+ if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service, shared))
1221
1404
  })
1222
1405
  if (enableTools) await ctx.inject(['tools', 'systemPrompt'], async toolCtx => {
1223
1406
  mountWebGuidance(toolCtx)
@@ -1233,7 +1416,8 @@ export async function apply(ctx, config = {}) {
1233
1416
  storeEntry.releaseTimer = null
1234
1417
  try {
1235
1418
  const pointer = await readCurrentReleasePointer(releasesDir)
1236
- 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
1237
1421
  } catch { /* 让 ready() 输出真实指针错误 */ }
1238
1422
  store.reset()
1239
1423
  prepareEntityRecognition(store).catch((error) => {