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.
- package/README.en.md +100 -23
- package/README.md +82 -18
- package/bin/build-mainline-catalog.py +195 -0
- package/contracts/corpus_i18n_v1.schema.json +10 -12
- package/contracts/corpus_tools_v1.schema.json +30 -1
- package/docs/endfield-mainline-navigation.md +45 -0
- package/lib/client.js +157 -27
- package/lib/skins/common.css +12 -2
- package/lib/skins/endfield-aic.css +1 -1
- package/lib/skins/prts-agent.css +9 -7
- package/package.json +7 -3
- package/presets/definition.js +1 -1
- package/presets/prts/preset.yml +1 -1
- package/resources/endfield-mainline.json +4632 -0
- package/skills/prts-composition/SKILL.md +3 -1
- package/skills/prts-investigation/SKILL.md +19 -113
- package/skills/prts-investigation/references/board-guide.md +131 -0
- package/skills/prts-retrieval/SKILL.md +4 -2
- package/skills/prts-retrieval/references/cloud-tools.md +3 -1
- package/skills/prts-retrieval/references/local-tools.md +3 -3
- package/skills/prts-retrieval/references/module-arknights.md +1 -1
- package/skills/prts-retrieval/references/module-dual.md +1 -1
- package/skills/prts-retrieval/references/module-endfield.md +3 -0
- package/skills/prts-retrieval/references/tools-runtime.md +22 -10
- package/src/cloud-projection.js +23 -2
- package/src/cloud.js +24 -0
- package/src/entity-recognizer.js +26 -5
- package/src/entity-routing.js +18 -4
- package/src/evidence-state.js +4 -2
- package/src/i18n.js +79 -30
- package/src/index.js +346 -162
- package/src/installer.js +86 -14
- package/src/investigation-tools.js +40 -17
- package/src/navigation.js +342 -0
- package/src/read-args.js +86 -0
- package/src/read.js +66 -15
- package/src/search-projection.js +154 -19
- package/src/search.js +177 -21
- package/src/skill.js +25 -16
- package/src/skin-capabilities.js +28 -0
- package/src/store.js +127 -3
- package/src/timeline.js +86 -2
- package/src/tool-args.js +53 -0
- package/src/ui.js +158 -34
- 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
|
+
}
|
package/src/read-args.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
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.
|
|
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',
|
|
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',
|
|
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
|
+
}
|