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
@@ -0,0 +1,342 @@
1
+ /** Source-table mainline navigation; never derives chapter order from story fragments. */
2
+ import { readFile } from 'node:fs/promises'
3
+ import { assertCorpusVersion, corpusVersionSnapshot, documentGame, documentUid,
4
+ naturalDocumentTitle } from './store.js'
5
+
6
+ export const NAVIGATION_DESCRIPTION = '导航终末地主线目录、当前资料覆盖的最新主线、版本/章节/进程及准确任务名。支持明确标题的上一段/下一段;返回梗概与任务原文的 corpus_search/read 参数。最新仅指当前资料覆盖,不代表实时游戏版本。'
7
+ export const NAVIGATION_PARAMETERS = {
8
+ type: 'object', additionalProperties: false,
9
+ properties: {
10
+ query: { type: 'string', description: '留空看主线目录;如最新主线、1.6主线、丹青渡、第二章第八进程、进程VIII、某标题下一段' },
11
+ game: { type: 'string', enum: ['endfield'], description: '当前仅支持终末地主线导航' },
12
+ },
13
+ }
14
+ const CONTRACT = 'prts-corpus-tools-v1'
15
+ const DEFAULT_CATALOG = new URL('../resources/endfield-mainline.json', import.meta.url)
16
+ const NARRATIVE = new Set(['dialogue', 'cutscene', 'radio', 'remote_comm', 'black_screen',
17
+ 'environment_talk', 'sns_topic', 'sns_chat', 'narration'])
18
+ const normalize = (value) => String(value ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
19
+ const titleKey = (value) => normalize(value).replace(/^终末地\s*·\s*/u, '')
20
+ const fault = (code, message) => Object.assign(new Error(message), { code,
21
+ retryable: code === 'PACKAGE_VERSION_MISMATCH' })
22
+ const active = (signal) => { if (signal?.aborted) throw fault('CANCELLED', '主线导航已取消') }
23
+ const compareVersion = (a, b) => a.version.localeCompare(b.version, 'en', { numeric: true })
24
+
25
+ function request(raw, allowedGames) {
26
+ if (raw === undefined) raw = {}
27
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
28
+ || Object.keys(raw).some((key) => !Object.hasOwn(NAVIGATION_PARAMETERS.properties, key))) {
29
+ throw fault('INVALID_REQUEST', 'corpus_navigate 仅接受 query 和 game')
30
+ }
31
+ if (raw.game !== undefined && raw.game !== 'endfield') {
32
+ throw fault('UNSUPPORTED_GAME', '主线导航当前仅支持终末地,不支持明日方舟(arknights)')
33
+ }
34
+ if (!Array.isArray(allowedGames) || !allowedGames.includes('endfield')) {
35
+ throw fault('GAME_NOT_ENABLED', '当前未启用终末地资料,无法使用终末地主线导航')
36
+ }
37
+ if (raw.query !== undefined && (typeof raw.query !== 'string' || raw.query.length > 500)) {
38
+ throw fault('INVALID_REQUEST', 'query 必须是最长 500 字符的字符串')
39
+ }
40
+ return normalize(raw.query)
41
+ }
42
+
43
+ function validateCatalog(value) {
44
+ const invalid = () => { throw fault('NAVIGATION_CATALOG_INVALID', '主线目录格式无效:需要明确的版本映射、章节编号与唯一源表 order;不能推断剧情顺序') }
45
+ if (!value || typeof value.source_game_version !== 'string'
46
+ || !Array.isArray(value.versions) || !Array.isArray(value.chapters)) invalid()
47
+ const ids = new Set(), orders = new Set(), versions = new Set()
48
+ for (const row of value.chapters) {
49
+ if (!row || typeof row.id !== 'string' || !row.id || ids.has(row.id)
50
+ || typeof row.title !== 'string' || !row.title
51
+ || !Number.isSafeInteger(row.chapter_number) || row.chapter_number < 0
52
+ || (row.process_number !== null && (!Number.isSafeInteger(row.process_number) || row.process_number < 0))
53
+ || !Number.isFinite(row.order) || orders.has(row.order)
54
+ || !Array.isArray(row.collection_names) || row.collection_names.some((s) => typeof s !== 'string' || !s.trim())
55
+ || (row.summary_title != null && typeof row.summary_title !== 'string')) invalid()
56
+ ids.add(row.id); orders.add(row.order)
57
+ }
58
+ for (const row of value.versions) {
59
+ if (!row || typeof row.version !== 'string' || !row.version || versions.has(row.version)
60
+ || typeof row.title !== 'string' || !Array.isArray(row.chapter_ids)
61
+ || row.chapter_ids.some((id) => !ids.has(id))
62
+ || new Set(row.chapter_ids).size !== row.chapter_ids.length) invalid()
63
+ versions.add(row.version)
64
+ }
65
+ return { ...value, chapters: [...value.chapters].sort((a, b) => a.order - b.order),
66
+ versions: [...value.versions].sort(compareVersion) }
67
+ }
68
+
69
+ function number(value) {
70
+ if (/^\d+$/u.test(value)) return Number(value)
71
+ const roman = value.toUpperCase()
72
+ if (/^[IVXLCDM]+$/u.test(roman)) {
73
+ const digits = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }
74
+ let result = 0
75
+ for (let i = 0; i < roman.length; i++) result += digits[roman[i]] * (digits[roman[i]] < (digits[roman[i + 1]] || 0) ? -1 : 1)
76
+ if (!/^(?=.)M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/u.test(roman)) return null
77
+ return result
78
+ }
79
+ const digits = '零一二三四五六七八九'
80
+ if (/^[零一二三四五六七八九两十百]+$/u.test(value)) {
81
+ let result = 0, digit = 0
82
+ for (const char of value.replaceAll('两', '二')) {
83
+ if (char === '十' || char === '百') { result += (digit || 1) * (char === '十' ? 10 : 100); digit = 0 }
84
+ else digit = digits.indexOf(char)
85
+ }
86
+ return result + digit
87
+ }
88
+ return null
89
+ }
90
+
91
+ function select(catalog, query) {
92
+ if (!query || /^(?:终末地)?(?:主线(?:目录|导航|剧情顺序|顺序)?|主线剧情目录)?$/u.test(query)) return { mode: 'directory', rows: catalog.chapters }
93
+ if (/^(?:终末地)?(?:最新主线(?:剧情|目录|讲了什么|讲什么)?|(?:现在|目前)主线到哪(?:了|里了|里)?)$/u.test(query)) {
94
+ const version = [...catalog.versions].reverse().find((row) => row.chapter_ids.length)
95
+ return { mode: 'latest', rows: version ? catalog.chapters.filter((row) => version.chapter_ids.includes(row.id)) : [], version,
96
+ message: version ? '最新指当前目录已有明确映射的最新主线;不代表实时游戏最新。' : '当前目录尚无已映射的最新主线,不能推断实时游戏更新。' }
97
+ }
98
+ const directions = query.match(/上一段|下一段/gu) || []
99
+ if (directions.length) {
100
+ if (directions.length !== 1) throw fault('INVALID_REQUEST', '一次只能导航上一段或下一段')
101
+ const anchor = query.replace(/上一段|下一段/gu, '').replace(/[《》“”"「」『』]/gu, '')
102
+ .replace(/^[\s::的]+|[\s::的]+$/gu, '')
103
+ if (!anchor) throw fault('ANCHOR_REQUIRED', '上一段/下一段必须带明确章节或任务标题;不会读取会话中的隐含位置')
104
+ const matches = exactChapters(catalog, anchor)
105
+ if (matches.length !== 1) return { mode: 'adjacent', rows: matches, status: matches.length ? 'ambiguous' : 'not_found',
106
+ message: matches.length ? '标题对应多个主线单元,请使用完整章节标题。' : '未找到准确的锚点标题,请提供目录中的章节或任务名。' }
107
+ const anchorInfo = { id: matches[0].id, title: matches[0].title, navigation_level: 'process' }
108
+ const processAnchor = [matches[0].id, matches[0].title, ...(matches[0].aliases || [])]
109
+ .some((name) => titleKey(name) === titleKey(anchor))
110
+ && !matches[0].collection_names.some((name) => titleKey(name) === titleKey(anchor))
111
+ if (!processAnchor) return { mode: 'adjacent', rows: matches, anchor: { ...anchorInfo, task_name: anchor },
112
+ navigation_level: 'process', message: '上一段/下一段在此工具指主线进程,不是单个任务。该锚点只匹配任务名,现返回所属进程;任务顺序未核实,不自动跳到相邻进程。请明确进程标题后导航。' }
113
+ const index = catalog.chapters.indexOf(matches[0]) + (directions[0] === '上一段' ? -1 : 1)
114
+ return { mode: 'adjacent', rows: catalog.chapters[index] ? [catalog.chapters[index]] : [],
115
+ anchor: anchorInfo, navigation_level: 'process',
116
+ message: catalog.chapters[index] ? '上一段/下一段指按源表顺序相邻的主线进程,不是单个任务。'
117
+ : '已到当前资料目录边界;不能据此断言游戏中没有后续或前序剧情。上一段/下一段指主线进程,不是单个任务。' }
118
+ }
119
+ const target = query.replace(/(?:的)?主线(?:剧情)?$/u, '').trim()
120
+ const exact = exactChapters(catalog, query)
121
+ const suffixed = !exact.length && target !== query ? exactChapters(catalog, target) : []
122
+ if (exact.length || suffixed.length) return { mode: 'title', rows: exact.length ? exact : suffixed }
123
+ if (/支线|角色任务/u.test(query)) throw fault('UNSUPPORTED_SCOPE', 'corpus_navigate 当前入口仅提供终末地主线导航;支线及角色任务请使用 corpus_search 检索,不会返回主线目录兜底')
124
+ const versionMatch = /^(?:终末地\s*)?[vV]?(\d+\.\d+(?:\.\d+)?)(?:\s*版本)?\s*的?\s*(?:主线|剧情|主线剧情)?$/u.exec(query)
125
+ const version = versionMatch ? catalog.versions.find((row) => row.version === versionMatch[1])
126
+ : catalog.versions.find((row) => [row.title, ...(row.aliases || [])].some((title) => [query, target].includes(normalize(title))))
127
+ if (versionMatch || version) return { mode: 'version', version, rows: version
128
+ ? catalog.chapters.filter((row) => version.chapter_ids.includes(row.id)) : [],
129
+ status: version?.chapter_ids.length ? 'ok' : 'unmapped',
130
+ message: !version ? `当前目录没有版本 ${versionMatch[1]} 的主线映射,不能猜测该版本新增剧情。`
131
+ : version.chapter_ids.length ? undefined : '该版本 chapter_ids 为空;仅表示当前目录没有映射条目,不能据此判断无主线更新,请参考 evidence 和 mapping_status。' }
132
+ const compact = query.replace(/\s+/gu, '').replace(/^终末地/u, '').replace(/^主线/u, '')
133
+ const unit = '[0-9零一二三四五六七八九两十百IVXLCDMivxlcdm]+'
134
+ const chapter = new RegExp(`^第?(${unit})章(?:第?(${unit})进程)?$`, 'u').exec(compact)
135
+ const process = new RegExp(`^(?:第?(${unit})进程|进程(${unit}))$`, 'u').exec(compact)
136
+ if (chapter || process) {
137
+ const chapterNumber = chapter ? number(chapter[1]) : null
138
+ const processNumber = chapter?.[2] ? number(chapter[2]) : process ? number(process[1] || process[2]) : null
139
+ const valid = (!chapter || chapterNumber !== null) && (!(chapter?.[2] || process) || processNumber !== null)
140
+ return { mode: 'chapter', rows: valid ? catalog.chapters.filter((row) => (!chapter || row.chapter_number === chapterNumber)
141
+ && (processNumber === null || row.process_number === processNumber)) : [] }
142
+ }
143
+ return { mode: 'title', rows: [], status: 'not_found', message: '当前目录未找到准确标题或可识别的版本/章节/进程;请先查看主线目录,不推断未映射内容。' }
144
+ }
145
+
146
+ function exactChapters(catalog, query) {
147
+ return catalog.chapters.filter((row) => [row.id, row.title, row.title_en, row.summary_title,
148
+ ...(row.summary_titles || []), ...row.collection_names, ...(row.aliases || [])]
149
+ .some((name) => name && titleKey(name) === titleKey(query)))
150
+ }
151
+
152
+ function availability(documents, store, kind) {
153
+ const installed = documents.length > 0 || !(store.packs instanceof Map)
154
+ || store.packs.has(kind === 'summary' ? 'endfield_reviewed_knowledge' : 'endfield_official_game')
155
+ return { status: documents.length ? 'available' : installed ? 'missing' : 'not_installed', document_count: documents.length,
156
+ message: documents.length ? '当前本地目录可定位资料。' : installed
157
+ ? '当前本地目录未找到对应资料;不代表没有该段剧情。' : '对应本地资料包未安装;不代表没有该段剧情。' }
158
+ }
159
+
160
+ function entry(row, catalog, documents, store) {
161
+ const summaries = [...new Set([row.summary_title, ...(row.summary_titles || [])].filter(Boolean))].map((title) => {
162
+ const matches = documents.filter((doc) => doc.resource_type !== 'original_story'
163
+ && [naturalDocumentTitle(doc), doc.display_title].some((name) => titleKey(name) === titleKey(title)))
164
+ const state = availability(matches, store, 'summary')
165
+ if (matches.length > 1) { state.status = 'ambiguous'; state.message = '同名梗概对应多篇资料,请先检索以选择具体 document_uid。' }
166
+ return { title, search: { query: title, games: ['endfield'] },
167
+ read: matches.length === 1 ? { document_uid: documentUid(matches[0].document_id), mode: 'document' }
168
+ : { title, mode: 'document' }, availability: state }
169
+ })
170
+ const summary = summaries[0] ?? null
171
+ const collections = row.collection_names.map((name) => {
172
+ const matches = documents.filter((doc) => doc.document_type === 'story' && doc.document_kind === 'story'
173
+ && doc.resource_type === 'original_story' && NARRATIVE.has(doc.content_type)
174
+ && normalize(doc.collection_name) === normalize(name))
175
+ const ids = new Set(matches.map((doc) => doc.collection_id || ''))
176
+ const state = availability(matches, store, 'collection')
177
+ if (ids.size > 1) { state.status = 'ambiguous'; state.message = '同名任务对应多个集合,请先检索具体篇章后使用 document_uid。' }
178
+ return { name, search: { games: ['endfield'], collection_names: [name], resource_types: ['original_story'] },
179
+ read: { collection_name: name, mode: 'collection' }, availability: state }
180
+ })
181
+ const index = catalog.chapters.indexOf(row)
182
+ const neighbor = (value) => value ? { id: value.id, title: value.title,
183
+ navigate: { query: value.id, game: 'endfield' } } : null
184
+ const states = [...summaries, ...collections].map((value) => value.availability.status)
185
+ const missionIds = new Set((row.missions || []).map((mission) => mission.id))
186
+ const missionMetadata = row.mission_metadata ?? (catalog.mission_metadata || [])
187
+ .filter((mission) => missionIds.has(mission.id) || missionIds.has(mission.name_alias_of))
188
+ return { id: row.id, title: row.title, aliases: row.aliases ?? [], title_en: row.title_en ?? null,
189
+ chapter_title: row.chapter_title ?? null, chapter_number: row.chapter_number, process_number: row.process_number,
190
+ order: row.order, version: row.version ?? null, evidence: row.evidence ?? null,
191
+ summary_title: row.summary_title ?? null, summary_titles: summaries.map((item) => item.title),
192
+ summary_coverage_note: row.summary_coverage_note ?? null, collection_names: [...row.collection_names],
193
+ collection_order: row.collection_order ?? (row.mission_order_status === 'community_explicit_order' ? 'community_explicit_order' : 'unknown'),
194
+ collection_order_evidence: row.collection_order_evidence ?? (row.missions || []).filter((mission) => mission.order_source)
195
+ .map((mission) => ({ id: mission.id, order: mission.order, source: mission.order_source })),
196
+ missions: row.missions ?? [], unresolved_missions: row.unresolved_missions ?? [],
197
+ mission_order_status: row.mission_order_status ?? 'unknown', classification_source: row.classification_source ?? null,
198
+ mission_metadata: missionMetadata, classification: row.classification ?? null,
199
+ classification_note: '任务分类以原始 missionType/mission_type 等任务元数据及其证据为准;章/进程顺序以独立源表 order 和 evidence 为准。Wiki 角色分类不等于游戏任务类型;chapter_bitmask 不等于章编号。',
200
+ summary, summaries, collections, availability: { status: !states.length ? 'unmapped'
201
+ : states.every((state) => state === 'available') ? 'available'
202
+ : states.some((state) => state === 'available') ? 'partial'
203
+ : states.includes('ambiguous') ? 'ambiguous' : states.every((state) => state === 'not_installed') ? 'not_installed' : 'missing',
204
+ message: 'availability 仅描述当前本地资料覆盖,不判断剧情是否存在。' },
205
+ prev: neighbor(catalog.chapters[index - 1]), next: neighbor(catalog.chapters[index + 1]) }
206
+ }
207
+
208
+ /** store.navigationCatalogPath optionally injects a catalog file (e.g. a temporary test fixture).
209
+ * No catalog or availability cache: each invocation is bound to its corpus snapshot. */
210
+ export async function executeNavigation(store, raw, { allowedGames = ['arknights', 'endfield'], signal } = {}) {
211
+ let snapshot
212
+ try {
213
+ active(signal)
214
+ const query = request(raw, allowedGames).replace(/明日方舟\s*[::]?\s*终末地/gu, '终末地')
215
+ if (/arknights/iu.test(query) || (/明日方舟/u.test(query) && !/终末地/u.test(query))) throw fault('UNSUPPORTED_GAME', '当前仅支持终末地主线导航,不支持明日方舟(arknights)。明日方舟请改用 timeline_search(活动时间位置)、corpus_search({activity_names:[活动名]}) 列篇目,或 corpus_read({stage_code:"1-7"}) 直接读关卡。')
216
+ await store.ready()
217
+ snapshot = corpusVersionSnapshot(store)
218
+ active(signal)
219
+ let bytes
220
+ try { bytes = await readFile(store.navigationCatalogPath ?? DEFAULT_CATALOG, { encoding: 'utf8', signal }) }
221
+ catch (error) {
222
+ if (error.code === 'ENOENT') throw fault('NAVIGATION_CATALOG_UNAVAILABLE', '当前未安装终末地主线导航目录;不能据此判断主线剧情是否存在')
223
+ throw error
224
+ }
225
+ assertCorpusVersion(store, snapshot)
226
+ active(signal)
227
+ let parsed
228
+ try { parsed = JSON.parse(bytes) } catch { throw fault('NAVIGATION_CATALOG_INVALID', '主线目录不是有效 JSON') }
229
+ const catalog = validateCatalog(parsed)
230
+ const selection = select(catalog, query)
231
+ const documents = [...store.documents.values()].map((record) => record.document)
232
+ .filter((doc) => doc && documentGame(doc) === 'endfield')
233
+ const allEntries = catalog.chapters.map((row) => entry(row, catalog, documents, store))
234
+ const available = (row) => row.availability.status === 'available' || row.availability.status === 'partial'
235
+ const latestAvailable = allEntries.findLast(available)
236
+ if (selection.mode === 'latest' && latestAvailable) {
237
+ const coveredVersion = [...catalog.versions].reverse().find((row) => row.chapter_ids.includes(latestAvailable.id))
238
+ selection.version = coveredVersion
239
+ selection.rows = catalog.chapters.filter((row) => coveredVersion
240
+ ? coveredVersion.chapter_ids.includes(row.id) && available(allEntries.find((item) => item.id === row.id))
241
+ : row.id === latestAvailable.id)
242
+ selection.message = '最新指按源表顺序当前本地可定位的最新主线;版本归属仅使用 versions.chapter_ids 明确映射,不代表实时游戏最新。'
243
+ }
244
+ const entries = allEntries.filter((row) => selection.rows.some((selected) => selected.id === row.id))
245
+ const warnings = []
246
+ if (store.packs instanceof Map && !store.packs.has('endfield_official_game')) warnings.push({
247
+ code: 'CORPUS_GAME_NOT_INSTALLED', message: '终末地官方原文本地资料包未安装;不代表没有主线剧情。' })
248
+ if (entries.some((row) => row.availability.status !== 'available')) warnings.push({
249
+ code: 'NAVIGATION_CONTENT_UNAVAILABLE', message: '部分导航资料尚未在当前本地目录完整定位;请查看逐项 availability,不代表没有剧情。' })
250
+ if (selection.mode === 'latest' && !latestAvailable) warnings.push({
251
+ code: 'NO_LOCAL_MAINLINE', message: '当前目录没有可用的本地主线资料;所列仅为目录映射边界,不宣称已安装覆盖。' })
252
+ const mapped = [...catalog.versions].reverse().find((version) => version.chapter_ids.length)
253
+ const versions = catalog.versions.map((row) => ({ ...row,
254
+ mapping_status: row.mapping_status ?? (row.chapter_ids.length ? 'mapped' : 'unmapped') }))
255
+ assertCorpusVersion(store, snapshot)
256
+ active(signal)
257
+ return { contract_version: CONTRACT, status: selection.status || (entries.length ? 'ok' : 'not_found'),
258
+ game: 'endfield', data_version: snapshot.dataVersion ?? null, query, mode: selection.mode,
259
+ navigation_level: 'process',
260
+ ordering_note: catalog.ordering_note ?? null, classification_note: catalog.classification_note ?? null,
261
+ mission_metadata: catalog.mission_metadata ?? [],
262
+ source_game_version: catalog.source_game_version,
263
+ version_boundary: { source_game_version: catalog.source_game_version, latest_mapped_version: mapped?.version ?? null,
264
+ latest_catalog_version: catalog.versions.at(-1)?.version ?? null,
265
+ latest_available_chapter_id: latestAvailable?.id ?? null,
266
+ latest_available_version: [...catalog.versions].reverse().find((row) => row.chapter_ids.includes(latestAvailable?.id))?.version ?? null,
267
+ note: '仅为当前资料目录覆盖边界;不代表实时游戏最新。' },
268
+ warnings, versions, ...(selection.version ? { version: versions.find((row) => row.version === selection.version.version) } : {}),
269
+ ...(selection.anchor ? { anchor: selection.anchor } : {}), entries,
270
+ ...(selection.message ? { message: selection.message } : {}) }
271
+ } catch (error) {
272
+ try { if (snapshot) assertCorpusVersion(store, snapshot) } catch (changed) { error = changed }
273
+ return { contract_version: CONTRACT, status: 'error', game: 'endfield', data_version: store?.dataVersion ?? null,
274
+ error: { code: error.name === 'AbortError' ? 'CANCELLED' : error.code || 'INTERNAL_ERROR',
275
+ message: error.name === 'AbortError' ? '主线导航已取消' : error.message,
276
+ retryable: Boolean(error.retryable) } }
277
+ }
278
+ }
279
+
280
+ /** Host render(args, value); the single-argument form also supports standalone projections. */
281
+ export function renderNavigation(args, value) {
282
+ const result = value === undefined ? args : value
283
+ if (result.error) return [{ type: 'text', text: `[corpus_navigate:error] ${result.error.code}: ${result.error.message}` }]
284
+ const lines = [`[corpus_navigate:${result.mode}] status=${result.status}`,
285
+ `资料版本边界:${result.source_game_version};最新明确映射:${result.version_boundary.latest_mapped_version ?? '未映射'}。${result.version_boundary.note}`]
286
+ if (result.message) lines.push(result.message)
287
+ for (const warning of result.warnings || []) lines.push(`提示 ${warning.code}:${warning.message}`)
288
+ if (result.mode === 'directory') {
289
+ lines.push('目录按源表明确的章/进程 order 排列;任务数仅表示集合成员数量,不表示严格任务顺序。来源与任务分类证据见条目详情。')
290
+ lines.push(`版本映射:${result.versions.map((row) => `${row.version}:${row.mapping_status}`).join(';').slice(0, 1200)}`)
291
+ for (const row of result.entries) {
292
+ const label = `第${row.chapter_number}章${row.process_number === null ? '' : `·第${row.process_number}进程`}`
293
+ const alias = row.aliases.length ? `(别名:${row.aliases.slice(0, 2).join('、').slice(0, 80)})` : ''
294
+ const line = `${label} ${row.title.slice(0, 160)}${alias} · ${row.collections.length}任务 [${row.availability.status}] collection_order=${row.collection_order};corpus_navigate(${JSON.stringify({ query: row.id })})`
295
+ if (lines.join('\n').length + line.length > 7800) { lines.push('更多条目请按明确章节/进程查询;完整目录保留在 JSON 结果中。'); break }
296
+ lines.push(line)
297
+ }
298
+ return [{ type: 'text', text: lines.join('\n') }]
299
+ }
300
+ const versions = result.version ? [result.version] : result.versions
301
+ for (const version of versions) {
302
+ const evidence = compactEvidence(version.evidence)
303
+ lines.push(`版本 ${version.version} ${version.title}:mapping_status=${version.mapping_status}${evidence ? `;依据:${evidence}` : ''}`)
304
+ }
305
+ for (const row of result.entries) {
306
+ lines.push(`${row.order}. ${row.title}(第${row.chapter_number}章${row.process_number === null ? '' : ` · 第${row.process_number}进程`};${row.version ?? '版本未映射'})[${row.availability.status}]`)
307
+ const metadata = compactMissionMetadata(row.mission_metadata)
308
+ const orderEvidence = compactEvidence(row.evidence)
309
+ lines.push(`任务元数据:${metadata || '无'}${row.classification?.basis ? `;分类依据 ${row.classification.basis}` : ''}。${row.classification_note}`)
310
+ if (orderEvidence) lines.push(`顺序证据:${orderEvidence}`)
311
+ for (const summary of row.summaries) lines.push(`梗概 ${summary.title} [${summary.availability.status}]:corpus_read(${JSON.stringify(summary.read)});也可 corpus_search(${JSON.stringify(summary.search)})`)
312
+ lines.push(`任务集合(collection_order=${row.collection_order}${row.collection_order === 'unknown' ? ';任务顺序尚未核实,下列排列不代表严格剧情顺序' : ''}):`)
313
+ for (const collection of row.collections) lines.push(`- ${collection.name} [${collection.availability.status}]:corpus_read(${JSON.stringify(collection.read)})`)
314
+ lines.push(row.availability.message, `上一段:${row.prev ? `${row.prev.title} corpus_navigate(${JSON.stringify(row.prev.navigate)})` : '当前目录边界'};下一段:${row.next ? `${row.next.title} corpus_navigate(${JSON.stringify(row.next.navigate)})` : '当前目录边界'}`)
315
+ }
316
+ return [{ type: 'text', text: lines.join('\n') }]
317
+ }
318
+
319
+ /** 证据只展示种类与结论;URL、源文件路径等留在结构化结果里,不进入模型可见文本。 */
320
+ function compactEvidence(evidence) {
321
+ if (!Array.isArray(evidence) || !evidence.length) return ''
322
+ return evidence.map((item) => {
323
+ if (typeof item === 'string') return /^(?:\/|[A-Za-z]:\\|https?:)/u.test(item) ? '' : item
324
+ if (!item || typeof item !== 'object') return ''
325
+ return [item.kind, item.claim].filter(Boolean).join(':')
326
+ }).filter(Boolean).join(';').slice(0, 400)
327
+ }
328
+
329
+ const MISSION_SCALAR_KEYS = /(?:^id$|mission_?id|^name$|type|bitmask|char_?id)/iu
330
+
331
+ /** 每个任务只保留 id/名称/类型/位掩码等判别字段,去掉源文件路径与导出细节。 */
332
+ function compactMissionMetadata(metadata) {
333
+ if (!Array.isArray(metadata) || !metadata.length) return ''
334
+ return metadata.slice(0, 12).map((mission) => {
335
+ if (!mission || typeof mission !== 'object') return ''
336
+ const fields = Object.entries(mission)
337
+ .filter(([key, value]) => MISSION_SCALAR_KEYS.test(key) && !/export/iu.test(key)
338
+ && (typeof value === 'number' || (typeof value === 'string' && value && value.length <= 40)))
339
+ .map(([key, value]) => `${key}=${value}`)
340
+ return fields.join(',')
341
+ }).filter(Boolean).join(';') + (metadata.length > 12 ? `;另 ${metadata.length - 12} 项` : '')
342
+ }
@@ -0,0 +1,86 @@
1
+ import { dropEmptyPlaceholders } from './tool-args.js'
2
+
3
+ const ZERO_FIELDS = ['line', 'start_line', 'segment', 'position', 'max_lines', 'max_chars']
4
+
5
+ /**
6
+ * 句柄 citation:检索结果发出去的"重新取用"凭据,形如 doc_xxxxxxxxxxxxxxxx#L123
7
+ * 或 doc_xxxxxxxxxxxxxxxx#L120-L135。它把"标题可能同名、抓不到 document_uid"的两跳
8
+ * 缩成一步,Agent 复制粘贴即可回到原文。
9
+ * @returns {{documentUid: string, startLine: number, endLine: number} | null}
10
+ */
11
+ export function parseCitation(value) {
12
+ const match = String(value ?? '').trim()
13
+ .match(/^(doc_[A-Za-z0-9_-]{16})#L([1-9][0-9]*)(?:-L?([1-9][0-9]*))?$/u)
14
+ if (!match) return null
15
+ const startLine = Number(match[2])
16
+ const endLine = match[3] === undefined ? startLine : Number(match[3])
17
+ if (endLine < startLine) return null
18
+ return { documentUid: match[1], startLine, endLine }
19
+ }
20
+
21
+ /** 把篇章定位与行号区间拼成句柄,供检索结果回显。 */
22
+ export function formatCitation(documentUid, startLine, endLine = startLine) {
23
+ if (!documentUid || !Number.isInteger(startLine)) return ''
24
+ return `${documentUid}#L${startLine === endLine ? startLine : `${startLine}-${endLine}`}`
25
+ }
26
+
27
+ /** Independent anchors, never inferred from an optional modifier alone. */
28
+ export function readTargets(args) {
29
+ return [
30
+ ['title', ['title']], ['document_uid', ['document_uid']], ['stage_code', ['stage_code']],
31
+ ['record_name', ['character_name', 'record_name']],
32
+ ['material', ['character_name', 'material']],
33
+ ['activity_name', ['activity_name']], ['collection_name', ['collection_name']],
34
+ ].filter(([key]) => args[key] !== undefined && (key !== 'material' || args.character_name !== undefined))
35
+ .map(([key, keys]) => ({ key, args: Object.fromEntries(keys.filter((name) => args[name] !== undefined)
36
+ .map((name) => [name, args[name]])) }))
37
+ }
38
+
39
+ /** Keep every real target and pagination credential. Only known orphan defaults are compatible fillers. */
40
+ export function normalizeReadArguments(raw, { sections = [] } = {}) {
41
+ const args = dropEmptyPlaceholders(raw, { zeroFields: ZERO_FIELDS })
42
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return { args, guidance: '' }
43
+ const notes = []
44
+ if (args.line === undefined) {
45
+ if (args.before === 0) delete args.before
46
+ if (args.after === 0) delete args.after
47
+ }
48
+ if (readTargets(args).length === 1 && args.cursor === undefined) {
49
+ const ignored = []
50
+ const removeDefault = (key, matches) => {
51
+ if (!matches || args[key] === undefined) return
52
+ ignored.push(key)
53
+ delete args[key]
54
+ }
55
+ // 表单式调用会把所有可选字段一并填上(""、0、默认值)。附属参数只要没有对应主定位,
56
+ // 就不表达任何意图:一律忽略并说明,不报错——报错只会让调用方在同一处反复试。
57
+ removeDefault('story_part', args.stage_code === undefined)
58
+ removeDefault('segment', args.record_name === undefined)
59
+ removeDefault('material', args.character_name === undefined)
60
+ removeDefault('start_line', args.mode !== 'document' && !(args.mode === undefined
61
+ && args.line === undefined && args.section === undefined))
62
+ removeDefault('game', !(args.character_name !== undefined && args.material !== undefined)
63
+ && ['arknights', 'endfield'].includes(args.game))
64
+ if (ignored.length) notes.push(`参数适配:已忽略没有对应定位信息的默认附属参数 ${ignored.join('、')};未改变目标文档。`)
65
+ if (args.mode === 'document' && sections.includes(args.section)) {
66
+ notes.push(`读取方式:mode=document 已明确选择全文,未应用 section=${args.section}。如只读取该字段,请省略 mode,保留 title 或 document_uid 与 section。`)
67
+ delete args.section
68
+ }
69
+ }
70
+ return { args, guidance: notes.join('\n') }
71
+ }
72
+
73
+ /** Concrete conflicting targets and independently executable retries; never choose a target for the caller. */
74
+ export function readTargetError(args) {
75
+ const targets = readTargets(args)
76
+ if (!targets.length) return '缺少主定位信息:请提供 title、document_uid、stage_code、character_name+record_name、character_name+material、activity_name 或 collection_name 中的一种。story_part、segment、material 单独出现不能定位资料。'
77
+ const examples = targets.map(({ key, args: target }) => ({ ...target,
78
+ mode: key === 'activity_name' ? 'activity' : key === 'collection_name' ? 'collection' : 'document' }))
79
+ const titleUidHint = args.title !== undefined && args.document_uid !== undefined
80
+ ? 'document_uid 会替代 title,不要同时提交二者。' : ''
81
+ return `必须且只能提供一种定位方式;实际冲突:${targets.map(({ args: target }) => Object.keys(target).join('+')).join(' 与 ')}。请选择目标后仅提交其中一组:${examples.map((example) => JSON.stringify(example)).join(' 或 ')}。${titleUidHint}附属参数不能代替主定位信息。`
82
+ }
83
+
84
+ export function withReadGuidance(value, guidance) {
85
+ return guidance ? { ...value, guidance: [value.guidance, guidance].filter(Boolean).join('\n') } : value
86
+ }
package/src/read.js CHANGED
@@ -14,7 +14,7 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
14
14
  import { assertCorpusVersion, corpusVersionSnapshot, computeLinesIntegrity,
15
15
  documentGame, documentUid, naturalDocumentTitle,
16
16
  operatorRecordSegment, publicCharacterMaterial, publicStoryPart,
17
- publicStoryStageCode } from './store.js'
17
+ publicStoryStageCode, shortDataVersion } from './store.js'
18
18
  import { WIKI_SECTION_VALUES, wikiSectionRanges } from './wiki.js'
19
19
 
20
20
  export const CONTRACT_VERSION = 'prts-corpus-tools-v1'
@@ -404,7 +404,17 @@ export async function executeRead(store, rawArgs, runtime) {
404
404
  throw error
405
405
  }
406
406
  if (found === null) {
407
- throw new ContractError('DOCUMENT_NOT_FOUND', '本地资料包中找不到该完整标题对应的文档')
407
+ const loose = store.looseTitleMatches?.(normalized.locator.display_title) ?? []
408
+ const candidates = loose.length > 1
409
+ ? loose.map((documentId) => store.documents.get(documentId)?.document)
410
+ .filter(Boolean).map((document) => naturalDocumentTitle(document))
411
+ : store.suggestTitles?.(normalized.locator.display_title, 5) ?? []
412
+ const unique = [...new Set(candidates)].slice(0, 8)
413
+ const endfieldTip = unique.some((title) => title.startsWith('终末地 · '))
414
+ ? '终末地同一任务的多段对话可改用 {"collection_name":"任务名","mode":"collection"} 连续阅读。' : ''
415
+ throw new ContractError('DOCUMENT_NOT_FOUND', unique.length
416
+ ? `本地资料包中找不到标题“${normalized.locator.display_title}”。最接近的完整标题:${unique.map((title) => `“${title}”`).join('、')};请改用其一作为 title 重试。${endfieldTip}`
417
+ : `本地资料包中找不到标题“${normalized.locator.display_title}”;请先用 corpus_search 按关键词或 activity_names 列目录,再复制返回的完整标题。`)
408
418
  }
409
419
  } else {
410
420
  const sourceRef = normalized.locator.source_ref
@@ -466,7 +476,10 @@ export async function executeRead(store, rawArgs, runtime) {
466
476
  }
467
477
  const ranges = wikiSectionRanges(record, [selection.section])
468
478
  if (!ranges.length) {
469
- throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”`)
479
+ const available = [...new Set(wikiSectionRanges(record).map((range) => range.name))]
480
+ throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”;`
481
+ + (available.length ? `可用字段:${available.join('、')}。` : '该文档没有可单独读取的标签字段,')
482
+ + '也可省略 section 并设置 mode="document" 读取全文。')
470
483
  }
471
484
  if (ranges.length > 1) {
472
485
  throw new ContractError('DOCUMENT_AMBIGUOUS',
@@ -883,10 +896,25 @@ export function readableRenderedLine(line, marker = 'L') {
883
896
  const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
884
897
  text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
885
898
  }
899
+ if (line.line_type === 'knowledge') text = readableWikiMarkup(text)
886
900
  return `${marker}${line.line_number} ${line.line_type || ''} ${speaker ? `${speaker}: ` : ''}${text}`
887
901
  .replace(/\s+$/u, '')
888
902
  }
889
903
 
904
+ /**
905
+ * Wiki 资料用 <字段名>…</字段名> 分段;直接展示标签既占篇幅又像 HTML 残留。
906
+ * 只改写整行的字段标记,正文中的尖括号保持原样。
907
+ */
908
+ function readableWikiMarkup(text) {
909
+ const trimmed = text.trim()
910
+ const inline = /^<([^<>/\s][^<>]{0,40})>([\s\S]*)<\/\1>$/u.exec(trimmed)
911
+ if (inline) return `${inline[1]}:${inline[2].trim()}`
912
+ const open = /^<([^<>/\s][^<>]{0,40})>$/u.exec(trimmed)
913
+ if (open) return `[${open[1]}]`
914
+ if (/^<\/[^<>]{1,40}>$/u.test(trimmed)) return ''
915
+ return text
916
+ }
917
+
890
918
  function publicLine(line) {
891
919
  const speaker = String(line.speaker_raw || '').trim()
892
920
  let text = String(line.text || '')
@@ -927,7 +955,7 @@ export function projectReadPublic(value) {
927
955
  returned_lines: Number(value.page?.returned_lines || value.primary.lines?.length || 0),
928
956
  has_more: hasMore,
929
957
  continuation: hasMore && Number.isInteger(nextLine) && nextLine > 0
930
- ? { ...shortLocator, mode: 'document', line: nextLine,
958
+ ? { ...shortLocator, mode: 'document', start_line: nextLine,
931
959
  ...(dataVersion ? { data_version: dataVersion } : {}) }
932
960
  : null,
933
961
  } }
@@ -1004,7 +1032,7 @@ export function projectReadPublic(value) {
1004
1032
  page: { returned_lines: Number(value.page?.returned || lines.length),
1005
1033
  has_more: Boolean(value.page?.has_more),
1006
1034
  continuation: value.page?.has_more
1007
- ? { ...continuationLocator, mode: 'document', line: Number(value.selection?.line_end) + 1,
1035
+ ? { ...continuationLocator, mode: 'document', start_line: Number(value.selection?.line_end) + 1,
1008
1036
  data_version: String(value.data_version || '') }
1009
1037
  : null },
1010
1038
  }
@@ -1024,6 +1052,7 @@ export function renderRead(_args, value) {
1024
1052
  }
1025
1053
  const projected = projectReadPublic(value)
1026
1054
  const parts = []
1055
+ if (projected.guidance) parts.push(projected.guidance)
1027
1056
  if (projected.primary) {
1028
1057
  const primary = projected.primary
1029
1058
  parts.push(`# ${primary.title}`)
@@ -1050,13 +1079,14 @@ export function renderRead(_args, value) {
1050
1079
  } else {
1051
1080
  groupEnd = line.line
1052
1081
  }
1053
- parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1054
- speaker_raw: line.speaker, text: line.text }))
1082
+ const rendered = readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1083
+ speaker_raw: line.speaker, text: line.text })
1084
+ parts.push(line.source_line_id ? `${rendered}〔${line.source_line_id}〕` : rendered)
1055
1085
  }
