prts-terrarchive 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +52 -18
  2. package/README.md +49 -16
  3. package/bin/build-mainline-catalog.py +195 -0
  4. package/contracts/corpus_i18n_v1.schema.json +1 -3
  5. package/contracts/corpus_tools_v1.schema.json +30 -1
  6. package/docs/endfield-mainline-navigation.md +45 -0
  7. package/lib/client.js +154 -27
  8. package/lib/skins/common.css +12 -2
  9. package/lib/skins/endfield-aic.css +1 -1
  10. package/lib/skins/prts-agent.css +9 -7
  11. package/package.json +7 -3
  12. package/resources/endfield-mainline.json +4632 -0
  13. package/skills/prts-investigation/SKILL.md +1 -1
  14. package/skills/prts-investigation/references/board-guide.md +1 -1
  15. package/skills/prts-retrieval/SKILL.md +2 -0
  16. package/skills/prts-retrieval/references/cloud-tools.md +3 -1
  17. package/skills/prts-retrieval/references/local-tools.md +3 -3
  18. package/skills/prts-retrieval/references/module-arknights.md +1 -1
  19. package/skills/prts-retrieval/references/module-dual.md +1 -1
  20. package/skills/prts-retrieval/references/module-endfield.md +3 -0
  21. package/skills/prts-retrieval/references/tools-runtime.md +22 -10
  22. package/src/cloud-projection.js +23 -2
  23. package/src/cloud.js +24 -0
  24. package/src/entity-recognizer.js +26 -5
  25. package/src/entity-routing.js +18 -4
  26. package/src/evidence-state.js +4 -2
  27. package/src/i18n.js +70 -15
  28. package/src/index.js +304 -98
  29. package/src/installer.js +86 -14
  30. package/src/investigation-tools.js +7 -4
  31. package/src/navigation.js +342 -0
  32. package/src/read-args.js +86 -0
  33. package/src/read.js +66 -15
  34. package/src/search-projection.js +154 -19
  35. package/src/search.js +177 -21
  36. package/src/store.js +127 -3
  37. package/src/timeline.js +86 -2
  38. package/src/tool-args.js +53 -0
  39. package/src/ui.js +158 -34
  40. package/src/web-guidance.js +14 -2
package/src/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
  }
package/src/store.js CHANGED
@@ -294,6 +294,34 @@ function characterMaterialKey(game, characterName, material) {
294
294
  return `${String(game || '')}\0${normalizedLookupText(characterName)}\0${String(material || '')}`
295
295
  }
296
296
 
