dsh-manage-session 2.0.3 → 2.0.5

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 (43) hide show
  1. package/CHANGELOG.ja.md +33 -33
  2. package/CHANGELOG.ko.md +33 -33
  3. package/CHANGELOG.md +33 -33
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +76 -62
  10. package/README.ja.md +53 -39
  11. package/README.ko.md +52 -38
  12. package/README.md +18 -4
  13. package/cordis.patch.yml +19 -19
  14. package/dsh.plugin.json +2 -2
  15. package/locale/de.json +6 -6
  16. package/locale/en.json +6 -6
  17. package/locale/es.json +6 -6
  18. package/locale/fr.json +6 -6
  19. package/locale/it.json +6 -6
  20. package/locale/ja.json +6 -6
  21. package/locale/ko.json +6 -6
  22. package/locale/ru.json +6 -6
  23. package/locale/zh.json +6 -6
  24. package/package.json +9 -9
  25. package/screenshots.json +3 -3
  26. package/src/client/bridge-scope.ts +202 -202
  27. package/src/client/card.tsx +154 -154
  28. package/src/client/host-api.ts +61 -61
  29. package/src/client/index.ts +594 -594
  30. package/src/client/locales.ts +893 -893
  31. package/src/client/manage-locales.ts +178 -178
  32. package/src/client/manage.tsx +430 -430
  33. package/src/client/panel.tsx +131 -131
  34. package/src/config.ts +43 -43
  35. package/src/host/health/attribution.ts +118 -118
  36. package/src/host/health/lossless.ts +128 -128
  37. package/src/host/health/source-kind.ts +350 -350
  38. package/src/host/history/archive-source.ts +250 -250
  39. package/src/host/history/archive.ts +249 -249
  40. package/src/host/history/favorites.ts +71 -71
  41. package/src/host/history/purge.ts +370 -370
  42. package/src/host/registry/archive-registry.ts +317 -317
  43. package/src/host/settings-bridge.ts +222 -222
