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
@@ -18,10 +18,15 @@ const EVIDENCE_LABELS = Object.freeze({
18
18
  derived_timeline: '整理性时间线', entity_projection: '实体关联入口', catalog: '资料目录',
19
19
  })
20
20
 
21
+ // 命中高亮用 ⟦⟧:明日方舟档案正文自身大量使用【代号】【性别】,不能再用【】。
22
+ const HIT_OPEN = '⟦'
23
+ const HIT_CLOSE = '⟧'
24
+
21
25
  function renderLine(line) {
22
26
  const marker = line.role === 'match' ? '>' : line.role === 'constraint' ? '+' : ' '
23
27
  const number = line.line == null ? '' : String(line.line).padStart(4, ' ')
24
- const speaker = line.speaker ? `${line.speaker}:` : ''
28
+ // 与 corpus_read 的行格式一致:「说话人: 台词」。
29
+ const speaker = line.speaker ? `${line.speaker}: ` : ''
25
30
  return `${marker} ${number} ${speaker}${line.rendered_text ?? line.text}${line.truncated ? '(本行已截断)' : ''}`.trimEnd()
26
31
  }
27
32
 
@@ -29,18 +34,47 @@ function normalizeText(value) {
29
34
  return String(value ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
30
35
  }
31
36
 
37
+ /**
38
+ * 在原文上高亮:匹配时按 NFKC + 小写比较(与检索一致),输出保留原文字符。
39
+ * 逐码点建立「归一化偏移 → 原文偏移」映射,避免全角标点被改写。
40
+ */
41
+ function normalizedIndex(text) {
42
+ let normalized = ''
43
+ const starts = []
44
+ const ends = []
45
+ let offset = 0
46
+ let previousSpace = true
47
+ for (const char of text) {
48
+ let piece = char.normalize('NFKC').toLocaleLowerCase()
49
+ if (/^\s+$/u.test(piece)) {
50
+ piece = previousSpace ? '' : ' '
51
+ }
52
+ for (let index = 0; index < piece.length; index += 1) {
53
+ starts.push(offset)
54
+ ends.push(offset + char.length)
55
+ }
56
+ if (piece) previousSpace = piece === ' '
57
+ normalized += piece
58
+ offset += char.length
59
+ }
60
+ return { normalized, starts, ends }
61
+ }
62
+
32
63
  function highlightLiteral(text, query) {
33
- const needle = normalizeText(query)
64
+ const needle = normalizeText(query).toLocaleLowerCase()
34
65
  if (!needle) return text
35
- const haystack = text.toLocaleLowerCase()
36
- const loweredNeedle = needle.toLocaleLowerCase()
66
+ const { normalized, starts, ends } = normalizedIndex(text)
37
67
  const parts = []
38
68
  let cursor = 0
39
- let offset = haystack.indexOf(loweredNeedle)
69
+ let offset = normalized.indexOf(needle)
40
70
  while (offset >= 0) {
41
- parts.push(text.slice(cursor, offset), `【${text.slice(offset, offset + needle.length)}】`)
42
- cursor = offset + needle.length
43
- offset = haystack.indexOf(loweredNeedle, cursor)
71
+ const from = starts[offset]
72
+ const to = ends[offset + needle.length - 1]
73
+ if (from >= cursor) {
74
+ parts.push(text.slice(cursor, from), `${HIT_OPEN}${text.slice(from, to)}${HIT_CLOSE}`)
75
+ cursor = to
76
+ }
77
+ offset = normalized.indexOf(needle, offset + needle.length)
44
78
  }
45
79
  return parts.length ? `${parts.join('')}${text.slice(cursor)}` : text
46
80
  }
@@ -48,26 +82,47 @@ function highlightLiteral(text, query) {
48
82
  function highlightRegex(text, query) {
49
83
  try {
50
84
  const regex = new RegExp(query, 'giu')
51
- return text.replace(regex, (matched) => `【${matched}】`)
85
+ // 检索按 NFKC 文本匹配;展示仍在原文上尽力高亮,匹配不上时保持原文不加标记。
86
+ return text.replace(regex, (matched) => matched ? `${HIT_OPEN}${matched}${HIT_CLOSE}` : matched)
52
87
  } catch {
53
88
  return text
54
89
  }
55
90
  }
56
91
 
92
+ function speakerlessQuery(line, query) {
93
+ const speaker = normalizeText(line.speaker)
94
+ const needle = normalizeText(query)
95
+ if (!speaker || !needle.toLocaleLowerCase().startsWith(`${speaker.toLocaleLowerCase()}:`)) return query
96
+ return needle.slice(speaker.length + 1).trim() || query
97
+ }
98
+
57
99
  function renderedExcerptLine(line, match, options) {
58
100
  if (line.role !== 'match' || !options.query) return line
59
101
  const renderedText = match.match_kind === 'regex'
60
- ? highlightRegex(line.text, options.query) : highlightLiteral(line.text, options.query)
102
+ ? highlightRegex(line.text, options.query)
103
+ : highlightLiteral(line.text, speakerlessQuery(line, options.query))
61
104
  return renderedText === line.text ? line : { ...line, rendered_text: renderedText }
62
105
  }
63
106
 
64
107
  function renderMatch(match, options) {
108
+ // 目录/元数据命中没有正文行:直说"为什么命中",别让调用方以为漏了内容。
109
+ if (match.line_start == null && !(match.excerpt || []).length) {
110
+ const locator = options.documentUid
111
+ ? { document_uid: options.documentUid, mode: 'document' }
112
+ : { title: String(options.documentTitle || ''), mode: 'document' }
113
+ return [`命中方式:标题/元数据命中本篇(该篇没有正文命中行)`,
114
+ `读取:corpus_read(${JSON.stringify(locator)})`].join('\n')
115
+ }
65
116
  const range = match.line_start == null ? ''
66
117
  : `命中:第 ${match.line_start === match.line_end ? match.line_start
67
118
  : `${match.line_start}-${match.line_end}`} 行`
119
+ // 句柄:检索结果直接给出可回读的凭据,Agent 不必先查 document_uid 再读。
120
+ const handle = match.citation_handle
121
+ ? `句柄:${match.citation_handle} · 回到这几行:corpus_read({"citation":"${match.citation_handle}"})`
122
+ : ''
68
123
  return [range, `证据:${EVIDENCE_LABELS[match.evidence_kind] || match.evidence_kind}`,
69
124
  ...(match.excerpt || []).map((line) => renderLine(renderedExcerptLine(line, match, options))),
70
- `引用:${match.citation}`].filter(Boolean).join('\n')
125
+ `引用:${match.citation}`, handle].filter(Boolean).join('\n')
71
126
  }
72
127
 
73
128
  function renderSection(section) {
@@ -84,14 +139,41 @@ function renderEntitySummary(summary) {
84
139
  `引用:${summary.citation}`].filter(Boolean).join('\n')
85
140
  }
86
141
 
142
+ const WIKI_TYPES = ['character_wiki', 'story_wiki', 'character_activity_wiki']
143
+ const WIKI_NOTICE = '引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。'
144
+
145
+ /** 引用资料(泰拉年表、活动时间线)里存的是原始 wikitext / JSON,展开进检索结果只会烧 token。 */
146
+ function isSerializedDataLine(line) {
147
+ const text = String(line.rendered_text ?? line.text ?? '')
148
+ if (text.length > 240) return true
149
+ if (/^\s*[{[]/u.test(text)) return true
150
+ return /"(?:schema_version|source_location|wikitext_line|raw_wikitext)"\s*:/u.test(text)
151
+ }
152
+
153
+ function serializedReferenceBody(document, matches) {
154
+ const lines = matches.flatMap((match) => match.excerpt || [])
155
+ if (!lines.length || !lines.some(isSerializedDataLine)) return null
156
+ if (lines.filter(isSerializedDataLine).length < Math.ceil(lines.length / 2)) return null
157
+ return ['本篇为引用资料,正文是原始 wikitext / JSON,不在检索结果里展开。',
158
+ '查事件年代与顺序请用 timeline_search;确需原文时按已知行号读取:',
159
+ `corpus_read(${JSON.stringify(document.document_uid
160
+ ? { document_uid: document.document_uid, mode: 'document' }
161
+ : { title: document.title, mode: 'document' })})`].join('\n')
162
+ }
163
+
87
164
  function renderDocument(document, options) {
165
+ const serialized = document.resource_type === 'reference'
166
+ ? serializedReferenceBody(document, document.matches || []) : null
88
167
  const body = document.entity_summary ? renderEntitySummary(document.entity_summary)
89
168
  : document.section_content ? renderSection(document.section_content)
90
- : document.matches?.length ? document.matches.map((match) => renderMatch(match, options)).join('\n\n')
91
- : document.available_sections?.length
92
- ? `可用字段:${document.available_sections.join('、')}` : '资料入口'
93
- const wikiNotice = ['character_wiki', 'story_wiki', 'character_activity_wiki']
94
- .includes(document.resource_type)
169
+ : serialized ? serialized
170
+ : document.matches?.length ? document.matches.map((match) => renderMatch(match, {
171
+ ...options, documentUid: document.document_uid, documentTitle: document.title })).join('\n\n')
172
+ : [document.available_sections?.length ? `可用字段:${document.available_sections.join('、')}` : '',
173
+ `读取:corpus_read(${JSON.stringify(document.document_uid
174
+ ? { document_uid: document.document_uid, mode: 'document' }
175
+ : { title: document.title })})`].filter(Boolean).join('\n')
176
+ const wikiNotice = !options.sharedWikiNotice && WIKI_TYPES.includes(document.resource_type)
95
177
  ? '引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。'
96
178
  : ''
97
179
  const game = document.game === 'endfield' ? '终末地'
@@ -104,6 +186,46 @@ function renderDocument(document, options) {
104
186
  .filter(Boolean).join('\n')
105
187
  }
106
188
 
189
+ /** 零命中时按实际使用的过滤条件给出具体放宽方向。 */
190
+ export function zeroHitHints(args = {}) {
191
+ const has = (field) => Array.isArray(args?.[field])
192
+ && args[field].some((item) => typeof item === 'string' && item.trim())
193
+ const hints = []
194
+ if (has('context_terms')) {
195
+ hints.push('context_terms 是强约束:每个词都必须出现在命中行前后 3 行内,不是整篇共现;请先去掉 context_terms 重试。')
196
+ }
197
+ if (has('story_names')) {
198
+ hints.push('story_names 只匹配单关/单篇剧情名(如“鳞的记忆”);活动或章节名(如“昨日海”)请改用 activity_names。')
199
+ }
200
+ if (has('activity_names') || has('collection_names')) {
201
+ hints.push('activity_names / collection_names 需与展示名完全一致;不确定时先省略 query 只按名称列目录核对。')
202
+ }
203
+ if (has('speakers')) {
204
+ hints.push('speakers 只匹配本人亲口台词(已自动展开已登记别名,如 银灰→恩希欧迪斯);提及该角色的旁白或他人台词请改用 entity_names 或 query。')
205
+ const query = normalizeText(args?.query)
206
+ if (query && args.speakers.some((name) => query.includes(normalizeText(name)))) {
207
+ hints.push('query 只匹配台词正文,不含说话人名;按说话人找台词时请省略 query,或只写台词片段。')
208
+ }
209
+ }
210
+ if (has('resource_types') || has('content_types') || has('wiki_sections')) {
211
+ hints.push('resource_types / content_types / wiki_sections 会缩小资料类型;可先移除再看命中分布。')
212
+ }
213
+ if (args?.match_mode === 'regex') hints.push('regex 模式按受限正则匹配;可改回默认 literal 并缩短为关键短语。')
214
+ return hints
215
+ }
216
+
217
+ /** 本页命中的游戏分布。跨游戏命中时提醒收窄,避免同名实体(如「博士」)互相污染。 */
218
+ function gameDistributionLine(byGame) {
219
+ const labels = { arknights: '明日方舟', endfield: '终末地' }
220
+ const parts = Object.entries(byGame || {})
221
+ .filter(([game, count]) => labels[game] && Number.isInteger(count) && count > 0)
222
+ .map(([game, count]) => `${labels[game]} ${count} 篇`)
223
+ if (!parts.length) return ''
224
+ const cross = Object.keys(byGame || {}).filter((game) => labels[game]).length > 1
225
+ return `命中分布(按游戏,本页):${parts.join(' · ')}`
226
+ + (cross ? '。跨游戏命中,做人物向研究时请用 games 限定' : '')
227
+ }
228
+
107
229
  export function projectSearch(value, options = {}) {
108
230
  const documents = value?.documents || []
109
231
  const matches = documents.reduce((total, document) => total
@@ -118,14 +240,21 @@ export function projectSearch(value, options = {}) {
118
240
  ? `扫描尚未穷尽。继续时保留本次搜索词和过滤条件,并设置 after: ${JSON.stringify(value.page.next_after)}。`
119
241
  : value?.page?.next_cursor
120
242
  ? `旧版分页尚未结束。继续时仅提交 cursor: ${JSON.stringify(value.page.next_cursor)}。` : ''
243
+ const distribution = gameDistributionLine(value?.by_game)
244
+ const hints = zeroHitHints(options.args)
245
+ // 覆盖率:让"没有找到"带上检查范围,成为可引用的否定证据。
246
+ const scopeText = Number.isInteger(value?.scope?.documents)
247
+ ? `本地 ${value.scope.documents} 篇 / ${Number(value.scope.lines || 0)} 行`
248
+ : ''
121
249
  const zero = documents.length ? '' : exhausted
122
- ? '已检查完整检索范围,没有找到。请检查展示名、缩短连续字面串或移除冲突过滤条件。'
250
+ ? [`已检查完整检索范围${scopeText ? `(${scopeText})` : ''},没有找到。请检查展示名、缩短连续字面串或移除冲突过滤条件。`,
251
+ ...hints.map((hint) => `- ${hint}`)].join('\n')
123
252
  : value?.page?.next_after
124
253
  ? '本页没有发现命中文档,但扫描位置已经推进;这不是全库零命中。'
125
254
  : '该旧版分页链已经结束,但不能证明资料范围已经穷尽;需要完整性时请重新搜索。'
126
255
  const complete = exhausted && documents.length
127
256
  ? Number.isInteger(value.page.total_documents)
128
- ? `已检查完整检索范围,共匹配 ${value.page.total_documents} 篇资料。`
257
+ ? `已检查完整检索范围${scopeText ? `(${scopeText})` : ''},共匹配 ${value.page.total_documents} 篇资料。`
129
258
  : '已扫描至当前检索范围末尾。'
130
259
  : ''
131
260
  const warnings = (value?.warnings || []).length
@@ -134,6 +263,12 @@ export function projectSearch(value, options = {}) {
134
263
  ? `## 再旅者对应关系(人工审校附属字段)\n${value.retraveler_relations.map((item) =>
135
264
  `- 终末地角色:${item.endfield_name};泰拉记忆原型:${item.terra_memory_prototype || '未登记'};` +
136
265
  `状态:${item.relation_status}。这是跨游戏关系,不是人物别名。`).join('\n')}` : ''
137
- return [heading, warnings, relations, ...documents.map((document) => renderDocument(document, options)), zero, complete, next]
266
+ // 多篇 Wiki 同屏时引文提示只说一次,避免每篇重复同一句话。
267
+ const wikiCount = documents.filter((document) => WIKI_TYPES.includes(document.resource_type)).length
268
+ const sharedWikiNotice = wikiCount > 1
269
+ const documentOptions = sharedWikiNotice ? { ...options, sharedWikiNotice } : options
270
+ return [heading, distribution, sharedWikiNotice ? `${WIKI_NOTICE}(适用于下列所有 Wiki 条目)` : '',
271
+ warnings, relations,
272
+ ...documents.map((document) => renderDocument(document, documentOptions)), zero, complete, next]
138
273
  .filter(Boolean).join('\n\n')
139
274
  }
package/src/search.js CHANGED
@@ -3,7 +3,8 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
3
3
  import { deflateRawSync, inflateRawSync } from 'node:zlib'
4
4
  import { activityMatches, aliasesFor } from './timeline.js'
5
5
  import { assertCorpusVersion, corpusVersionSnapshot,
6
- DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle } from './store.js'
6
+ DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle,
7
+ DATA_VERSION_REF_PATTERN, dataVersionMatches, shortDataVersion } from './store.js'
7
8
  import { projectSearch } from './search-projection.js'
8
9
  import { wikiActivityName, wikiActivityRanges, wikiCharacterName, wikiDocumentRole,
9
10
  wikiSectionAt, wikiSectionRanges } from './wiki.js'
@@ -51,7 +52,14 @@ const SEARCH_POLICY_FINGERPRINT = createHash('sha256').update(JSON.stringify({
51
52
  matchingPolicyVersion: MATCHING_POLICY_VERSION,
52
53
  })).digest('base64url').slice(0, 16)
53
54
  const FILTER_LIMIT = 16
54
- const DATA_VERSION_PATTERN = /^[0-9a-f]{64}$/
55
+ // The high word identifies the installed pack set; the low word retains the
56
+ // candidate ordinal. Keep these bit assignments fixed across plugin releases.
57
+ const PAGE_SCOPE_PACKS = Object.freeze(['official_game', 'endfield_official_game',
58
+ 'reviewed_wiki', 'terra_journey', 'endfield_reviewed_knowledge', 'entities', 'references'])
59
+ const PAGE_POSITION_STRIDE = 2 ** 32
60
+ const MAX_CANDIDATE_POSITION = 10_000_000
61
+ const MAX_PAGE_POSITION = (2 ** PAGE_SCOPE_PACKS.length) * PAGE_POSITION_STRIDE
62
+ + MAX_CANDIDATE_POSITION
55
63
  /** 过滤单项最长 512 码点:与上述项数上限共同保证压缩游标可被重新解码。 */
56
64
  const FILTER_ITEM_LIMIT = 512
57
65
  /**
@@ -231,14 +239,14 @@ function normalizedRequest(raw = {}) {
231
239
  const resourceType = normalizeText(raw.after.resource_type)
232
240
  const title = normalizeText(raw.after.title)
233
241
  const position = raw.after.position
234
- if (typeof dataVersion !== 'string' || !DATA_VERSION_PATTERN.test(dataVersion)
242
+ if (typeof dataVersion !== 'string' || !DATA_VERSION_REF_PATTERN.test(dataVersion)
235
243
  || !resourceType || !title || !Number.isSafeInteger(position) || position < 0
236
- || position > 10_000_000
244
+ || position > MAX_PAGE_POSITION || position % PAGE_POSITION_STRIDE > MAX_CANDIDATE_POSITION
237
245
  || Object.keys(raw.after).some((key) => ![
238
246
  'data_version', 'resource_type', 'title', 'position',
239
247
  ].includes(key))) {
240
248
  throw Object.assign(new Error(
241
- 'after 必须包含且只能包含 64 位小写 data_version、resource_type、title 和 0..10000000 的安全整数 position'),
249
+ 'after 必须包含且只能包含 data_version(续页给出的版本,至少 8 位)、resource_type、title 和续页给出的安全整数 position'),
242
250
  { code: 'INVALID_REQUEST' })
243
251
  }
244
252
  after = { data_version: dataVersion, resource_type: resourceType, title, position }
@@ -307,17 +315,46 @@ function withoutAfter(request) {
307
315
  return searchRequest
308
316
  }
309
317
 
318
+ function pageScopeMask(store) {
319
+ if (!store.scopeFingerprint) return null
320
+ if (!(store.packs instanceof Map)
321
+ || [...store.packs.keys()].some((id) => !PAGE_SCOPE_PACKS.includes(id))) {
322
+ throw Object.assign(new Error('无法识别当前安装范围,请重新安装资料包'),
323
+ { code: 'PAGE_ANCHOR_INVALID' })
324
+ }
325
+ return PAGE_SCOPE_PACKS.reduce((mask, id, index) =>
326
+ mask + (store.packs.has(id) ? 2 ** index : 0), 0)
327
+ }
328
+
329
+ function encodePagePosition(store, ordinal) {
330
+ const mask = pageScopeMask(store)
331
+ return mask === null ? ordinal : (mask + 1) * PAGE_POSITION_STRIDE + ordinal
332
+ }
333
+
334
+ function decodePagePosition(store, position) {
335
+ const mask = pageScopeMask(store)
336
+ const encodedMask = Math.floor(position / PAGE_POSITION_STRIDE) - 1
337
+ const ordinal = position % PAGE_POSITION_STRIDE
338
+ if ((mask === null ? encodedMask !== -1 : encodedMask !== mask)
339
+ || ordinal > MAX_CANDIDATE_POSITION) {
340
+ throw Object.assign(new Error('分页锚点绑定的安装范围已经变化,请重新搜索'),
341
+ { code: 'PAGE_ANCHOR_MISMATCH' })
342
+ }
343
+ return ordinal
344
+ }
345
+
310
346
  async function checkpointAfterTitle(store, after, request, { signal, deadline } = {}) {
311
347
  assertSearchActive(signal, deadline, '分页锚点校验')
312
- if (after.data_version !== store.dataVersion) {
348
+ if (!dataVersionMatches(after.data_version, store.dataVersion)) {
313
349
  throw Object.assign(new Error('分页锚点绑定到另一个资料版本,请重新搜索'),
314
350
  { code: 'PAGE_ANCHOR_VERSION_MISMATCH', retryable: false })
315
351
  }
352
+ const position = decodePagePosition(store, after.position)
316
353
  const regex = request.match_mode === 'regex'
317
354
  ? new RegExp(safeRegex(request.query).source, 'iu') : null
318
355
  const candidates = await candidateDocumentIds(store, request, regex, { signal, deadline })
319
356
  assertSearchActive(signal, deadline, '分页锚点校验')
320
- const documentId = candidates[after.position]
357
+ const documentId = candidates[position]
321
358
  const location = documentId ? store.documents.get(documentId) : null
322
359
  if (!location) throw Object.assign(new Error('找不到分页锚点,请重新搜索'),
323
360
  { code: 'PAGE_ANCHOR_NOT_FOUND' })
@@ -326,7 +363,7 @@ async function checkpointAfterTitle(store, after, request, { signal, deadline }
326
363
  throw Object.assign(new Error('分页锚点与当前资料版本不匹配,请重新搜索'),
327
364
  { code: 'PAGE_ANCHOR_MISMATCH' })
328
365
  }
329
- return { nextCandidateIndex: after.position + 1, matchedDocumentsSoFar: 0,
366
+ return { nextCandidateIndex: position + 1, matchedDocumentsSoFar: 0,
330
367
  matchedCountKnown: false }
331
368
  }
332
369
 
@@ -352,8 +389,8 @@ async function exposeTitleContinuation(store, result, { signal, deadline } = {})
352
389
  const document = documentId ? store.documents.get(documentId)?.document : null
353
390
  if (!document) throw Object.assign(new Error('无法生成下一页的标题锚点'),
354
391
  { code: 'PAGE_ANCHOR_INVALID' })
355
- nextAfter = { data_version: store.dataVersion, resource_type: publicResourceType(document),
356
- title: naturalDocumentTitle(document), position: anchorOrdinal }
392
+ nextAfter = { data_version: shortDataVersion(store.dataVersion), resource_type: publicResourceType(document),
393
+ title: naturalDocumentTitle(document), position: encodePagePosition(store, anchorOrdinal) }
357
394
  }
358
395
  return { ...result, page: { ...page, next_after: nextAfter } }
359
396
  }
@@ -401,7 +438,8 @@ function validCursorSignature(body, received, secret, bytes = 32) {
401
438
  }
402
439
 
403
440
  async function encodeOffsetCursor(store, request, offset) {
404
- const payload = [3, cursorVersionTag(store.dataVersion), offset, compactCursorRequest(request)]
441
+ const payload = [3, cursorVersionTag(store.dataVersion), offset,
442
+ compactCursorRequest(request), store.scopeFingerprint ?? null]
405
443
  const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
406
444
  const encoded = compressed.toString('base64url')
407
445
  const body = `s3.${encoded}`
@@ -413,7 +451,7 @@ async function encodeOffsetCursor(store, request, offset) {
413
451
  async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocumentsSoFar) {
414
452
  const payload = [4, cursorVersionTag(store.dataVersion), DOCUMENT_ORDERING_VERSION,
415
453
  SEARCH_POLICY_FINGERPRINT, nextCandidateIndex, matchedDocumentsSoFar,
416
- compactCursorRequest(request)]
454
+ compactCursorRequest(request), store.scopeFingerprint ?? null]
417
455
  const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
418
456
  const encoded = compressed.toString('base64url')
419
457
  const body = `s4.${encoded}`
@@ -423,6 +461,13 @@ async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocum
423
461
  return `${body}.${signature}`
424
462
  }
425
463
 
464
+ function assertCursorScope(store, scope) {
465
+ if ((scope ?? null) !== (store.scopeFingerprint ?? null)) {
466
+ throw Object.assign(new Error('cursor 绑定的安装范围已经变化,请重新搜索'),
467
+ { code: 'CURSOR_VERSION_MISMATCH' })
468
+ }
469
+ }
470
+
426
471
  async function decodeCursor(store, cursor) {
427
472
  if (String(cursor).length > CURSOR_MAX_LENGTH) {
428
473
  throw Object.assign(new Error('cursor 超过长度上限'), { code: 'CURSOR_INVALID' })
@@ -450,6 +495,7 @@ async function decodeCursor(store, cursor) {
450
495
  throw Object.assign(new Error('cursor 绑定到另一个资料版本'),
451
496
  { code: 'CURSOR_VERSION_MISMATCH' })
452
497
  }
498
+ assertCursorScope(store, value[7])
453
499
  if (value[2] !== DOCUMENT_ORDERING_VERSION || value[3] !== SEARCH_POLICY_FINGERPRINT) {
454
500
  throw Object.assign(new Error('cursor 绑定的排序或搜索策略已经变化,请重新搜索'),
455
501
  { code: 'CURSOR_POLICY_MISMATCH' })
@@ -477,6 +523,7 @@ async function decodeCursor(store, cursor) {
477
523
  { code: value?.[1] !== cursorVersionTag(store.dataVersion)
478
524
  ? 'CURSOR_VERSION_MISMATCH' : 'CURSOR_INVALID' })
479
525
  }
526
+ assertCursorScope(store, value[4])
480
527
  return { kind: 'legacy', request, offset: value[2] }
481
528
  }
482
529
 
@@ -496,6 +543,7 @@ async function decodeCursor(store, cursor) {
496
543
  || !Number.isInteger(value.offset) || value.offset < 0) {
497
544
  throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
498
545
  }
546
+ assertCursorScope(store, value.scope_fingerprint)
499
547
  return { kind: 'legacy', request: value.request, offset: value.offset }
500
548
  }
501
549
 
@@ -520,7 +568,53 @@ function documentMatches(document, speakers, filters) {
520
568
  if (!filters.activity_names.some((name) => activityMatches(activity, name, { exact }))) return false
521
569
  }
522
570
  if (filters.wiki_sections.length && !wikiRole) return false
523
- return !filters.speakers.length || filters.speakers.some((speaker) => speakers.includes(speaker))
571
+ return !filters.speakers.length || speakers.some((speaker) => speakerAllowed(filters, speaker))
572
+ }
573
+
574
+ /**
575
+ * speakers 过滤按别名组匹配:请求「银灰」也命中原文说话人「恩希欧迪斯」。
576
+ * 展开结果挂在不可枚举属性上,游标编码仍只保存用户原始输入。
577
+ */
578
+ const SPEAKER_ALIASES = Symbol('speakerAliases')
579
+ const SPEAKER_CANONICALS = Symbol('speakerCanonicals')
580
+
581
+ async function expandSpeakerAliases(store, request) {
582
+ const speakers = request?.filters?.speakers || []
583
+ if (!speakers.length) return request
584
+ let groups = []
585
+ try { groups = await aliasesFor(store, speakers) } catch { groups = [] }
586
+ const names = new Set(speakers)
587
+ const canonicals = new Set()
588
+ for (const group of groups) {
589
+ if (group.aliases.length > 1 || group.games.length) canonicals.add(normalizeText(group.canonical))
590
+ for (const alias of group.aliases) {
591
+ const value = normalizeText(alias)
592
+ if (!value) continue
593
+ names.add(value)
594
+ // 原文说话人常只写名不写姓:「恩希欧迪斯·希瓦艾什」→「恩希欧迪斯」。
595
+ const given = value.split(/[·・•]/u)[0].trim()
596
+ if (given && given !== value && [...given].length >= 2) names.add(given)
597
+ }
598
+ }
599
+ Object.defineProperty(request.filters, SPEAKER_ALIASES, { value: names, enumerable: false })
600
+ Object.defineProperty(request.filters, SPEAKER_CANONICALS, { value: canonicals, enumerable: false })
601
+ return request
602
+ }
603
+
604
+ /** 行级兜底:资料包已把说话人链接到实体时,按实体规范名匹配。 */
605
+ function speakerOccurrenceAllowed(filters, line) {
606
+ const canonicals = filters[SPEAKER_CANONICALS]
607
+ if (!canonicals?.size) return false
608
+ return (line.entity_occurrences ?? []).some((item) => item.evidence_kind === 'speaker'
609
+ && canonicals.has(normalizeText(item.canonical_name)))
610
+ }
611
+
612
+ function speakerAllowed(filters, speaker) {
613
+ if (!filters.speakers.length) return true
614
+ const value = normalizeText(speaker)
615
+ if (!value) return false
616
+ return (filters[SPEAKER_ALIASES] || filters.speakers).has?.(value)
617
+ ?? filters.speakers.includes(value)
524
618
  }
525
619
 
526
620
  function needsWikiActivityHydration(document) {
@@ -572,6 +666,29 @@ function lineContent(line) {
572
666
  ? text.slice(prefix.length).trimStart() : text
573
667
  }
574
668
 
669
+ /** 展示用正文:只去掉说话人前缀,保留原文全角标点与空白,便于逐字复制引用。 */
670
+ function displayLineContent(line) {
671
+ const text = String(line.text ?? '').trim()
672
+ const speaker = String(line.speaker_raw ?? '').trim()
673
+ if (line.line_type !== 'dialogue' || !speaker) return text
674
+ const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
675
+ return text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
676
+ }
677
+
678
+ /**
679
+ * 用户常把搜索结果中的「说话人: 台词」整行复制回来搜索;台词正文不含说话人,
680
+ * 因此当 query 以本行说话人加冒号开头时,剥掉前缀再匹配正文。
681
+ */
682
+ function speakerlessQuery(line, query) {
683
+ const speaker = normalizeText(line.speaker_raw)
684
+ if (!query || !speaker || line.line_type !== 'dialogue') return null
685
+ const lowered = query.toLowerCase()
686
+ const prefix = `${speaker.toLowerCase()}:`
687
+ if (!lowered.startsWith(prefix)) return null
688
+ const rest = query.slice(prefix.length).trim()
689
+ return rest || null
690
+ }
691
+
575
692
  function matchesText(text, query, mode, regex) {
576
693
  if (!query) return true
577
694
  if (mode === 'regex') return regex.test(normalizeText(text))
@@ -638,18 +755,24 @@ function entityOccurrence(record, line, entityGroups, queryPresent, queryRange =
638
755
 
639
756
  function lineMatch(record, index, request, regex, entityGroups, bounds = null) {
640
757
  const line = record.lines[index]
641
- if (request.filters.speakers.length && !request.filters.speakers.includes(line.speaker_raw)) return null
758
+ if (!speakerAllowed(request.filters, line.speaker_raw)
759
+ && !speakerOccurrenceAllowed(request.filters, line)) return null
642
760
  const content = lineContent(line)
643
- if (!matchesText(content, request.query, request.match_mode, regex)) return null
761
+ let query = request.query
762
+ if (!matchesText(content, query, request.match_mode, regex)) {
763
+ const stripped = request.match_mode === 'regex' ? null : speakerlessQuery(line, query)
764
+ if (!stripped || !matchesText(content, stripped, request.match_mode, regex)) return null
765
+ query = stripped
766
+ }
644
767
  let start = null
645
768
  let end = null
646
- if (request.query) {
769
+ if (query) {
647
770
  if (regex) {
648
771
  const match = new RegExp(regex.source, regex.flags).exec(normalizeText(content))
649
772
  if (match) { start = match.index; end = match.index + match[0].length }
650
773
  } else {
651
774
  const haystack = normalizeText(content).toLowerCase()
652
- const needle = request.query.toLowerCase()
775
+ const needle = query.toLowerCase()
653
776
  start = haystack.indexOf(needle)
654
777
  // 区间终点必须用归一化后 needle 的长度:toLocaleLowerCase 可能改变长度
655
778
  // (如土耳其语 İ → i̇),用原 query 长度会让实体邻近度判定错位。
@@ -742,7 +865,7 @@ function isExactOfficialArchiveTitle(document, request) {
742
865
  }
743
866
 
744
867
  function lineAllowed(line, filters) {
745
- return !filters.speakers.length || filters.speakers.includes(line.speaker_raw)
868
+ return speakerAllowed(filters, line.speaker_raw) || speakerOccurrenceAllowed(filters, line)
746
869
  }
747
870
 
748
871
  function readableAnchor(record, filters, ranges = null) {
@@ -845,7 +968,7 @@ function excerpt(record, item, bounds = null) {
845
968
  item.passage_end + PREVIEW_OPTIONS.after_lines)
846
969
  let truncated = false
847
970
  const lines = record.lines.slice(start - 1, end).map((line) => {
848
- const raw = String(lineContent(line) || '')
971
+ const raw = displayLineContent(line)
849
972
  const text = [...raw].length > PREVIEW_OPTIONS.max_chars_per_line
850
973
  ? `${[...raw].slice(0, PREVIEW_OPTIONS.max_chars_per_line).join('')}…` : raw
851
974
  if (text !== raw) truncated = true
@@ -1003,6 +1126,8 @@ async function executeLegacySearch(store, request, offset,
1003
1126
  citation: titleOnly ? `《${title}》`
1004
1127
  : sectionName ? `《${title}》Wiki·${sectionName}`
1005
1128
  : `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
1129
+ ...(titleOnly || sectionName ? {}
1130
+ : { citation_handle: `${documentUid(metadata.document_id)}#L${lineStart}` }),
1006
1131
  })
1007
1132
  returnedChars += shown.characters
1008
1133
  }
@@ -1017,6 +1142,7 @@ async function executeLegacySearch(store, request, offset,
1017
1142
  assertSearchActive(signal, deadline, '分页游标生成')
1018
1143
  return {
1019
1144
  result_kind: resultKind, documents,
1145
+ by_game: gameDistribution(documents),
1020
1146
  page: { returned_documents: documents.length,
1021
1147
  total_relation: 'unknown', has_more: hasMore, exhausted: false,
1022
1148
  next_cursor: nextCursor },
@@ -1095,6 +1221,19 @@ async function candidateDocumentIds(store, request, regex, { signal, deadline }
1095
1221
  indexed = await store.findDocumentsByShortLiteral?.(request.query,
1096
1222
  { signal, deadline, packIds: scopedPackIds }) ?? null
1097
1223
  }
1224
+ // 「说话人: 台词」整行复制搜索:倒排里没有说话人前缀,另按冒号后的台词补一次候选。
1225
+ const colon = request.query.indexOf(':')
1226
+ const spoken = colon > 0 ? request.query.slice(colon + 1).trim() : ''
1227
+ if (indexed !== null && spoken && [...request.query.slice(0, colon)].length <= FILTER_ITEM_LIMIT) {
1228
+ const spokenNgrams = ngramsFor(spoken)
1229
+ let extra = spokenNgrams.length && indexedLookup
1230
+ ? await indexedLookup.call(store, spokenNgrams, { signal, deadline, packIds: scopedPackIds }) : null
1231
+ if (extra === null && spokenNgrams.length && [...spokenNgrams[0]].length < 3) {
1232
+ extra = await store.findDocumentsByShortLiteral?.(spoken,
1233
+ { signal, deadline, packIds: scopedPackIds }) ?? null
1234
+ }
1235
+ indexed = extra === null ? null : [...new Set([...indexed, ...extra])]
1236
+ }
1098
1237
  assertSearchActive(signal, deadline, '候选文档发现')
1099
1238
  if (indexed === null) return prioritizeOfficialTitle(scopedIds)
1100
1239
  return prioritizeOfficialTitle(
@@ -1352,6 +1491,8 @@ function buildPublicDocument(store, group, resultKind, request, budget = Infinit
1352
1491
  citation: titleOnly ? `《${title}》`
1353
1492
  : sectionName ? `《${title}》Wiki·${sectionName}`
1354
1493
  : `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
1494
+ ...(titleOnly || sectionName ? {}
1495
+ : { citation_handle: `${documentUid(metadata.document_id)}#L${lineStart}` }),
1355
1496
  })
1356
1497
  characters += shownCharacters
1357
1498
  }
@@ -1359,6 +1500,17 @@ function buildPublicDocument(store, group, resultKind, request, budget = Infinit
1359
1500
  return { document: result, characters, reasons }
1360
1501
  }
1361
1502
 
1503
+ /** 本页命中的游戏分布:同名实体跨游戏时,让调用方一眼看出该收窄 games。 */
1504
+ function gameDistribution(documents) {
1505
+ const counts = {}
1506
+ for (const document of documents) {
1507
+ const game = document?.game
1508
+ if (game !== 'arknights' && game !== 'endfield') continue
1509
+ counts[game] = (counts[game] || 0) + 1
1510
+ }
1511
+ return counts
1512
+ }
1513
+
1362
1514
  async function executeScanSearch(store, request, checkpoint,
1363
1515
  { signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
1364
1516
  const started = Date.now()
@@ -1433,6 +1585,9 @@ async function executeScanSearch(store, request, checkpoint,
1433
1585
  return {
1434
1586
  result_kind: resultKind,
1435
1587
  documents,
1588
+ by_game: gameDistribution(documents),
1589
+ // 本次检索覆盖的本地资料规模:零命中时据此说明"检查了多少篇/行"。
1590
+ ...(typeof store.corpusTotals === 'function' ? { scope: store.corpusTotals() } : {}),
1436
1591
  page: {
1437
1592
  returned_documents: documents.length,
1438
1593
  ...(exhausted && countKnown ? { total_documents: matchedDocuments } : {}),
@@ -1464,7 +1619,7 @@ export async function executeSearch(store, raw,
1464
1619
  snapshot = corpusVersionSnapshot(store)
1465
1620
  deadline = Date.now() + SEARCH_TIMEOUT_MS
1466
1621
  assertSearchActive(signal, deadline)
1467
- const normalized = normalizedRequest(raw)
1622
+ const normalized = await expandSpeakerAliases(store, normalizedRequest(raw))
1468
1623
  const attachCorpusWarnings = (value, request) => {
1469
1624
  assertCorpusVersion(store, snapshot)
1470
1625
  if (value?.status === 'error' || !(store.packs instanceof Map)) return value
@@ -1480,6 +1635,7 @@ export async function executeSearch(store, raw,
1480
1635
  if (normalized.cursor) {
1481
1636
  assertSearchActive(signal, deadline, '分页游标校验')
1482
1637
  const decoded = await decodeCursor(store, normalized.cursor)
1638
+ await expandSpeakerAliases(store, decoded.request)
1483
1639
  assertSearchActive(signal, deadline, '分页游标校验')
1484
1640
  const requestedGames = decoded.request.filters?.games?.length
1485
1641
  ? decoded.request.filters.games : ['arknights', 'endfield']
@@ -1517,6 +1673,6 @@ export async function executeSearch(store, raw,
1517
1673
  export function renderSearch(args, value) {
1518
1674
  if (value?.error) return [{ type: 'text', text: `[corpus_search:error] ${value.error.code}: ${value.error.message}` }]
1519
1675
  return [{ type: 'text', text: projectSearch(value, {
1520
- query: normalizeText(args?.query),
1676
+ query: normalizeText(args?.query), args,
1521
1677
  }) }]
1522
1678
  }