1056
1086
  finishGroup()
1057
1087
  if (primary.text) parts.push(primary.text)
1058
1088
  if (projected.page.has_more && projected.page.continuation) {
1059
- parts.push(`继续阅读:corpus_read(${JSON.stringify(projected.page.continuation)})`)
1089
+ parts.push(`继续阅读:corpus_read(${JSON.stringify(compactContinuation(projected.page.continuation))})`)
1060
1090
  }
1061
1091
  return [{ type: 'text', text: parts.join('\n') }]
1062
1092
  }
@@ -1071,27 +1101,48 @@ export function renderRead(_args, value) {
1071
1101
  : `${range.line_start}-${range.line_end}`).join('、')
1072
1102
  parts.push(`复用上文:第 ${ranges} 行已在上方可见工具结果中;本次只展示尚未覆盖的行。`)
1073
1103
  }
1104
+ let hiddenProvenance = 0
1074
1105
  for (const line of primary.lines || []) {
1075
- parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1076
- speaker_raw: line.speaker, text: line.text }))
1106
+ if (primary.kind === 'local_document' && isProvenanceLine(line)) { hiddenProvenance += 1; continue }
1107
+ const rendered = readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1108
+ speaker_raw: line.speaker, text: line.text })
1109
+ // 溯源行号(终末地是 dlg_e12m5_6 这类对话 ID)跟着正文一起给出去,
1110
+ // 引用才能被读者回查到具体那一句,而不只是一串行号。
1111
+ parts.push(line.source_line_id ? `${rendered}〔${line.source_line_id}〕` : rendered)
1077
1112
  }