@@ -1,131 +1,131 @@
1
- /**
2
- * 会话管理面板壳:一个侧边栏入口,两个页签(管理=主页面 / 体检=备用)。
3
- *
4
- * 2026-10-20 页面调整(用户定标):「养老院」页签取消——管理台已覆盖其全部
5
- * 动作面(archive/unarchive/purge + 归档域筛选),独立页签属重复入口;
6
- * 历史文件组件(history-panel.tsx)随摘除,宿主侧 history 路由与配置字段
7
- * (historyFiles)保留不动,字典键留用以备回迁。
8
- *
9
- * 页签可见性由开关决定:`search=false` 时「管理」消失(退到体检),
10
- * `healthCheck=false` 时「体检」消失;两者都关时进入面板直接显示空壳提示
11
- * (客户端也会隐藏入口)。
12
- */
13
- import { createElement, useEffect, useState, type ReactElement } from 'react'
14
- import { createPortal } from 'react-dom'
15
- import { HealthPanel } from './health-panel.tsx'
16
- import { ManageConsole } from './manage.tsx'
17
- import { translate, type LocaleKey } from './locales.ts'
18
-
19
- /** 面板字典面。 */
20
- export type PanelTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
21
-
22
- /** 页签 id(导出供测试断言)。 */
23
- export const TAB_HEALTH = 'dms-tab-health'
24
- export const TAB_MANAGE = 'dms-tab-manage'
25
-
26
- /** 面板入参。 */
27
- export interface StewardPanelProps {
28
- t?: PanelTranslate
29
- /** 健康检查子域是否可见(关掉只剩管理页签)。 */
30
- healthCheck: boolean
31
- /** 打开会话(管理台行点击;apply 侧经 openSessionThrough 解析)。 */
32
- openSession?: (sessionId: string) => void
33
- onClose: () => void
34
- /**
35
- * 壳形态:`dialog`(缺省)= 背景遮罩上的浮动对话框(底栏入口用);
36
- * `page` = 填满主栏的整页(顶栏工具行入口用,无遮罩 / 无圆角阴影)。
37
- */
38
- variant?: 'dialog' | 'page'
39
- }
40
-
41
- /** 会话管理对话框 / 整页。 */
42
- export function StewardPanel({ t, healthCheck, openSession, onClose, variant = 'dialog' }: StewardPanelProps): ReactElement {
43
- // 主页面=管理(会话管理台);体检是备用页签。
44
- const [tab, setTab] = useState<'health' | 'manage'>('manage')
45
-
46
- // 开关变化后收敛到仍然可见的页签(体检关掉→回管理)。
47
- useEffect(() => {
48
- if (tab === 'health' && !healthCheck) setTab('manage')
49
- }, [tab, healthCheck])
50
-
51
- useEffect(() => {
52
- const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() }
53
- document.addEventListener('keydown', onKey)
54
- return () => { document.removeEventListener('keydown', onKey) }
55
- }, [onClose])
56
-
57
- const tabs: ReactElement[] = []
58
- tabs.push(createElement('button', {
59
- key: TAB_MANAGE,
60
- id: TAB_MANAGE,
61
- type: 'button',
62
- className: `dms_tab${tab === 'manage' ? ' dms_tabActive' : ''}`,
63
- 'aria-selected': tab === 'manage',
64
- onClick: () => { setTab('manage') },
65
- }, translate(t, 'panel.tab.manage')))
66
- if (healthCheck) {
67
- tabs.push(createElement('button', {
68
- key: TAB_HEALTH,
69
- id: TAB_HEALTH,
70
- type: 'button',
71
- className: `dms_tab${tab === 'health' ? ' dms_tabActive' : ''}`,
72
- 'aria-selected': tab === 'health',
73
- onClick: () => { setTab('health') },
74
- }, translate(t, 'panel.tab.health')))
75
- }
76
-
77
- const body: ReactElement = tab === 'manage'
78
- ? createElement('div', { style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 } },
79
- createElement(ManageConsole, { open: openSession ?? (() => {}) }))
80
- : createElement(HealthPanel, { t, onClose })
81
-
82
- const panelEl = createElement('div', {
83
- key: 'panel',
84
- className: variant === 'page' ? 'dms_panel dms_panelPage' : 'dms_panel',
85
- role: variant === 'page' ? 'region' : 'dialog',
86
- 'aria-label': translate(t, 'panel.title'),
87
- style: { display: 'flex', flexDirection: 'column' },
88
- }, [
89
- createElement('div', { key: 'head', className: 'dms_dialogHead' }, [
90
- createElement('span', { key: 'title', className: 'dms_dialogTitle' }, translate(t, 'panel.title')),
91
- createElement('span', { key: 'tabs', className: 'dms_tabRow' }, tabs),
92
- createElement('button', {
93
- key: 'close',
94
- type: 'button',
95
- className: 'dms_actBtn',
96
- onClick: onClose,
97
- }, translate(t, 'panel.close')),
98
- ]),
99
- body,
100
- ])
101
-
102
- // 整页形态直接占用主栏(宿主已给页容器,不再叠遮罩 / 传送门)。
103
- if (variant === 'page') return panelEl
104
- return createPortal(createElement('div', { key: 'steward-root' }, [
105
- createElement('div', { key: 'backdrop', className: 'dms_backdrop', onClick: onClose }),
106
- panelEl,
107
- ]), document.body)
108
- }
109
-
110
- /** 侧边栏脚部入口:宽栏带文案成行控件,收起轨道退化为 36x36 图标钮。 */
111
- export function StewardFooter(props: {
112
- onClick?: () => void
113
- title?: string
114
- label?: string
115
- wide?: boolean
116
- }): ReactElement {
117
- const wide = props.wide === true
118
- return createElement('button', {
119
- type: 'button',
120
- className: wide ? 'dms_footerEntry' : 'dms_footerEntry dms_footerEntryRail',
121
- title: props.title,
122
- 'aria-label': props.title,
123
- onClick: props.onClick,
124
- }, [
125
- createElement('span', { key: 'icon', className: 'dms_footerIcon', 'aria-hidden': true }, '🧭'),
126
- // 收起轨道里 36px 装不下文案,标签与宽栏同条件出现,与搜索入口一致。
127
- wide && props.label !== undefined
128
- ? createElement('span', { key: 'label', className: 'dms_footerLabel' }, props.label)
129
- : null,
130
- ])
131
- }
1
+ /**
2
+ * 会话管理面板壳:一个侧边栏入口,两个页签(管理=主页面 / 体检=备用)。
3
+ *
4
+ * 2026-10-20 页面调整(用户定标):「养老院」页签取消——管理台已覆盖其全部
5
+ * 动作面(archive/unarchive/purge + 归档域筛选),独立页签属重复入口;
6
+ * 历史文件组件(history-panel.tsx)随摘除,宿主侧 history 路由与配置字段
7
+ * (historyFiles)保留不动,字典键留用以备回迁。
8
+ *
9
+ * 页签可见性由开关决定:`search=false` 时「管理」消失(退到体检),
10
+ * `healthCheck=false` 时「体检」消失;两者都关时进入面板直接显示空壳提示
11
+ * (客户端也会隐藏入口)。
12
+ */
13
+ import { createElement, useEffect, useState, type ReactElement } from 'react'
14
+ import { createPortal } from 'react-dom'
15
+ import { HealthPanel } from './health-panel.tsx'
16
+ import { ManageConsole } from './manage.tsx'
17
+ import { translate, type LocaleKey } from './locales.ts'
18
+
19
+ /** 面板字典面。 */
20
+ export type PanelTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
21
+
22
+ /** 页签 id(导出供测试断言)。 */
23
+ export const TAB_HEALTH = 'dms-tab-health'
24
+ export const TAB_MANAGE = 'dms-tab-manage'
25
+
26
+ /** 面板入参。 */
27
+ export interface StewardPanelProps {
28
+ t?: PanelTranslate
29
+ /** 健康检查子域是否可见(关掉只剩管理页签)。 */
30
+ healthCheck: boolean
31
+ /** 打开会话(管理台行点击;apply 侧经 openSessionThrough 解析)。 */
32
+ openSession?: (sessionId: string) => void
33
+ onClose: () => void
34
+ /**
35
+ * 壳形态:`dialog`(缺省)= 背景遮罩上的浮动对话框(底栏入口用);
36
+ * `page` = 填满主栏的整页(顶栏工具行入口用,无遮罩 / 无圆角阴影)。
37
+ */
38
+ variant?: 'dialog' | 'page'
39
+ }
40
+
41
+ /** 会话管理对话框 / 整页。 */
42
+ export function StewardPanel({ t, healthCheck, openSession, onClose, variant = 'dialog' }: StewardPanelProps): ReactElement {
43
+ // 主页面=管理(会话管理台);体检是备用页签。
44
+ const [tab, setTab] = useState<'health' | 'manage'>('manage')
45
+
46
+ // 开关变化后收敛到仍然可见的页签(体检关掉→回管理)。
47
+ useEffect(() => {
48
+ if (tab === 'health' && !healthCheck) setTab('manage')
49
+ }, [tab, healthCheck])
50
+
51
+ useEffect(() => {
52
+ const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() }
53
+ document.addEventListener('keydown', onKey)
54
+ return () => { document.removeEventListener('keydown', onKey) }
55
+ }, [onClose])
56
+
57
+ const tabs: ReactElement[] = []
58
+ tabs.push(createElement('button', {
59
+ key: TAB_MANAGE,
60
+ id: TAB_MANAGE,
61
+ type: 'button',
62
+ className: `dms_tab${tab === 'manage' ? ' dms_tabActive' : ''}`,
63
+ 'aria-selected': tab === 'manage',
64
+ onClick: () => { setTab('manage') },
65
+ }, translate(t, 'panel.tab.manage')))
66
+ if (healthCheck) {
67
+ tabs.push(createElement('button', {
68
+ key: TAB_HEALTH,
69
+ id: TAB_HEALTH,
70
+ type: 'button',
71
+ className: `dms_tab${tab === 'health' ? ' dms_tabActive' : ''}`,
72
+ 'aria-selected': tab === 'health',
73
+ onClick: () => { setTab('health') },
74
+ }, translate(t, 'panel.tab.health')))
75
+ }
76
+
77
+ const body: ReactElement = tab === 'manage'
78
+ ? createElement('div', { style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 } },
79
+ createElement(ManageConsole, { open: openSession ?? (() => {}) }))
80
+ : createElement(HealthPanel, { t, onClose })
81
+
82
+ const panelEl = createElement('div', {
83
+ key: 'panel',
84
+ className: variant === 'page' ? 'dms_panel dms_panelPage' : 'dms_panel',
85
+ role: variant === 'page' ? 'region' : 'dialog',
86
+ 'aria-label': translate(t, 'panel.title'),
87
+ style: { display: 'flex', flexDirection: 'column' },
88
+ }, [
89
+ createElement('div', { key: 'head', className: 'dms_dialogHead' }, [
90
+ createElement('span', { key: 'title', className: 'dms_dialogTitle' }, translate(t, 'panel.title')),
91
+ createElement('span', { key: 'tabs', className: 'dms_tabRow' }, tabs),
92
+ createElement('button', {
93
+ key: 'close',
94
+ type: 'button',
95
+ className: 'dms_actBtn',
96
+ onClick: onClose,
97
+ }, translate(t, 'panel.close')),
98
+ ]),
99
+ body,
100
+ ])
101
+
102
+ // 整页形态直接占用主栏(宿主已给页容器,不再叠遮罩 / 传送门)。
103
+ if (variant === 'page') return panelEl
104
+ return createPortal(createElement('div', { key: 'steward-root' }, [
105
+ createElement('div', { key: 'backdrop', className: 'dms_backdrop', onClick: onClose }),
106
+ panelEl,
107
+ ]), document.body)
108
+ }
109
+
110
+ /** 侧边栏脚部入口:宽栏带文案成行控件,收起轨道退化为 36x36 图标钮。 */
111
+ export function StewardFooter(props: {
112
+ onClick?: () => void
113
+ title?: string
114
+ label?: string
115
+ wide?: boolean
116
+ }): ReactElement {
117
+ const wide = props.wide === true
118
+ return createElement('button', {
119
+ type: 'button',
120
+ className: wide ? 'dms_footerEntry' : 'dms_footerEntry dms_footerEntryRail',
121
+ title: props.title,
122
+ 'aria-label': props.title,
123
+ onClick: props.onClick,
124
+ }, [
125
+ createElement('span', { key: 'icon', className: 'dms_footerIcon', 'aria-hidden': true }, '🧭'),
126
+ // 收起轨道里 36px 装不下文案,标签与宽栏同条件出现,与搜索入口一致。
127
+ wide && props.label !== undefined
128
+ ? createElement('span', { key: 'label', className: 'dms_footerLabel' }, props.label)
129
+ : null,
130
+ ])
131
+ }
package/src/config.ts CHANGED
@@ -1,43 +1,43 @@
1
- /**
2
- * dsh-manage-session 的共享配置面(会话管理器:归档 + 体检)。
3
- *
4
- * 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
5
- * 使一处 admitted 的值在另一处也 admitted。
6
- *
7
- * 设置命名空间只有一个:`manage-session`(enabled/historyFiles/healthCheck/directDelete)。
8
- * 搜索/索引子域已拆回 dsh-search-index,本包不再持有 `search` 开关、索引目录、
9
- * 语料源(DRS)等字段——DRS 依赖也随搜索一起迁回那边。
10
- */
11
-
12
- /** 会话管理运行时配置(设置命名空间 + 组合入口)。 */
13
- export interface StewardConfig {
14
- /** 插件总开关(关闭后不注册任何子域路由)。 */
15
- enabled: boolean
16
- /** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由。 */
17
- historyFiles?: boolean
18
- /** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
19
- healthCheck?: boolean
20
- /**
21
- * 直接删除入口(2026-10-20 用户定标):开启后侧栏会话菜单出现「删除」行
22
- * (两步确认,走管理端同款 session-history-purge 链路)。
23
- * **缺省关**——破坏性入口不默认铺开;管理端批量删除不受本开关限制。
24
- */
25
- directDelete?: boolean
26
- }
27
-
28
- /** 缺省值。 */
29
- export const DEFAULT_CONFIG: Required<StewardConfig> = {
30
- enabled: true,
31
- historyFiles: true,
32
- healthCheck: true,
33
- directDelete: false,
34
- }
35
-
36
- /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
37
- export const STEWARD_SETTINGS_NAMESPACE = 'manage-session'
38
-
39
- /** 侧边栏入口 id(客户端注册 id)。 */
40
- export const STEWARD_ENTRY_ID = 'dsh-manage-session'
41
-
42
- /** host 路由前缀(管理 + 体检,方法名一律 `session-*`)。 */
43
- export const STEWARD_API_PREFIX = '/manage-session/api'
1
+ /**
2
+ * dsh-manage-session 的共享配置面(会话管理器:归档 + 体检)。
3
+ *
4
+ * 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
5
+ * 使一处 admitted 的值在另一处也 admitted。
6
+ *
7
+ * 设置命名空间只有一个:`manage-session`(enabled/historyFiles/healthCheck/directDelete)。
8
+ * 搜索/索引子域已拆回 dsh-search-index,本包不再持有 `search` 开关、索引目录、
9
+ * 语料源(DRS)等字段——DRS 依赖也随搜索一起迁回那边。
10
+ */
11
+
12
+ /** 会话管理运行时配置(设置命名空间 + 组合入口)。 */
13
+ export interface StewardConfig {
14
+ /** 插件总开关(关闭后不注册任何子域路由)。 */
15
+ enabled: boolean
16
+ /** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由。 */
17
+ historyFiles?: boolean
18
+ /** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
19
+ healthCheck?: boolean
20
+ /**
21
+ * 直接删除入口(2026-10-20 用户定标):开启后侧栏会话菜单出现「删除」行
22
+ * (两步确认,走管理端同款 session-history-purge 链路)。
23
+ * **缺省关**——破坏性入口不默认铺开;管理端批量删除不受本开关限制。
24
+ */
25
+ directDelete?: boolean
26
+ }
27
+
28
+ /** 缺省值。 */
29
+ export const DEFAULT_CONFIG: Required<StewardConfig> = {
30
+ enabled: true,
31
+ historyFiles: true,
32
+ healthCheck: true,
33
+ directDelete: false,
34
+ }
35
+
36
+ /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
37
+ export const STEWARD_SETTINGS_NAMESPACE = 'manage-session'
38
+
39
+ /** 侧边栏入口 id(客户端注册 id)。 */
40
+ export const STEWARD_ENTRY_ID = 'dsh-manage-session'
41
+
42
+ /** host 路由前缀(管理 + 体检,方法名一律 `session-*`)。 */
43
+ export const STEWARD_API_PREFIX = '/manage-session/api'
@@ -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
+ }