prts-terrarchive 0.2.2 → 0.2.4

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 +326 -103
  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/timeline.js CHANGED
@@ -149,6 +149,63 @@ class TimelineFault extends Error {
149
149
  * 2. references/char_alias.txt 的分号分隔行(挂到同名 canonical 组,找不到则自建)。
150
150
  * @returns {Promise<Array<{canonical: string, aliases: string[], games: string[]}>>}
151
151
  */
152
+ /** 别名字面归一:与识别器同源(NFKC + 小写 + 压缩空白),避免大小写或全角差异放过冲突。 */
153
+ function aliasKey(value) {
154
+ return String(value || '').normalize('NFKC').replace(/\s+/gu, ' ').trim().toLocaleLowerCase()
155
+ }
156
+
157
+ /**
158
+ * 一个别名字面只能指向一个实体。资料里出现过把正文提及词当成别名的记录
159
+ * (如「玛利图斯」的 aliases 里塞进「预言家」「海嗣」),AC 自动机便会对同一字面
160
+ * 同时吐出两个 canonical,让无关实体混进本轮检索上下文。
161
+ * 裁决:字面等于某实体自身规范名时保留该实体(如「海嗣」保留给海嗣),
162
+ * 其余情况一律丢弃并登记冲突,交给数据侧修正别名表——不在代码里猜谁才是对的。
163
+ */
164
+ function resolveAliasConflicts(groups, pinned = new Set()) {
165
+ const owners = new Map()
166
+ for (const group of groups.values()) for (const alias of group.aliases) {
167
+ const key = aliasKey(alias)
168
+ if (!key) continue
169
+ const list = owners.get(key) || []
170
+ list.push({ group, alias })
171
+ owners.set(key, list)
172
+ }
173
+ const conflicts = []
174
+ for (const [key, list] of owners) {
175
+ const canonicals = [...new Set(list.map((item) => item.group.canonical))]
176
+ if (canonicals.length < 2) continue
177
+ const keeper = list.find((item) => item.group.canonical === item.alias)
178
+ for (const item of list) {
179
+ if (item === keeper) continue
180
+ item.group.aliases.delete(item.alias)
181
+ }
182
+ if (!keeper && !pinned.has(key)) conflicts.push({ alias: list[0].alias, canonicals: canonicals.sort() })
183
+ }
184
+ return conflicts
185
+ }
186
+
187
+ /**
188
+ * 去掉"太泛"的短别名:两个字的词很容易是通用词(如某组织把「联合」当别名,
189
+ * 于是"联合解析"这种句子会把它整个拉进检索上下文)。
190
+ * 判据是它在多少条**其他**实体的规范名里当子串——只有人工别名表里裁决过的短别名豁免。
191
+ */
192
+ function dropGenericShortAliases(groups, curated) {
193
+ const canonicals = [...groups.keys()]
194
+ const dropped = []
195
+ for (const group of groups.values()) {
196
+ for (const alias of [...group.aliases]) {
197
+ const key = aliasKey(alias)
198
+ if (key.length > 2 || key === aliasKey(group.canonical) || curated.has(key)) continue
199
+ const others = canonicals.filter((name) => name !== group.canonical
200
+ && aliasKey(name).includes(key)).length
201
+ if (others < 3) continue
202
+ group.aliases.delete(alias)
203
+ dropped.push({ alias, canonical: group.canonical, others })
204
+ }
205
+ }
206
+ return dropped
207
+ }
208
+
152
209
  export async function buildAliasGroups(store) {
153
210
  const groups = new Map()
154
211
  const crossGameNames = relationEndfieldNames(await loadEntityRelationCatalog(store))
@@ -156,7 +213,11 @@ export async function buildAliasGroups(store) {
156
213
  const canonical = String(canonicalValue ?? '').trim()
157
214
  if (!canonical) return
158
215
  const group = groups.get(canonical) || { canonical, aliases: new Set([canonical]), games: new Set() }
159
- for (const value of aliasValues) {
216
+ // 实体 JSON 点里 aliases 是分号分隔的字符串(个别用全角分号),资料包里是数组;
217
+ // 字符串直接迭代会被按字拆开,这里统一先切分。
218
+ const aliasList = typeof aliasValues === 'string' ? aliasValues.split(/[;;]/u)
219
+ : Array.isArray(aliasValues) ? aliasValues : []
220
+ for (const value of aliasList) {
160
221
  const alias = String(value ?? '').trim()
161
222
  // 旧 PRTS 数据曾把终末地再旅者名塞进泰拉人物 aliases 方便联搜。
162
223
  // 关系现在由独立附属字段承载;跨游戏名不再改变实体身份。
@@ -178,15 +239,38 @@ export async function buildAliasGroups(store) {
178
239
  entity.aliases || record.aliases, game)
179
240
  }
180
241
 
242
+ // 人工别名表(char_alias.txt)是裁决,不是补充:实体 JSON 里的 aliases 由当时的
243
+ // LLM 生成 json 点时顺手写出,会把正文提及词当成别名(「玛利图斯」曾带「预言家」
244
+ // 「海嗣」)。表里每一行声明的别名只归该行实体,其余组必须让出。
181
245
  const aliasReference = await store.getDocumentByPath('char_alias.txt')
246
+ const pinned = new Set()
182
247
  for (const line of aliasReference?.record?.lines || []) {
183
248
  const aliases = [...new Set(String(line.text || '').split(';').map((item) => item.trim()).filter(Boolean))]
184
249
  if (!aliases.length) continue
185
250
  const existing = [...groups.values()].find((group) =>
186
251
  group.canonical === aliases[0] || group.aliases.has(aliases[0]))
187
- remember(existing?.canonical || aliases[0], aliases)
252
+ const canonical = existing?.canonical || aliases[0]
253
+ for (const alias of aliases) {
254
+ const key = aliasKey(alias)
255
+ if (!key) continue
256
+ for (const group of [...groups.values()]) {
257
+ if (group.canonical === canonical) continue
258
+ for (const owned of [...group.aliases]) {
259
+ if (aliasKey(owned) === key) group.aliases.delete(owned)
260
+ }
261
+ }
262
+ pinned.add(key)
263
+ }
264
+ remember(canonical, aliases)
188
265
  }
189
266
 
267
+ const conflicts = resolveAliasConflicts(groups, pinned)
268
+ const generic = dropGenericShortAliases(groups, pinned)
269
+ if (store && typeof store === 'object') {
270
+ store._aliasConflicts = conflicts
271
+ store._genericAliases = generic
272
+ store._curatedAliases = pinned
273
+ }
190
274
  return [...groups.values()].map((group) => ({ canonical: group.canonical,
191
275
  aliases: [...group.aliases], games: [...group.games] }))
192
276
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 模型参数的空占位归一化。
3
+ *
4
+ * DSH 工具 Schema 子集不支持 minLength/minItems/minimum,模型或第三方接口常把
5
+ * 可选字段整体填成 ""、[]、{}、null 或 0。这些值不表达任何意图,却会触发执行层
6
+ * 的严格校验并让 Agent 陷入循环。这里只删除“明确为空”的占位:
7
+ * - null / undefined
8
+ * - 去空白后为空的字符串
9
+ * - 删除空字符串项后为空的数组(非空项保持原样,交给执行层严格校验)
10
+ * - 递归清理后没有任何键(或只剩数字 0)的普通对象
11
+ * - 显式列入 zeroFields 的整数 0(仅用于从 1 开始计数的字段)
12
+ * 部分填写的对象、非空的非法值仍原样保留,由执行层报告具体错误。
13
+ */
14
+
15
+ const isPlainObject = (value) => value !== null && typeof value === 'object'
16
+ && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
17
+
18
+ const isBlankString = (value) => typeof value === 'string' && value.trim() === ''
19
+
20
+ function cleanValue(value) {
21
+ if (value === null || value === undefined) return undefined
22
+ if (isBlankString(value)) return undefined
23
+ if (Array.isArray(value)) {
24
+ const items = value.filter((item) => item !== null && item !== undefined && !isBlankString(item))
25
+ return items.length ? items : undefined
26
+ }
27
+ if (isPlainObject(value)) {
28
+ const cleaned = {}
29
+ for (const [key, item] of Object.entries(value)) {
30
+ const next = cleanValue(item)
31
+ if (next !== undefined) cleaned[key] = next
32
+ }
33
+ // {position: 0} 这类只剩数字 0 的对象同样是整体占位(如空的 after 分页凭据)。
34
+ return Object.values(cleaned).some((item) => item !== 0) ? cleaned : undefined
35
+ }
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * @param {unknown} args 模型原始参数
41
+ * @param {{ zeroFields?: string[] }} [options]
42
+ * @returns {unknown} 删除空占位后的新对象;非对象原样返回,交给调用方报错
43
+ */
44
+ export function dropEmptyPlaceholders(args, { zeroFields = [] } = {}) {
45
+ if (!isPlainObject(args)) return args
46
+ const result = {}
47
+ for (const [key, value] of Object.entries(args)) {
48
+ if (zeroFields.includes(key) && value === 0) continue
49
+ const next = cleanValue(value)
50
+ if (next !== undefined) result[key] = next
51
+ }
52
+ return result
53
+ }
package/src/ui.js CHANGED
@@ -641,13 +641,14 @@ const emptyDataset = (game) => ({
641
641
  })
642
642
 
643
643
  /** 把联合 release 中的 pack 汇总成用户可理解的两个游戏资料库。 */
644
- async function readReleaseDatasets(dir, manifest, packManifests = null) {
644
+ async function readReleaseDatasets(dir, manifest, packManifests = null, installedPackIds = null) {
645
645
  const datasets = {
646
646
  arknights: emptyDataset('arknights'),
647
647
  endfield: emptyDataset('endfield'),
648
648
  }
649
649
  const sharedPacks = []
650
650
  for (const pack of Array.isArray(manifest?.packs) ? manifest.packs : []) {
651
+ if (installedPackIds && !installedPackIds.includes(pack.pack_id)) continue
651
652
  const manifestPath = String(pack?.manifest_path ?? '')
652
653
  if (!manifestPath || manifestPath.includes('..') || manifestPath.startsWith('/')) continue
653
654
  let detail = packManifests?.get(String(pack?.pack_id ?? ''))
@@ -657,8 +658,8 @@ async function readReleaseDatasets(dir, manifest, packManifests = null) {
657
658
  const packId = String(detail.pack_id ?? pack.pack_id ?? '')
658
659
  const game = detail.game === 'arknights' || detail.game === 'endfield'
659
660
  ? detail.game
660
- : packId === 'official_game' ? 'arknights'
661
- : packId.startsWith('endfield_') ? 'endfield' : null
661
+ : packId.startsWith('endfield_') ? 'endfield'
662
+ : ['official_game', 'reviewed_wiki', 'terra_journey', 'entities', 'references'].includes(packId) ? 'arknights' : null
662
663
  const summary = {
663
664
  packId,
664
665
  authority: detail.authority ?? pack.authority ?? null,
@@ -697,43 +698,57 @@ async function readReleaseDatasets(dir, manifest, packManifests = null) {
697
698
  async function readLocalReleases(shared, sizeCache = new Map()) {
698
699
  const releases = []
699
700
  let activeId = null
701
+ let previousId = null
700
702
  try {
701
- activeId = (await readCurrentReleasePointer(shared.releasesDir)).release_id
703
+ const pointer = await readCurrentReleasePointer(shared.releasesDir)
704
+ activeId = pointer.release_id
705
+ previousId = pointer.previous_release_id ?? null
702
706
  } catch { /* 尚无激活版本 */ }
703
707
  let entries = []
704
708
  try {
705
709
  entries = await readdir(shared.releasesDir, { withFileTypes: true })
706
- } catch { return { activeId, releases } }
710
+ } catch { return { activeId, previousId, releases } }
707
711
  for (const entry of entries) {
708
712
  if (!entry.isDirectory() || !RELEASE_ID_PATTERN.test(entry.name)) continue
709
713
  const dir = join(shared.releasesDir, entry.name)
710
714
  let manifest = null
711
715
  let packManifests = null
716
+ let installedPackIds = null
717
+ let installedGames = []
712
718
  try {
713
- const validated = await validateLocalRelease(shared.releasesDir, entry.name, { details: true })
719
+ const validated = await validateLocalRelease(shared.releasesDir, entry.name, { details: true, allowPartial: true })
714
720
  manifest = validated.manifest
715
721
  packManifests = validated.packManifests
722
+ installedPackIds = validated.installedPackIds ?? manifest.packs.map((pack) => pack.pack_id)
723
+ installedGames = validated.installedGames ?? []
716
724
  } catch { /* 半成品/外来目录:仍列出,标记不完整 */ }
717
- let sizeBytes = sizeCache.get(entry.name)
718
- if (sizeBytes === undefined) {
719
- sizeBytes = await releaseDirSize(dir).catch(() => 0)
720
- if (manifest) sizeCache.set(entry.name, sizeBytes)
721
- }
725
+ // 目录大小只对不完整的目录才有意义(用于提示半成品占用);完整版本直接取
726
+ // 清单里的 compressed_size。此前对每个 release 都全量 stat 一遍,
727
+ // 设置页要等十几秒才出内容。
728
+ let sizeBytes = manifest
729
+ ? (manifest.compressed_size ?? 0)
730
+ : await releaseDirSize(dir).catch(() => 0)
722
731
  releases.push({
723
732
  releaseId: entry.name,
724
733
  active: entry.name === activeId,
725
734
  complete: Boolean(manifest),
735
+ installedGames, installedPackIds,
736
+ missingGames: manifest ? missingEnabledGamePacks(manifest, shared.effective().enabledGames, installedPackIds) : shared.effective().enabledGames,
726
737
  dataVersion: manifest?.data_version ?? null,
727
- documentCount: manifest?.document_count ?? null,
728
- compressedSize: manifest?.compressed_size ?? null,
738
+ documentCount: manifest ? manifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id))
739
+ .reduce((sum, pack) => sum + pack.document_count, 0) : null,
740
+ compressedSize: manifest ? manifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id))
741
+ .reduce((sum, pack) => sum + pack.compressed_size, 0) : null,
742
+ releaseDocumentCount: manifest?.document_count ?? null,
743
+ releaseCompressedSize: manifest?.compressed_size ?? null,
729
744
  createdAt: manifest?.created_at ?? null,
730
745
  sizeBytes,
731
746
  needsExtract: true, // 本地分片以 .jsonl.gz 存储,打开时需解压
732
- datasets: manifest ? await readReleaseDatasets(dir, manifest, packManifests) : null,
747
+ datasets: manifest ? await readReleaseDatasets(dir, manifest, packManifests, installedPackIds) : null,
733
748
  })
734
749
  }
735
750
  releases.sort((left, right) => String(right.createdAt ?? '').localeCompare(String(left.createdAt ?? '')))
736
- return { activeId, releases }
751
+ return { activeId, previousId, releases }
737
752
  }
738
753
 
739
754
  /**
@@ -752,6 +767,8 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
752
767
  dataVersion: localRelease?.dataVersion ?? null,
753
768
  documentCount: localRelease?.documentCount ?? null,
754
769
  sizeBytes: localRelease?.sizeBytes ?? null,
770
+ installedGames: localRelease?.installedGames ?? [],
771
+ missingGames: localRelease?.missingGames ?? shared.effective().enabledGames,
755
772
  }
756
773
  try {
757
774
  const trusted = await resolveTrustedCurrentRelease({ fetchImpl,
@@ -765,7 +782,7 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
765
782
  uncompressedSize: trusted.uncompressedSize,
766
783
  createdAt: null,
767
784
  }
768
- const updateAvailable = local.activeId !== trusted.releaseId
785
+ const updateAvailable = localInfo.missingGames.length > 0 || local.activeId !== trusted.releaseId
769
786
  || (localInfo.dataVersion != null && localInfo.dataVersion !== trusted.dataVersion)
770
787
  return { source: 'site', local: localInfo, remote, updateAvailable }
771
788
  } catch (error) {
@@ -780,6 +797,8 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
780
797
  * @param {{ logger?: object }} [env]
781
798
  */
782
799
  export function buildApi(shared, env = {}) {
800
+ // 保留最近几个版本:当前版本 + 回滚目标 + 余量。
801
+ const KEEP_RELEASES = 3
783
802
  const sizeCache = new Map()
784
803
  const fetchImpl = env.fetchImpl ?? fetch
785
804
  const manualCloudSearch = createManualCloudSearch(shared, { fetchImpl })
@@ -789,7 +808,51 @@ export function buildApi(shared, env = {}) {
789
808
  releaseMutation = running.catch(() => {})
790
809
  return running
791
810
  }
811
+ /**
812
+ * 自动清理旧资料版本:只保留最近 KEEP_RELEASES 个(当前版本与回滚目标必须保留),
813
+ * 否则每下载一次就在用户硬盘上多堆一份几百 MB 的旧包。
814
+ * 只删「能通过 Release 校验」的受管目录;半成品/外来目录一律不碰。
815
+ */
816
+ const pruneOldReleases = async ({ keep = KEEP_RELEASES } = {}) => {
817
+ let pointer = null
818
+ try { pointer = await readCurrentReleasePointer(shared.releasesDir) } catch { /* 无激活版本 */ }
819
+ const activeId = pointer?.release_id ?? null
820
+ const previousId = pointer?.previous_release_id ?? null
821
+ let entries = []
822
+ try { entries = await readdir(shared.releasesDir, { withFileTypes: true }) } catch { return [] }
823
+ const candidates = []
824
+ for (const entry of entries) {
825
+ if (!entry.isDirectory() || !RELEASE_ID_PATTERN.test(entry.name)) continue
826
+ if (entry.name === activeId || entry.name === previousId) continue
827
+ if (shared.download?.active && shared.download.releaseId === entry.name) continue
828
+ try {
829
+ const validated = await validateLocalRelease(shared.releasesDir, entry.name, { allowPartial: true })
830
+ candidates.push({ releaseId: entry.name, createdAt: String(validated.manifest?.created_at ?? '') })
831
+ } catch { /* 不是受管版本,绝不自动删除 */ }
832
+ }
833
+ const reserved = (activeId ? 1 : 0) + (previousId ? 1 : 0)
834
+ const room = Math.max(0, keep - reserved)
835
+ candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt)
836
+ || right.releaseId.localeCompare(left.releaseId))
837
+ const removed = []
838
+ for (const item of candidates.slice(room)) {
839
+ try {
840
+ await rm(join(shared.releasesDir, item.releaseId), { recursive: true, force: true })
841
+ sizeCache.delete(item.releaseId)
842
+ removed.push(item.releaseId)
843
+ } catch (error) {
844
+ env.logger?.warn?.(`prts-corpus: 清理旧版本失败 ${item.releaseId}: ${error?.message ?? error}`)
845
+ }
846
+ }
847
+ if (removed.length) {
848
+ env.logger?.info?.(`prts-corpus: 自动清理 ${removed.length} 个旧版本(保留最近 ${keep} 个):${removed.join('、')}`)
849
+ }
850
+ return removed
851
+ }
852
+
792
853
  const startDownload = async ({ releaseId }) => {
854
+ const config = shared.effective()
855
+ const enabledGames = [...config.enabledGames]
793
856
  const progress = shared.download
794
857
  if (progress.active) throw new ApiError(409, '已有下载任务在进行中')
795
858
  // 目标解析包含最长数十秒的网络请求;下载槽位必须在首个 await 之前同步
@@ -837,10 +900,10 @@ export function buildApi(shared, env = {}) {
837
900
  // 后台执行;结果写回 shared.download,界面轮询 status 跟踪
838
901
  void (async () => {
839
902
  try {
840
- const config = shared.effective()
841
903
  const result = await ensureCorpusRelease({
842
904
  releasesDir: shared.releasesDir,
843
905
  releaseId: target,
906
+ enabledGames,
844
907
  ...(trustedCurrent ? { trustedCurrent } : {}),
845
908
  order: config.downloadOrder,
846
909
  siteBaseUrl: config.downloadSiteBaseUrl,
@@ -861,7 +924,8 @@ export function buildApi(shared, env = {}) {
861
924
  if (shared.store) {
862
925
  try {
863
926
  const pointer = await readCurrentReleasePointer(shared.releasesDir)
864
- if (pointer.release_id !== shared.store.releaseId) {
927
+ if (pointer.release_id !== shared.store.releaseId
928
+ || (pointer.installation_id ?? null) !== shared.store.installationId) {
865
929
  shared.store.reset()
866
930
  }
867
931
  } catch { /* current.json 异常时保持现状 */ }
@@ -874,10 +938,15 @@ export function buildApi(shared, env = {}) {
874
938
  progress.active = false
875
939
  progress.finishedAt = new Date().toISOString()
876
940
  shared.notifyRuntime()
941
+ // 新包落位后立刻回收旧包;失败不影响下载结果。
942
+ await pruneOldReleases().catch((error) => {
943
+ env.logger?.warn?.(`prts-corpus: 自动清理旧版本失败:${error?.message ?? error}`)
944
+ })
877
945
  }
878
946
  })()
879
947
  return { started: true, releaseId: target }
880
948
  }
949
+ // 供启动清理调用(不进入路由,不暴露给 UI)。
881
950
 
882
951
  /** 原始路由分发:ApiError/配置校验错误转为响应,其余向上抛给 HTTP 层。 */
883
952
  const routeCall = async (method, pathname, body, { signal } = {}) => {
@@ -1025,11 +1094,17 @@ export function buildApi(shared, env = {}) {
1025
1094
  const config = shared.effective()
1026
1095
  let installed = false
1027
1096
  let installationIssue = null
1097
+ let installedGames = []
1098
+ let installedPackIds = []
1099
+ let missingGames = []
1028
1100
  try {
1029
1101
  const pointer = await readCurrentReleasePointer(shared.releasesDir)
1030
1102
  const releaseId = String(pointer.release_id || '')
1031
- const manifest = await validateLocalRelease(shared.releasesDir, releaseId)
1032
- const missingGames = missingEnabledGamePacks(manifest, config.enabledGames)
1103
+ const validated = await validateLocalRelease(shared.releasesDir, releaseId, { details: true, allowPartial: true })
1104
+ const { manifest } = validated
1105
+ installedGames = validated.installedGames ?? []
1106
+ installedPackIds = validated.installedPackIds ?? manifest.packs.map((pack) => pack.pack_id)
1107
+ missingGames = missingEnabledGamePacks(manifest, config.enabledGames, installedPackIds)
1033
1108
  installed = Boolean((!pointer.data_version || pointer.data_version === manifest.data_version)
1034
1109
  && missingGames.length === 0)
1035
1110
  if (missingGames.length) installationIssue = `当前版本缺少已启用模块:${missingGames.map((game) =>
@@ -1040,7 +1115,7 @@ export function buildApi(shared, env = {}) {
1040
1115
  ? '未找到本地语料;请下载资料或检查资料目录配置'
1041
1116
  : `无法读取本地语料配置:${error?.message ?? error}`
1042
1117
  }
1043
- let storeInfo = { loaded: ready, installed, installationIssue,
1118
+ let storeInfo = { loaded: ready, installed, installationIssue, installedGames, installedPackIds, missingGames,
1044
1119
  releaseId: null, dataVersion: null, documentCount: null, packCount: null }
1045
1120
  if (store && ready && store.releaseId) {
1046
1121
  storeInfo = { ...storeInfo, loaded: true,
@@ -1050,8 +1125,8 @@ export function buildApi(shared, env = {}) {
1050
1125
  return { status: 200, json: { store: storeInfo, download: { ...shared.download }, config: redactConfig(config) } }
1051
1126
  }
1052
1127
  if (method === 'GET' && route === 'releases') {
1053
- const { activeId, releases } = await readLocalReleases(shared, sizeCache)
1054
- return { status: 200, json: { activeId, releases } }
1128
+ const { activeId, previousId, releases } = await readLocalReleases(shared, sizeCache)
1129
+ return { status: 200, json: { activeId, previousId, releases } }
1055
1130
  }
1056
1131
  if (method === 'GET' && route === 'check-update') {
1057
1132
  return { status: 200, json: await checkForUpdate(shared, env, sizeCache) }
@@ -1079,21 +1154,38 @@ export function buildApi(shared, env = {}) {
1079
1154
  return await withReleaseMutationLock(shared.releasesDir, async () => {
1080
1155
  let manifest
1081
1156
  try {
1082
- manifest = await validateLocalRelease(shared.releasesDir, releaseId, { verifyHashes: true })
1157
+ const validated = await validateLocalRelease(shared.releasesDir, releaseId, { verifyHashes: true, details: true, allowPartial: true })
1158
+ manifest = validated.manifest
1159
+ const missing = missingEnabledGamePacks(manifest, shared.effective().enabledGames, validated.installedPackIds)
1160
+ if (missing.length) throw new Error(`缺少已启用模块:${missing.join("、")}`)
1083
1161
  } catch (error) {
1084
1162
  throw new ApiError(400, `版本不完整,无法激活:${error?.message ?? error}`)
1085
1163
  }
1164
+ // 记下上一版本,设置页才能提供「回滚」;否则切换一次就把退路弄丢了。
1165
+ let previousId = null
1166
+ let generation = 1
1167
+ try {
1168
+ const old = await readCurrentReleasePointer(shared.releasesDir)
1169
+ previousId = old.release_id && old.release_id !== releaseId ? old.release_id
1170
+ : (old.previous_release_id ?? null)
1171
+ generation = Number(old.generation ?? 0) + 1
1172
+ } catch { /* 首次激活 */ }
1086
1173
  const pointerTemp = join(shared.releasesDir, `current.json.${randomBytes(6).toString('hex')}.tmp`)
1087
1174
  await writeFile(pointerTemp, JSON.stringify({
1088
1175
  release_id: releaseId, data_version: manifest.data_version,
1089
1176
  channel: 'manual', public_download: true, schema_version: 1,
1177
+ installation_id: randomBytes(16).toString('hex'),
1090
1178
  activated_at: new Date().toISOString(),
1179
+ generation,
1180
+ ...(previousId ? { previous_release_id: previousId } : {}),
1181
+ rollback_available: Boolean(previousId),
1091
1182
  }))
1092
1183
  await rename(pointerTemp, join(shared.releasesDir, 'current.json'))
1093
1184
  shared.store?.reset()
1094
1185
  shared.notifyRuntime()
1095
1186
  env.logger?.info?.(`prts-corpus: 激活版本切换为 ${releaseId}`)
1096
- return { status: 200, json: { activated: releaseId } }
1187
+ const pruned = await pruneOldReleases().catch(() => [])
1188
+ return { status: 200, json: { activated: releaseId, pruned } }
1097
1189
  })
1098
1190
  } catch (error) {
1099
1191
  if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
@@ -1101,6 +1193,18 @@ export function buildApi(shared, env = {}) {
1101
1193
  }
1102
1194
  })
1103
1195
  }
1196
+ if (method === 'POST' && route === 'prune') {
1197
+ return withReleaseMutation(async () => {
1198
+ try {
1199
+ return await withReleaseMutationLock(shared.releasesDir, async () => ({
1200
+ status: 200, json: { removed: await pruneOldReleases() },
1201
+ }))
1202
+ } catch (error) {
1203
+ if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
1204
+ throw error
1205
+ }
1206
+ })
1207
+ }
1104
1208
  if (method === 'POST' && route === 'delete') {
1105
1209
  const releaseId = String(body?.releaseId ?? '')
1106
1210
  if (!RELEASE_ID_PATTERN.test(releaseId)) throw new ApiError(400, 'releaseId 非法')
@@ -1116,7 +1220,7 @@ export function buildApi(shared, env = {}) {
1116
1220
  throw new ApiError(409, '该版本正在下载')
1117
1221
  }
1118
1222
  try {
1119
- await validateLocalRelease(shared.releasesDir, releaseId)
1223
+ await validateLocalRelease(shared.releasesDir, releaseId, { allowPartial: true })
1120
1224
  } catch (error) {
1121
1225
  throw new ApiError(400, `目录不是可确认的完整资料版本,拒绝递归删除:${error?.message ?? error}`)
1122
1226
  }
@@ -1134,6 +1238,7 @@ export function buildApi(shared, env = {}) {
1134
1238
  }
1135
1239
 
1136
1240
  return {
1241
+ pruneOldReleases,
1137
1242
  async call(method, pathname, body, options = {}) {
1138
1243
  try {
1139
1244
  return await routeCall(method, pathname, body, options)
@@ -1160,6 +1265,12 @@ export function applyUi(ctx, shared) {
1160
1265
  const connection = ctx.get?.('connection') ?? ctx.connection
1161
1266
  if (!connection) return false
1162
1267
  const api = buildApi(shared, { logger: ctx.logger })
1268
+ // 启动后再清一次:老用户机器上可能已经堆了十几个版本,不必等下一次下载。
1269
+ setTimeout(() => {
1270
+ void api.pruneOldReleases?.().catch?. ((error) => {
1271
+ ctx.logger?.warn?.(`prts-corpus: 启动清理旧版本失败:${error?.message ?? error}`)
1272
+ })
1273
+ }, 15_000).unref?.()
1163
1274
  const endpoints = Object.freeze({
1164
1275
  status: ['GET', '/api/prts-corpus/status'],
1165
1276
  releases: ['GET', '/api/prts-corpus/releases'],
@@ -1169,6 +1280,7 @@ export function applyUi(ctx, shared) {
1169
1280
  download: ['POST', '/api/prts-corpus/download'],
1170
1281
  activate: ['POST', '/api/prts-corpus/activate'],
1171
1282
  delete: ['POST', '/api/prts-corpus/delete'],
1283
+ prune: ['POST', '/api/prts-corpus/prune'],
1172
1284
  read: ['POST', '/api/prts-corpus/read'],
1173
1285
  'archive.search': ['POST', '/api/prts-corpus/archive/search'],
1174
1286
  'archive.cloud-search': ['POST', '/api/prts-corpus/archive/cloud-search'],
@@ -1299,8 +1411,20 @@ export function applyUi(ctx, shared) {
1299
1411
 
1300
1412
  function applyWebAssets(ctx, shared) {
1301
1413
  const webServer = ctx.webServer
1414
+ const registerAsset = (route) => webServer.register({
1415
+ ...route,
1416
+ handler: (req, res) => {
1417
+ // Match /api admission before serving legacy paths. Older Connection
1418
+ // providers expose no admit helper and retain their static-asset behavior.
1419
+ const admission = ctx.connection.admit?.(req)
1420
+ if (admission && 'rejection' in admission) {
1421
+ res.writeHead(admission.rejection); res.end(); return
1422
+ }
1423
+ return route.handler(req, res)
1424
+ },
1425
+ })
1302
1426
  ctx.effect(() => {
1303
- const disposeSkin = webServer.register({
1427
+ const disposeSkin = registerAsset({
1304
1428
  kind: 'exact', path: '/prts-corpus/ui-skin.json',
1305
1429
  handler: (req, res) => {
1306
1430
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -1311,31 +1435,31 @@ function applyWebAssets(ctx, shared) {
1311
1435
  res.end(req.method === 'HEAD' ? undefined : body)
1312
1436
  },
1313
1437
  })
1314
- const disposeAgentSkin = webServer.register({
1438
+ const disposeAgentSkin = registerAsset({
1315
1439
  kind: 'exact', path: '/prts-corpus/skins/prts-agent.css',
1316
1440
  handler: (req, res) => serveSkinStylesheet(req, res, 'prts-agent.css'),
1317
1441
  })
1318
- const disposeCommonSkin = webServer.register({
1442
+ const disposeCommonSkin = registerAsset({
1319
1443
  kind: 'exact', path: '/prts-corpus/skins/common.css',
1320
1444
  handler: (req, res) => serveSkinStylesheet(req, res, 'common.css'),
1321
1445
  })
1322
- const disposeAicSkin = webServer.register({
1446
+ const disposeAicSkin = registerAsset({
1323
1447
  kind: 'exact', path: '/prts-corpus/skins/endfield-aic.css',
1324
1448
  handler: (req, res) => serveSkinStylesheet(req, res, 'endfield-aic.css'),
1325
1449
  })
1326
- const disposeRhineSkin = webServer.register({
1450
+ const disposeRhineSkin = registerAsset({
1327
1451
  kind: 'exact', path: '/prts-corpus/skins/rhine-lab.css',
1328
1452
  handler: (req, res) => serveSkinStylesheet(req, res, 'rhine-lab.css'),
1329
1453
  })
1330
- const disposeRhineAssets = Object.keys(RHINE_ASSETS).map((relative) => webServer.register({
1454
+ const disposeRhineAssets = Object.keys(RHINE_ASSETS).map((relative) => registerAsset({
1331
1455
  kind: 'exact', path: `/prts-corpus/rhine/${relative}`,
1332
1456
  handler: (req, res) => serveRhineAsset(req, res, relative),
1333
1457
  }))
1334
- const disposeBundle = webServer.register({
1458
+ const disposeBundle = registerAsset({
1335
1459
  kind: 'prefix', path: '/prts-corpus/endfield-map',
1336
1460
  handler: (req, res) => serveEndfieldMapAsset(req, res, '/prts-corpus/endfield-map'),
1337
1461
  })
1338
- const disposeResources = webServer.register({
1462
+ const disposeResources = registerAsset({
1339
1463
  kind: 'prefix', path: '/webmap3d/resources',
1340
1464
  handler: (req, res) => serveEndfieldMapAsset(req, res, '/webmap3d'),
1341
1465
  })
@@ -1,15 +1,25 @@
1
1
  /** Observe the official web tools without replacing providers or handling their credentials. */
2
2
  const searchSetup = 'DSH 网页搜索的凭据独立于桌面账号登录。缺少 Key 时请告知用户在插件 → 网页搜索中配置搜索提供方的 API Key;旧版可在设置 → 模型配置对应凭据。不要索取聊天中的密钥,不要把登录 token 当 API Key,也不要自行更换端点。'
3
3
  const fetchSetup = 'web_fetch 不需要网页搜索的 API Key。单个站点连接失败不能推断整个网络不可用;可换用另一个已知的权威来源核验。若多个站点持续失败,提示用户检查 DSH 启动进程的网络/HTTPS_PROXY 配置并重启,不要猜测代理地址或关闭证书验证。'
4
+ const fetchCredentialSetup = '当前联网抓取由 DeepSeek 官方接口提供,需要与聊天相同的 API Key:请提示用户在模型配置中补齐该凭据,或改回默认的匿名 HTTP 抓取。单个站点失败不能推断整个网络不可用;配置未改变前不要重复抓取同一个 URL。'
5
+
6
+ // 0.2.x 有两套 DeepSeek 联网实现:聊天同源的 deepseek-official 报 "DeepSeek search has no
7
+ // API key",新版 Search/Fetch 接口报 "DeepSeek web access has no API key"。两者都带
8
+ // WEB_PROVIDER_CREDENTIAL_MISSING,文案匹配保证旧宿主与新版开关都能命中。
9
+ const credentialMissing = (code, text) => code === 'WEB_PROVIDER_CREDENTIAL_MISSING'
10
+ || /DeepSeek (?:search|web access) has no API key/.test(text)
4
11
 
5
12
  export function webFailureGuidance(name, result) {
6
13
  if (!['web_search', 'web_fetch'].includes(name) || !result.isError) return null
7
14
  const code = result.error?.info?.code || result.error?.code || ''
8
15
  const text = (result.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n')
9
16
  if (/ABORT|CANCEL/.test(code) || /aborted|cancelled|已取消/i.test(text)) return null
10
- if (name === 'web_search' && (code === 'WEB_PROVIDER_CREDENTIAL_MISSING' || /DeepSeek search has no API key/.test(text))) {
17
+ if (name === 'web_search' && credentialMissing(code, text)) {
11
18
  return { code: 'WEB_PROVIDER_CREDENTIAL_MISSING', guidance: searchSetup + '配置未改变前不要重复搜索;继续本地核验,并明确说明现实资料尚未完成联网查证。' }
12
19
  }
20
+ if (name === 'web_fetch' && credentialMissing(code, text)) {
21
+ return { code: 'WEB_PROVIDER_CREDENTIAL_MISSING', guidance: fetchCredentialSetup }
22
+ }
13
23
  if (name === 'web_fetch' && /web fetch failed/.test(text)) {
14
24
  return { code: 'WEB_FETCH_CONNECTION_FAILED', guidance: fetchSetup }
15
25
  }
@@ -34,7 +44,9 @@ export function mountWebGuidance(ctx) {
34
44
  states.set(exec.agent, state)
35
45
  } else if (!result.isError) state.delete(key)
36
46
  })
37
- ctx.systemPrompt.context({ name: 'prts-terrarchive:web-access', order: 1006, text: ({ scope }) => {
47
+ // interpolate:false keeps literal text: DSH 0.2.1 resolves {{name}} in contexts
48
+ // and throws on unknown names, while this text is data/receipt prose.
49
+ ctx.systemPrompt.context({ name: 'prts-terrarchive:web-access', order: 1006, interpolate: false, text: ({ scope }) => {
38
50
  const issues = scope && states.get(scope)
39
51
  if (!issues?.size) return ''
40
52
  return '<prts:web-access>\n以下是本会话最近的联网工具故障及恢复指引。不得把未抓取到的网页或模型记忆说成已联网核实的来源。\n'