297
+ /** 标题宽松比较键:NFKC、小写、去空白与常见间隔符。 */
298
+ export function looseTitleKey(value) {
299
+ return String(value ?? '').normalize('NFKC').toLowerCase()
300
+ .replace(/[\s·・•‧∙⋅_\-—–]+/gu, '')
301
+ }
302
+
303
+ function sharedBigrams(left, right) {
304
+ const grams = (text) => {
305
+ const chars = [...text]
306
+ if (chars.length < 2) return new Set(chars)
307
+ return new Set(chars.slice(1).map((char, index) => chars[index] + char))
308
+ }
309
+ const a = grams(left)
310
+ const b = grams(right)
311
+ if (!a.size || !b.size) return 0
312
+ let shared = 0
313
+ for (const gram of a) if (b.has(gram)) shared += 1
314
+ return shared / a.size
315
+ }
316
+
317
+ function isSubsequence(needle, haystack) {
318
+ const chars = [...needle]
319
+ if (chars.length < 2) return false
320
+ let index = 0
321
+ for (const char of haystack) if (char === chars[index]) index += 1
322
+ return index === chars.length
323
+ }
324
+
297
325
  function naturalDocumentTitleBase(document = {}, { includeOperatorRecordSegment = true } = {}) {
298
326
  const displayTitle = publicMetadataText(document.display_title)
299
327
  if (documentGame(document) === 'endfield' && document.game) return displayTitle
@@ -449,6 +477,8 @@ export class CorpusStore {
449
477
  this._loaded = false
450
478
  this.releaseId = null
451
479
  this.dataVersion = null
480
+ this.installationId = null
481
+ this.scopeFingerprint = null
452
482
  /** @type {Map<string, { packId: string, shardPath: string, index: number }>} */
453
483
  this.documents = new Map()
454
484
  /** @type {string[]} 全局稳定 document_ordinal → document_id。 */
@@ -535,6 +565,8 @@ export class CorpusStore {
535
565
  this._loaded = false
536
566
  this.releaseId = null
537
567
  this.dataVersion = null
568
+ this.installationId = null
569
+ this.scopeFingerprint = null
538
570
  this.documents.clear()
539
571
  this.documentOrder = []
540
572
  this.prefixIndex.clear()
@@ -560,6 +592,7 @@ export class CorpusStore {
560
592
  this.unstableChars = null
561
593
  this._timelineRows = null
562
594
  this._aliasGroups = null
595
+ this._looseTitleIndex = null
563
596
  }
564
597
 
565
598
  async _init(generation) {
@@ -568,7 +601,7 @@ export class CorpusStore {
568
601
  // Store 是 Host 权限下的持久文件入口。每次装载都先验证 release id、声明的
569
602
  // 全部 pack、资源路径/尺寸/哈希与符号链接,再读取任何压缩内容。
570
603
  const validated = await validateLocalRelease(this.releasesDir, releaseId,
571
- { verifyHashes: true, details: true })
604
+ { verifyHashes: true, details: true, allowPartial: true })
572
605
  const { manifest: releaseManifest, packManifests, releaseDir } = validated
573
606
  if (current.data_version != null && current.data_version !== releaseManifest.data_version) {
574
607
  throw new Error('CorpusStore: current.json data_version does not match release-manifest')
@@ -594,7 +627,8 @@ export class CorpusStore {
594
627
  }
595
628
  const stableChars = new Set()
596
629
 
597
- const entries = releaseManifest.packs.map((descriptor) => ({
630
+ const installedPackIds = validated.installedPackIds ?? releaseManifest.packs.map((pack) => pack.pack_id)
631
+ const entries = releaseManifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id)).map((descriptor) => ({
598
632
  name: descriptor.pack_id,
599
633
  manifestPath: descriptor.manifest_path,
600
634
  })).sort(packOrder)
@@ -742,6 +776,9 @@ export class CorpusStore {
742
776
  if (generation !== this._generation) return false
743
777
  this.releaseId = next.releaseId
744
778
  this.dataVersion = next.dataVersion
779
+ this.installationId = current.installation_id ?? null
780
+ this.scopeFingerprint = sha256(JSON.stringify([next.dataVersion, [...installedPackIds].sort()]))
781
+ this._looseTitleIndex = null
745
782
  this.documents = next.documents
746
783
  this.documentOrder = next.documentOrder
747
784
  this.prefixIndex = next.prefixIndex
@@ -899,6 +936,26 @@ export class CorpusStore {
899
936
  return bytes
900
937
  }
901
938
 
939
+ /**
940
+ * 本地资料包规模:可检索的篇数与行数。零命中时用它说明"检查了多大的范围",
941
+ * 让"没有找到"成为可引用的否定证据。按资料版本缓存,切版自动失效。
942
+ * @returns {{documents: number, lines: number}}
943
+ */
944
+ corpusTotals() {
945
+ if (this._corpusTotals?.dataVersion === this.dataVersion) return this._corpusTotals.value
946
+ let documents = 0
947
+ let lines = 0
948
+ for (const entry of this.documents.values()) {
949
+ const document = entry?.document
950
+ if (!document) continue
951
+ documents += 1
952
+ lines += Number(document.line_count || 0)
953
+ }
954
+ const value = { documents, lines }
955
+ this._corpusTotals = { dataVersion: this.dataVersion, value }
956
+ return value
957
+ }
958
+
902
959
  /**
903
960
  * 按 document_id 取完整文档记录。
904
961
  * @returns {Promise<{ record: object, packId: string } | null>}
@@ -996,7 +1053,64 @@ export class CorpusStore {
996
1053
  `标题“${normalized}”对应 ${documentIds.length} 篇资料;请使用 corpus_search 返回的带资料类型完整标题`,
997
1054
  ), { code: 'DOCUMENT_AMBIGUOUS' })
998
1055
  }
999
- return documentIds.length ? this.getDocument(documentIds[0]) : null
1056
+ if (documentIds.length) return this.getDocument(documentIds[0])
1057
+ // 宽松回退:忽略空白、间隔号(· ・ •)、斜杠两侧空格、全半角与大小写差异,
1058
+ // 以及角色×活动 Wiki 的内部「第 N 篇」序号;唯一命中才直接返回。
1059
+ const loose = this.looseTitleMatches(normalized)
1060
+ if (loose.length === 1) return this.getDocument(loose[0])
1061
+ return null
1062
+ }
1063
+
1064
+ /** 宽松标题索引:loose key → document_id[],按资料版本惰性构建。 */
1065
+ looseTitleIndex() {
1066
+ if (this._looseTitleIndex?.version === this.dataVersion) return this._looseTitleIndex.index
1067
+ const index = new Map()
1068
+ const add = (title, documentId) => {
1069
+ const key = looseTitleKey(title)
1070
+ if (!key) return
1071
+ const ids = index.get(key) ?? []
1072
+ if (!ids.includes(documentId)) ids.push(documentId)
1073
+ index.set(key, ids)
1074
+ }
1075
+ for (const [title, ids] of this.naturalTitleIndex) {
1076
+ for (const documentId of ids) {
1077
+ add(title, documentId)
1078
+ const withoutSequence = title.replace(/\s*·\s*第\s*\d+\s*篇$/u, '')
1079
+ if (withoutSequence !== title) add(withoutSequence, documentId)
1080
+ }
1081
+ }
1082
+ for (const [title, ids] of this.titleIndex) for (const documentId of ids) add(title, documentId)
1083
+ this._looseTitleIndex = { version: this.dataVersion, index }
1084
+ return index
1085
+ }
1086
+
1087
+ looseTitleMatches(title) {
1088
+ const key = looseTitleKey(title)
1089
+ return key ? [...(this.looseTitleIndex().get(key) ?? [])] : []
1090
+ }
1091
+
1092
+ /** 读取失败时给出最接近的完整标题候选(最多 limit 条)。 */
1093
+ suggestTitles(title, limit = 5) {
1094
+ const key = looseTitleKey(title)
1095
+ if (!key) return []
1096
+ const scored = []
1097
+ for (const [candidateTitle, ids] of this.naturalTitleIndex) {
1098
+ if (!ids.some((documentId) => this.isPreferredNaturalDocument?.(documentId) !== false)) continue
1099
+ const candidate = looseTitleKey(candidateTitle)
1100
+ let score = 0
1101
+ if (candidate === key) score = 100
1102
+ else if (candidate.includes(key)) score = 80 - Math.min(40, candidate.length - key.length)
1103
+ else if (key.includes(candidate) && candidate.length >= 2) score = 60 - Math.min(40, key.length - candidate.length)
1104
+ else if (isSubsequence(key, candidate)) score = 70 - Math.min(40, candidate.length - key.length)
1105
+ else {
1106
+ const shared = sharedBigrams(key, candidate)
1107
+ if (shared >= 0.6) score = Math.round(shared * 50) - Math.min(20, Math.abs(candidate.length - key.length))
1108
+ }
1109
+ if (score > 0) scored.push({ title: candidateTitle, score })
1110
+ }
1111
+ return scored.sort((left, right) => right.score - left.score
1112
+ || left.title.length - right.title.length || left.title.localeCompare(right.title, 'zh-CN'))
1113
+ .slice(0, limit).map((item) => item.title)
1000
1114
  }
1001
1115
 
1002
1116
  /** 搜索结果若无法仅靠自然标题/合集名稳定回读,就公开短 document_uid。 */
@@ -1567,3 +1681,13 @@ export class CorpusStore {
1567
1681
  return matches
1568
1682
  }
1569
1683
  }
1684
+
1685
+ /** 模型可见的资料版本引用:续页只需前 12 位;校验时接受 ≥8 位前缀。 */
1686
+ export const DATA_VERSION_REF_PATTERN = /^[0-9a-f]{8,64}$/u
1687
+ export function shortDataVersion(value) {
1688
+ return String(value || '').slice(0, 12)
1689
+ }
1690
+ export function dataVersionMatches(given, actual) {
1691
+ return typeof given === 'string' && DATA_VERSION_REF_PATTERN.test(given)
1692
+ && typeof actual === 'string' && actual.startsWith(given)
1693
+ }