dsh-manage-session 0.5.1 → 0.5.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 (63) hide show
  1. package/CHANGELOG.ja.md +207 -242
  2. package/CHANGELOG.ko.md +207 -242
  3. package/CHANGELOG.md +341 -341
  4. package/INSTALL.ja.md +164 -179
  5. package/INSTALL.ko.md +164 -179
  6. package/INSTALL.md +164 -179
  7. package/INSTALL.zh.md +164 -179
  8. package/LICENSE +21 -21
  9. package/README.en.md +4 -12
  10. package/README.ja.md +0 -12
  11. package/README.ko.md +0 -12
  12. package/README.md +4 -14
  13. package/cordis.patch.yml +16 -11
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +3882 -690
  16. package/lib/index.d.ts +759 -29
  17. package/lib/index.js +2604 -93
  18. package/locale/de.json +6 -0
  19. package/locale/en.json +6 -0
  20. package/locale/es.json +6 -0
  21. package/locale/fr.json +6 -0
  22. package/locale/it.json +6 -0
  23. package/locale/ja.json +6 -0
  24. package/locale/ko.json +6 -0
  25. package/locale/ru.json +6 -0
  26. package/locale/zh.json +6 -0
  27. package/package.json +43 -32
  28. package/src/client/bridge-scope.ts +213 -0
  29. package/src/client/card.tsx +150 -150
  30. package/src/client/history-panel.tsx +319 -319
  31. package/src/client/index.ts +465 -354
  32. package/src/client/locales.ts +857 -698
  33. package/src/client/panel.tsx +139 -118
  34. package/src/client/search/card.tsx +563 -0
  35. package/src/client/search/host-api.ts +208 -0
  36. package/src/client/search/locales.ts +780 -0
  37. package/src/client/search/manage.tsx +400 -0
  38. package/src/client/search/panel.tsx +815 -0
  39. package/src/client/search/platform.ts +136 -0
  40. package/src/config.ts +82 -33
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/source-kind.ts +350 -350
  44. package/src/host/history/archive-source.ts +250 -202
  45. package/src/host/history/archive.ts +249 -194
  46. package/src/host/history/favorites.ts +71 -0
  47. package/src/host/history/purge.ts +370 -370
  48. package/src/host/index/archive-source.ts +90 -0
  49. package/src/host/index/engine.ts +571 -0
  50. package/src/host/index/extract.ts +272 -0
  51. package/src/host/index/peers.ts +62 -0
  52. package/src/host/index/rebuild.ts +368 -0
  53. package/src/host/index/schema.ts +136 -0
  54. package/src/host/index/snapshot.ts +135 -0
  55. package/src/host/index/sync.ts +286 -0
  56. package/src/host/registry/archive-registry.ts +317 -0
  57. package/src/host/settings-bridge.ts +237 -0
  58. package/src/index.ts +1421 -532
  59. package/README.de.md +0 -120
  60. package/README.es.md +0 -120
  61. package/README.fr.md +0 -120
  62. package/README.it.md +0 -120
  63. package/README.ru.md +0 -120
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Platform detection and the keyboard-label vocabulary, for the client half.
3
+ *
4
+ * Why this module exists
5
+ * ----------------------
6
+ * A shortcut hint is the one place where a single hard-coded string is wrong
7
+ * for most users: `⌘K` is meaningless on Windows, `Ctrl K` is wrong on macOS.
8
+ * The obvious implementation — one `navigator.platform` test — is also the
9
+ * fragile one: that API is deprecated, and it returns an empty string in some
10
+ * privacy configurations, which silently degrades every Mac user to the
11
+ * Windows vocabulary without any error to notice.
12
+ *
13
+ * This module is the single owner of both the platform answer and every label
14
+ * derived from it. The hint rendered in the sidebar, the hint rendered in the
15
+ * panel footer, and the chord the keydown handler actually matches all read
16
+ * from here — so a platform fix cannot land in one of them and miss another,
17
+ * and the advertised key cannot drift away from the bound key.
18
+ */
19
+
20
+ /** The navigator fields the detection reads (all optional, all untrusted). */
21
+ export interface NavigatorLike {
22
+ userAgentData?: { platform?: unknown }
23
+ platform?: unknown
24
+ userAgent?: unknown
25
+ }
26
+
27
+ /**
28
+ * Resolve the running platform string, three tiers deep.
29
+ *
30
+ * 1. UA-CH (`navigator.userAgentData.platform`) — modern and precise.
31
+ * 2. `navigator.platform` — broadly available, deprecated, and empty in some
32
+ * privacy modes.
33
+ * 3. The UA string — last resort, still non-empty in practice.
34
+ *
35
+ * @param nav - the navigator to read; `undefined` is allowed (non-DOM host).
36
+ * @returns a lower-cased platform string, or `''` when nothing could be read.
37
+ */
38
+ export function detectPlatform(nav: NavigatorLike | undefined): string {
39
+ const chPlatform = nav?.userAgentData?.platform
40
+ if (typeof chPlatform === 'string' && chPlatform !== '') return chPlatform.toLowerCase()
41
+ const legacy = nav?.platform
42
+ if (typeof legacy === 'string' && legacy !== '') return legacy.toLowerCase()
43
+ const agent = nav?.userAgent
44
+ if (typeof agent === 'string' && agent !== '') return agent.toLowerCase()
45
+ return ''
46
+ }
47
+
48
+ /** The keyboard vocabulary for one platform. */
49
+ export interface PlatformLabels {
50
+ /** The lower-cased platform string these labels were derived from. */
51
+ platform: string
52
+ isMac: boolean
53
+ isWindows: boolean
54
+ /** Invoke-key modifier: `⌘` on macOS, `Ctrl` elsewhere. */
55
+ modLabel: string
56
+ /** Secondary modifier: `⌥` on macOS, `Alt` elsewhere. */
57
+ altLabel: string
58
+ /** Shift: `⇧` on macOS, `Shift` elsewhere. */
59
+ shiftLabel: string
60
+ /** Confirm: the arrow glyph is legible on every platform. */
61
+ enterLabel: string
62
+ /** Escape: `esc` is the macOS spelling, `Esc` the one everywhere else. */
63
+ escLabel: string
64
+ /**
65
+ * The whole invoke chord as one face, ready for a key chip: `⌘K` on macOS,
66
+ * `Ctrl K` on Windows and Linux. `Ctrl K` keeps the space because `CtrlK`
67
+ * reads as a word rather than as two keys.
68
+ */
69
+ invokeLabel: string
70
+ }
71
+
72
+ /**
73
+ * Derive the label vocabulary from a platform string.
74
+ *
75
+ * An unrecognised platform — an empty string, or a UA that names neither
76
+ * system — takes the non-Mac vocabulary on purpose: `Ctrl` is legible to
77
+ * everyone, while `⌘` is opaque to anyone who has never used a Mac.
78
+ *
79
+ * @param platform - the string produced by {@link detectPlatform}.
80
+ * @returns the label vocabulary for that platform.
81
+ */
82
+ export function platformLabels(platform: string): PlatformLabels {
83
+ const isMac = /mac|iphone|ipad|ipod/u.test(platform)
84
+ const isWindows = /win/u.test(platform)
85
+ const modLabel = isMac ? '⌘' : 'Ctrl'
86
+ return {
87
+ platform,
88
+ isMac,
89
+ isWindows,
90
+ modLabel,
91
+ altLabel: isMac ? '⌥' : 'Alt',
92
+ shiftLabel: isMac ? '⇧' : 'Shift',
93
+ enterLabel: '↵',
94
+ escLabel: isMac ? 'esc' : 'Esc',
95
+ invokeLabel: isMac ? `${modLabel}K` : `${modLabel} K`,
96
+ }
97
+ }
98
+
99
+ /** Read `globalThis.navigator` without assuming a DOM (or a DOM lib). */
100
+ function currentNavigator(): NavigatorLike | undefined {
101
+ const candidate = (globalThis as { navigator?: unknown }).navigator
102
+ if (candidate === null || typeof candidate !== 'object') return undefined
103
+ return candidate as NavigatorLike
104
+ }
105
+
106
+ /** The labels for the platform this bundle is running on. */
107
+ export const LABELS: PlatformLabels = platformLabels(detectPlatform(currentNavigator()))
108
+
109
+ /** The minimal keydown face the chord test reads. */
110
+ export interface KeyChordEvent {
111
+ key: string
112
+ ctrlKey: boolean
113
+ metaKey: boolean
114
+ altKey: boolean
115
+ shiftKey: boolean
116
+ }
117
+
118
+ /**
119
+ * Whether a keydown event is the invoke chord advertised by
120
+ * {@link PlatformLabels.invokeLabel}.
121
+ *
122
+ * The hint and the binding have to agree: a footer promising `⌘K` while the
123
+ * handler matches `Ctrl+K` is worse than showing no hint at all. Both sides
124
+ * call this, so neither can be changed alone. Requiring the other modifier to
125
+ * be *absent* keeps the two chords distinct instead of letting either one
126
+ * satisfy both platforms.
127
+ *
128
+ * @param event - the keydown payload.
129
+ * @param isMac - the running platform's macOS-ness, from {@link LABELS}.
130
+ * @returns `true` when the event is the invoke chord.
131
+ */
132
+ export function isInvokeChord(event: KeyChordEvent, isMac: boolean): boolean {
133
+ if (event.key !== 'k' && event.key !== 'K') return false
134
+ if (event.altKey || event.shiftKey) return false
135
+ return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
136
+ }
package/src/config.ts CHANGED
@@ -1,33 +1,82 @@
1
- /**
2
- * dsh-manage-session 的共享配置面。
3
- *
4
- * 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
5
- * 使一处 admitted 的值在另一处也 admitted —— 与 dsh-session-search-toggle /
6
- * dsh-thinking-levels 的 config 面惯例一致。
7
- */
8
-
9
- /** 会话管家运行时配置(设置命名空间 + 组合入口)。 */
10
- export interface StewardConfig {
11
- /** 插件总开关。 */
12
- enabled: boolean
13
- /** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由、不渲染「养老院」页签。 */
14
- historyFiles?: boolean
15
- /** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
16
- healthCheck?: boolean
17
- }
18
-
19
- /** 缺省值。 */
20
- export const DEFAULT_CONFIG: Required<StewardConfig> = {
21
- enabled: true,
22
- historyFiles: true,
23
- healthCheck: true,
24
- }
25
-
26
- /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
27
- export const STEWARD_SETTINGS_NAMESPACE = 'session-steward'
28
-
29
- /** 侧边栏入口 id(客户端注册 id)。 */
30
- export const STEWARD_ENTRY_ID = 'dsh-manage-session'
31
-
32
- /** host 路由前缀(与搜索索引插件的 /switch-search/api 互不干扰)。 */
33
- export const STEWARD_API_PREFIX = '/session-steward/api'
1
+ /**
2
+ * dsh-manage-session 的共享配置面(合并包:管家子域 + 搜索索引子域)。
3
+ *
4
+ * 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
5
+ * 使一处 admitted 的值在另一处也 admitted。
6
+ *
7
+ * 两个设置命名空间**并存且互不迁移**:
8
+ * - `session-steward` —— 管家子域(enabled/historyFiles/healthCheck/search 等共享键);
9
+ * - `switch-search` —— 搜索子域的历史命名空间,存储键承诺保持稳定(见
10
+ * dsh-search-index README「存储键保持稳定,不做迁移」);0.1.7 起两者实际
11
+ * 都落在同一 composition entry(configForms),旧宿主才按命名空间分流。
12
+ */
13
+
14
+ /** 会话管家运行时配置(设置命名空间 + 组合入口)。 */
15
+ export interface StewardConfig {
16
+ /** 插件总开关(合并包唯一 master:关闭后不注册任何子域路由)。 */
17
+ enabled: boolean
18
+ /** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由、不渲染「养老院」页签。 */
19
+ historyFiles?: boolean
20
+ /** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
21
+ healthCheck?: boolean
22
+ /** 搜索索引子域(`index-*` 路由与侧栏搜索入口)。关闭后索引子域整体停摆。 */
23
+ search?: boolean
24
+ }
25
+
26
+ /** 缺省值。 */
27
+ export const DEFAULT_CONFIG: Required<StewardConfig> = {
28
+ enabled: true,
29
+ historyFiles: true,
30
+ healthCheck: true,
31
+ search: true,
32
+ }
33
+
34
+ /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
35
+ export const STEWARD_SETTINGS_NAMESPACE = 'session-steward'
36
+
37
+ /** 侧边栏入口 id(客户端注册 id)。 */
38
+ export const STEWARD_ENTRY_ID = 'dsh-manage-session'
39
+
40
+ /** host 路由前缀(管家子域;与搜索子域的 /switch-search/api 互不干扰)。 */
41
+ export const STEWARD_API_PREFIX = '/session-steward/api'
42
+
43
+ /** ------------------------------------------------------------------ 搜索子域 */
44
+
45
+ /** 搜索子域的历史设置命名空间(存储键稳定承诺,永不迁移)。 */
46
+ export const SWITCH_SEARCH_SETTINGS_NAMESPACE = 'switch-search'
47
+
48
+ /** 搜索子域路由前缀(历史值,浏览器旧 bundle 与快照文件名依赖它,保持不变)。 */
49
+ export const SWITCH_API_PREFIX = '/switch-search/api'
50
+
51
+ /** 搜索面板打开时的默认模式。 */
52
+ export type SwitchSearchDefaultMode = 'title' | 'content'
53
+
54
+ /** 搜索子域可调配置(独立于 StewardConfig 的历史形状,字段名保持稳定)。 */
55
+ export interface SwitchSearchConfig {
56
+ /**
57
+ * 搜索卡上的「启用」开关。合并包里它与管家 `enabled` 是**同一个**组合入口
58
+ * 字段(0.1.7 configForms 单 scope);保留在此处是为了 legacy settingsScope
59
+ * 回退路径(旧 switch-search 命名空间的存储形状)与设置卡类型不变。
60
+ */
61
+ enabled?: boolean
62
+ /** 面板默认模式。 */
63
+ defaultMode: SwitchSearchDefaultMode
64
+ /** 独立索引是否后台自动同步(水位轮询)。 */
65
+ autoSync?: boolean
66
+ /** 增量同步间隔(ms)。 */
67
+ syncIntervalMs?: number
68
+ /** 整理索引后保留的归档份数。 */
69
+ archiveKeep?: number
70
+ /** 独立索引文件的可选绝对目录。 */
71
+ indexDir?: string
72
+ }
73
+
74
+ /** 搜索子域缺省值。 */
75
+ export const SWITCH_DEFAULT_CONFIG: Required<SwitchSearchConfig> = {
76
+ enabled: true,
77
+ defaultMode: 'title',
78
+ autoSync: true,
79
+ syncIntervalMs: 30_000,
80
+ archiveKeep: 2,
81
+ indexDir: '',
82
+ }
@@ -1,118 +1,118 @@
1
- /**
2
- * 归属:把投影 key 指回产出它的插件包。
3
- *
4
- * 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
5
- * `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
6
- * `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
7
- * `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
8
- */
9
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
10
- import { homedir } from 'node:os'
11
- import { join } from 'node:path'
12
- import type { GateAttribution } from './gates.ts'
13
-
14
- /** 一条归属记录。 */
15
- export interface ProjectionOwner {
16
- key: string
17
- /** 包名;内置包以 `@deepseek-ai/` 前缀判定。 */
18
- package: string
19
- /** 'core' 表示内置包,'plugin' 表示第三方插件。 */
20
- kind: 'core' | 'plugin'
21
- /** 命中文件(相对 node_modules)。 */
22
- entry: string
23
- }
24
-
25
- /** 默认扫描根:profile 的 node_modules。 */
26
- export function defaultProfileNodeModules(dshHome?: string): string {
27
- return join(dshHome ?? join(homedir(), '.dsh'), 'profiles', 'web', 'node_modules')
28
- }
29
-
30
- /** 收集候选包目录(含 @scope/pkg 一层)。 */
31
- function candidatePackages(root: string): { name: string; dir: string }[] {
32
- const out: { name: string; dir: string }[] = []
33
- if (!existsSync(root)) return out
34
- for (const entry of readdirSync(root, { withFileTypes: true })) {
35
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
36
- if (entry.name.startsWith('.')) continue
37
- const dir = join(root, entry.name)
38
- if (entry.name.startsWith('@')) {
39
- let scoped: string[] = []
40
- try {
41
- scoped = readdirSync(dir)
42
- } catch { continue }
43
- for (const child of scoped) {
44
- out.push({ name: `${entry.name}/${child}`, dir: join(dir, child) })
45
- }
46
- continue
47
- }
48
- out.push({ name: entry.name, dir })
49
- }
50
- return out
51
- }
52
-
53
- /** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
54
- const MAX_SCAN_BYTES = 12 << 20
55
-
56
- /** 扫描若干候选入口文件里出现的投影 key。 */
57
- function keysInFile(file: string): string[] {
58
- if (!existsSync(file)) return []
59
- let text: string
60
- try {
61
- if (statSync(file).size > MAX_SCAN_BYTES) return []
62
- text = readFileSync(file, 'utf8')
63
- } catch {
64
- return []
65
- }
66
- const keys = new Set<string>()
67
- const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g
68
- let match: RegExpExecArray | null
69
- while ((match = pattern.exec(text)) !== null) {
70
- const key = match[1]
71
- if (key !== undefined && key !== '') keys.add(key)
72
- }
73
- return [...keys]
74
- }
75
-
76
- /**
77
- * 建立投影 key → 包 的索引。
78
- * @param nodeModulesRoot - profile 的 node_modules 根。
79
- * @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
80
- */
81
- export function buildProjectionOwnerIndex(nodeModulesRoot: string): ProjectionOwner[] {
82
- const records: ProjectionOwner[] = []
83
- const seen = new Set<string>()
84
- for (const pack of candidatePackages(nodeModulesRoot)) {
85
- const lib = join(pack.dir, 'lib')
86
- let entries: string[] = []
87
- try {
88
- if (!statSync(lib).isDirectory()) continue
89
- entries = readdirSync(lib).filter(name => name.endsWith('.js'))
90
- } catch {
91
- continue
92
- }
93
- for (const name of entries) {
94
- const file = join(lib, name)
95
- for (const key of keysInFile(file)) {
96
- if (seen.has(key)) continue
97
- seen.add(key)
98
- records.push({
99
- key,
100
- package: pack.name,
101
- kind: pack.name.startsWith('@deepseek-ai/') ? 'core' : 'plugin',
102
- entry: `${pack.name}/lib/${name}`,
103
- })
104
- }
105
- }
106
- }
107
- return records.sort((left, right) => left.key.localeCompare(right.key))
108
- }
109
-
110
- /** 索引查询器。 */
111
- export function createAttributor(index: readonly ProjectionOwner[]): (projection: string) => GateAttribution | undefined {
112
- const byKey = new Map(index.map(record => [record.key, record]))
113
- return (projection: string) => {
114
- const owner = byKey.get(projection)
115
- if (owner === undefined) return { projection, package: 'unknown' }
116
- return { projection, package: owner.package }
117
- }
118
- }
1
+ /**
2
+ * 归属:把投影 key 指回产出它的插件包。
3
+ *
4
+ * 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
5
+ * `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
6
+ * `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
7
+ * `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
8
+ */
9
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import type { GateAttribution } from './gates.ts'
13
+
14
+ /** 一条归属记录。 */
15
+ export interface ProjectionOwner {
16
+ key: string
17
+ /** 包名;内置包以 `@deepseek-ai/` 前缀判定。 */
18
+ package: string
19
+ /** 'core' 表示内置包,'plugin' 表示第三方插件。 */
20
+ kind: 'core' | 'plugin'
21
+ /** 命中文件(相对 node_modules)。 */
22
+ entry: string
23
+ }
24
+
25
+ /** 默认扫描根:profile 的 node_modules。 */
26
+ export function defaultProfileNodeModules(dshHome?: string): string {
27
+ return join(dshHome ?? join(homedir(), '.dsh'), 'profiles', 'web', 'node_modules')
28
+ }
29
+
30
+ /** 收集候选包目录(含 @scope/pkg 一层)。 */
31
+ function candidatePackages(root: string): { name: string; dir: string }[] {
32
+ const out: { name: string; dir: string }[] = []
33
+ if (!existsSync(root)) return out
34
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
35
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
36
+ if (entry.name.startsWith('.')) continue
37
+ const dir = join(root, entry.name)
38
+ if (entry.name.startsWith('@')) {
39
+ let scoped: string[] = []
40
+ try {
41
+ scoped = readdirSync(dir)
42
+ } catch { continue }
43
+ for (const child of scoped) {
44
+ out.push({ name: `${entry.name}/${child}`, dir: join(dir, child) })
45
+ }
46
+ continue
47
+ }
48
+ out.push({ name: entry.name, dir })
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
54
+ const MAX_SCAN_BYTES = 12 << 20
55
+
56
+ /** 扫描若干候选入口文件里出现的投影 key。 */
57
+ function keysInFile(file: string): string[] {
58
+ if (!existsSync(file)) return []
59
+ let text: string
60
+ try {
61
+ if (statSync(file).size > MAX_SCAN_BYTES) return []
62
+ text = readFileSync(file, 'utf8')
63
+ } catch {
64
+ return []
65
+ }
66
+ const keys = new Set<string>()
67
+ const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g
68
+ let match: RegExpExecArray | null
69
+ while ((match = pattern.exec(text)) !== null) {
70
+ const key = match[1]
71
+ if (key !== undefined && key !== '') keys.add(key)
72
+ }
73
+ return [...keys]
74
+ }
75
+
76
+ /**
77
+ * 建立投影 key → 包 的索引。
78
+ * @param nodeModulesRoot - profile 的 node_modules 根。
79
+ * @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
80
+ */
81
+ export function buildProjectionOwnerIndex(nodeModulesRoot: string): ProjectionOwner[] {
82
+ const records: ProjectionOwner[] = []
83
+ const seen = new Set<string>()
84
+ for (const pack of candidatePackages(nodeModulesRoot)) {
85
+ const lib = join(pack.dir, 'lib')
86
+ let entries: string[] = []
87
+ try {
88
+ if (!statSync(lib).isDirectory()) continue
89
+ entries = readdirSync(lib).filter(name => name.endsWith('.js'))
90
+ } catch {
91
+ continue
92
+ }
93
+ for (const name of entries) {
94
+ const file = join(lib, name)
95
+ for (const key of keysInFile(file)) {
96
+ if (seen.has(key)) continue
97
+ seen.add(key)
98
+ records.push({
99
+ key,
100
+ package: pack.name,
101
+ kind: pack.name.startsWith('@deepseek-ai/') ? 'core' : 'plugin',
102
+ entry: `${pack.name}/lib/${name}`,
103
+ })
104
+ }
105
+ }
106
+ }
107
+ return records.sort((left, right) => left.key.localeCompare(right.key))
108
+ }
109
+
110
+ /** 索引查询器。 */
111
+ export function createAttributor(index: readonly ProjectionOwner[]): (projection: string) => GateAttribution | undefined {
112
+ const byKey = new Map(index.map(record => [record.key, record]))
113
+ return (projection: string) => {
114
+ const owner = byKey.get(projection)
115
+ if (owner === undefined) return { projection, package: 'unknown' }
116
+ return { projection, package: owner.package }
117
+ }
118
+ }