1078
1113
  if (primary.text) parts.push(primary.text)
1114
+ if (hiddenProvenance) parts.push(`(已省略 ${hiddenProvenance} 行数据来源元信息,行号保持原样。)`)
1079
1115
  parts.push(`引用:${primary.citation}`)
1080
1116
  if (projected.page.has_more && projected.page.continuation) {
1081
- const next = projected.page.continuation
1082
- parts.push(`继续阅读《${primary.title}》,从第 ${next.line} 行开始。`)
1117
+ const next = compactContinuation(projected.page.continuation)
1118
+ parts.push(`继续阅读《${primary.title}》,从第 ${next.start_line} 行开始。`)
1083
1119
  if (next.stage_code && next.story_part) {
1084
1120
  parts.push(`调用:corpus_read(${JSON.stringify({ stage_code: next.stage_code,
1085
- story_part: next.story_part, mode: 'document', line: next.line,
1121
+ story_part: next.story_part, mode: 'document', start_line: next.start_line,
1086
1122
  data_version: next.data_version })})`)
1087
1123
  } else if (next.record_name || next.material) {
1088
- parts.push(`调用:corpus_read(${JSON.stringify(next)})`)
1124
+ parts.push(`调用:corpus_read(${JSON.stringify({ ...next, mode: 'document' })})`)
1089
1125
  } else {
1090
1126
  parts.push(`调用:corpus_read(${JSON.stringify({ title: String(next.title || ''),
1091
- mode: 'document', line: next.line, data_version: next.data_version })})`)
1127
+ mode: 'document', start_line: next.start_line, data_version: next.data_version })})`)
1092
1128
  }
1093
1129
  }
1094
1130
  return [{ type: 'text', text: parts.join('\n') }]
1095
1131
  }
1096
1132
  return [{ type: 'text', text: '[corpus_read:error] 无法投影读取结果' }]
1097
1133
  }
1134
+
1135
+ /** 续读调用只展示短版本号;index.js 接受 ≥8 位前缀并在执行前还原为完整版本。 */
1136
+ function compactContinuation(continuation) {
1137
+ if (!continuation?.data_version) return continuation
1138
+ return { ...continuation, data_version: shortDataVersion(continuation.data_version) }
1139
+ }
1140
+
1141
+ /**
1142
+ * 干员/角色资料由导出脚本写入的溯源行(数据表路径、记录 ID、解锁条件枚举):
1143
+ * 对阅读与引用都没有价值,却占档案约三成篇幅。只在非剧情、非 Wiki 资料中隐藏。
1144
+ */
1145
+ const PROVENANCE_LINE = /^(?:角色 ?ID|来源|来源记录|解锁条件)[::]\s*\S/u
1146
+ function isProvenanceLine(line) {
1147
+ return !line.speaker && line.line_type !== 'dialogue' && PROVENANCE_LINE.test(String(line.text || '').trim())
1148
+ }