dsh-router-core 0.4.2 → 0.4.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 (51) hide show
  1. package/README.md +9 -10
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +64 -2
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +286 -127
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +64 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-version.d.ts +7 -8
  21. package/lib/types/index.d.ts +1 -1
  22. package/lib/types/shared.d.ts +33 -0
  23. package/package.json +17 -7
  24. package/src/client/EndpointTab.tsx +15 -18
  25. package/src/client/ExtDetail.tsx +552 -9
  26. package/src/client/index.tsx +7 -28
  27. package/src/client/router.css +132 -62
  28. package/src/ext/contract.ts +111 -0
  29. package/src/ext-prompt/assembly.test.ts +79 -0
  30. package/src/ext-prompt/content.ts +181 -0
  31. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  32. package/src/ext-prompt/index.ts +104 -0
  33. package/src/ext-prompt/locale/en.json +6 -0
  34. package/src/ext-prompt/locale/zh.json +6 -0
  35. package/src/ext-prompt/logo.svg +14 -0
  36. package/src/ext-prompt/logo.ts +18 -0
  37. package/src/ext-prompt/mount.test.ts +95 -0
  38. package/src/ext-prompt/mount.ts +79 -0
  39. package/src/ext-prompt/plugin.ts +223 -0
  40. package/src/ext-prompt/render.test.ts +256 -0
  41. package/src/ext-prompt/render.ts +216 -0
  42. package/src/ext-prompt/row.json +5 -0
  43. package/src/ext-route.test.ts +78 -0
  44. package/src/host-version.test.ts +21 -28
  45. package/src/host-version.ts +39 -39
  46. package/src/index.ts +216 -94
  47. package/src/llm/adapter.test.ts +19 -42
  48. package/src/llm/adapter.ts +20 -47
  49. package/src/shared.ts +34 -0
  50. package/src/suppliers/registry.test.ts +56 -0
  51. package/src/suppliers/registry.ts +40 -10
@@ -0,0 +1,216 @@
1
+ /**
2
+ * 纯逻辑层 —— 分类开关的读取、校验与 prompt 文本渲染。
3
+ *
4
+ * 为什么单独成文件:**不 import cordis / 任何 DSH 包**,所以能被 `node --test`
5
+ * 直接跑。DSH 侧的注册/挂载(ext.ts / inject.ts)只是薄壳;所有会出错的判断
6
+ * 都在这里,且这里能注入违规用例证明它们会变红。
7
+ */
8
+ import { PROMPT_CATEGORIES, PROMPT_TITLE, type PromptCategory } from './content.ts'
9
+
10
+ /** 落盘在 `router.extStore` 数据块里的形状。 */
11
+ export interface PromptExtData {
12
+ /** 分类开关:`{ [categoryId]: boolean }`。缺项走 `defaultOn`。 */
13
+ categories?: Record<string, boolean>
14
+ /**
15
+ * 用户在面板里改过的文本:`{ [categoryId]: { title?, body? } }`。
16
+ *
17
+ * ⚠️ **与 `categories` 分开两个键**:开关是"开不开"、文本是"写成什么样"。
18
+ * 挤在一个键里,改标题会顺手碰到开关状态(`writeData` 是**整块替换**,
19
+ * 少一个键就是少一份数据)。
20
+ *
21
+ * ⚠️ **只存覆盖值,不存全文**:没改过的分类不出现在这里 ⇒ 内置内容仍是唯一
22
+ * 事实源,升级内置文案时没改过的那几条会跟着更新。存全文 = 用户改一个字
23
+ * 就永久冻结了整段,之后内置更新对它们完全失效。
24
+ */
25
+ text?: Record<string, PromptCategoryOverride>
26
+ /**
27
+ * 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
28
+ * 内置那 14 条是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
29
+ * 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
30
+ *
31
+ * 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
32
+ */
33
+ custom?: PromptCategory[]
34
+ /**
35
+ * 展示顺序:`[id, …]`,含内置与自建。
36
+ *
37
+ * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置 14 条的次序是 `content.ts`
38
+ * 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
39
+ * 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
40
+ * 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
41
+ */
42
+ order?: string[]
43
+ }
44
+
45
+ /** 一条分类上用户改过的字段(只记改了的那些)。 */
46
+ export interface PromptCategoryOverride {
47
+ title?: string
48
+ body?: string
49
+ }
50
+
51
+ /** 解出某分类**生效用**的文本:用户覆盖优先,否则用内置的。 */
52
+ export function effectiveText(
53
+ id: string,
54
+ override: PromptCategoryOverride | undefined,
55
+ base: PromptCategory,
56
+ ): { title: string; body: string } {
57
+ return {
58
+ // ⚠️ 空串是**非法**覆盖(写入端已挡),万一脏数据进来了也不能让它把
59
+ // 分类"变成空的" —— 那会让那条规则凭空消失,看起来像被关了。
60
+ title: override?.title !== undefined && override.title !== '' ? override.title : base.title,
61
+ body: override?.body !== undefined && override.body !== '' ? override.body : base.body,
62
+ }
63
+ }
64
+
65
+ /** 分类 id 的全集(校验用)。 */
66
+ export function knownCategoryIds(): string[] {
67
+ return PROMPT_CATEGORIES.map((c) => c.id)
68
+ }
69
+
70
+ /**
71
+ * 解出「哪些分类要渲染」。
72
+ *
73
+ * 三层语义,**顺序不可换**:
74
+ * ① 总开关关 ⇒ 一条都不渲染(不是"渲染但没内容"——那样面板显示已开启、实际无效果);
75
+ * ② 数据块里显式写了的分类,用**用户存的值**(含显式 `false`);
76
+ * ③ 没写过的分类,用 `defaultOn`。
77
+ *
78
+ * ⚠️ 为什么 ② 要区别于「没写」:`false` 与 `undefined` 语义完全不同 ——
79
+ * 前者是用户主动关掉,后者是"没碰过"。把两者当同一件事,用户第一次关掉某分类
80
+ * 后升级插件、新增同名分类,那条就永远是关的(用户没要求过)。
81
+ */
82
+ export function resolveEnabledCategories(
83
+ masterOn: boolean,
84
+ data: PromptExtData | undefined,
85
+ ): Set<string> {
86
+ const out = new Set<string>()
87
+ if (!masterOn) return out
88
+ const saved = data?.categories
89
+ // ⚠️ 遍历的是**合成后的列表**(内置 + 自建),不是内置常量 ——
90
+ // 否则自建条目的开关永远算不出来(它不在 PROMPT_CATEGORIES 里)。
91
+ for (const c of resolveCategories(data)) {
92
+ const v = saved ? saved[c.id] : undefined
93
+ if (v === undefined) {
94
+ if (c.defaultOn) out.add(c.id)
95
+ } else if (v === true) {
96
+ out.add(c.id)
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ /**
103
+ * 渲染成**一个**段落文本(多分类拼一起)。
104
+ *
105
+ * 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
106
+ * 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
107
+ * 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
108
+ *
109
+ * @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
110
+ */
111
+ export function renderCategories(
112
+ enabled: ReadonlySet<string>,
113
+ categories: readonly PromptCategory[] = PROMPT_CATEGORIES,
114
+ overrides?: PromptExtData['text'],
115
+ /** 有没有加整段标题。默认 true;传**外部分类表**时传 false(见下方注释)。 */
116
+ withTitle = true,
117
+ ): string {
118
+ const lines: string[] = []
119
+ for (const c of categories) {
120
+ if (!enabled.has(c.id)) continue
121
+ // ⚠️ **这里用覆盖后的 body** —— 面板改的内容必须真的进 prompt。
122
+ // 漏了这一行,编辑就只是改了**显示**(用户会以为改了却没生效,
123
+ // 而 prompt 里还是旧的 ⇒ 最坏的一种"看起来成功了")。
124
+ // 自建条目没有覆盖(`text` 里不会有它的键),`effectiveText` 走原值。
125
+ lines.push(effectiveText(c.id, overrides?.[c.id], c).body)
126
+ }
127
+ // ⚠️ **标题放在最后 join 之前**:它不属于任何分类(见 PROMPT_TITLE 的注释),
128
+ // 且只在**至少有一条规则开启**时才出现 —— 全关时整段返回空串被平台丢弃,
129
+ // 留着标题就变成"有一套叫准则 v5 的东西"却一条规则都没有。
130
+ // 标题放在**前面**:它要解释后面这些规则是什么。
131
+ // ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n` 时 15 条规则在窄栏里
132
+ // 密排成一块,肉眼看不出是 15 条独立的规则 —— 那正是"全都跑成一行"的观感
133
+ // 来源(实测系统提示词里 `\n` 一直都在,是**排版**问题不是**内容**问题)。
134
+ // 空行让每条规则成为独立段落。代价是 13 个换行符(~13 token,可忽略),
135
+ // 且不改变前缀位置 ⇒ KV cache 行为不变。
136
+ // ⚠️ 标题由**显式参数**决定,**不靠 `categories === PROMPT_CATEGORIES` 这种
137
+ // 引用相等**(2026-09-29 实测踩过):接入自建条目后调用方传的是
138
+ // `resolveCategories(data)` 合成列表,引用永远不相等 ⇒ 标题静默消失
139
+ // (测试当场抓到)。「是不是默认那张表」是个**语义问题**,不该由
140
+ // 「是不是同一个对象」这种实现细节决定。
141
+ if (lines.length === 0) return ''
142
+ return withTitle ? [PROMPT_TITLE, ...lines].join('\n\n') : lines.join('\n\n')
143
+ }
144
+
145
+ /**
146
+ * 渲染当前应注入的完整 prompt 文本(总开关 + 分类开关都算进去)。
147
+ *
148
+ * 总开关关 ⇒ 空串,让上游 `renderPrompt` 丢弃该段 —— **不是**注入一段空白。
149
+ */
150
+ export function renderPromptText(masterOn: boolean, data: PromptExtData | undefined): string {
151
+ if (!masterOn) return ''
152
+ // ⚠️ 传入 `resolveCategories(data)`(含自建 + 排序)而不是常量
153
+ // `PROMPT_CATEGORIES` —— 否则拖动排序和自建条目都只停在面板上,不进 prompt。
154
+ return renderCategories(
155
+ resolveEnabledCategories(masterOn, data),
156
+ resolveCategories(data),
157
+ data?.text,
158
+ true, // 合成列表也是本扩展的条目集,照样加标题
159
+ )
160
+ }
161
+
162
+ /**
163
+ * 合成后的**一条**准则条目:内置或自建,已应用标题/正文覆盖。
164
+ * `builtin` 用来决定按钮(内置没有「删除」,只有「还原」)。
165
+ */
166
+ export interface ResolvedCategory extends PromptCategory {
167
+ /** 自建(用户加的)——可删除;内置的不可删除。 */
168
+ readonly custom: boolean
169
+ /** 文本被用户改过(决定「还原」按钮是否可点)。 */
170
+ readonly overridden: boolean
171
+ }
172
+
173
+ /**
174
+ * 内置 + 自建 + 排序 ⇒ 有序的最终条目列表。
175
+ *
176
+ * ⚠️ 三条规则(顺序不可换):
177
+ * 1. **内置在前、自建在后**,除非 `order` 显式给了别的次序。代码顺序是
178
+ * "准则作为一份文档的阅读顺序",不该被用户拖动改掉(见 `PromptExtData.order`)。
179
+ * 2. `order` 里**不认识的 id 一律忽略**,落盘不可信(手改的 ext.json、上一版的残渣)。
180
+ * 3. `order` **没提到的条目按原序补在后面** —— 丢了某条 ≠ 它消失了。
181
+ */
182
+ export function resolveCategories(data: PromptExtData | undefined): ResolvedCategory[] {
183
+ const custom = Array.isArray(data?.custom) ? data.custom : []
184
+ // 自建条目也要过滤:脏数据里的空 id / 空正文会让渲染产出空白段落。
185
+ const validCustom = custom.filter(
186
+ (c) => !!c && typeof c.id === 'string' && c.id !== '' && typeof c.body === 'string' && c.body !== '',
187
+ )
188
+ const base: ResolvedCategory[] = [
189
+ ...PROMPT_CATEGORIES.map((c) => ({
190
+ ...c,
191
+ custom: false,
192
+ overridden: data?.text?.[c.id] !== undefined,
193
+ })),
194
+ ...validCustom.map((c) => ({
195
+ ...c,
196
+ title: c.title,
197
+ defaultOn: c.defaultOn !== false,
198
+ custom: true,
199
+ overridden: false, // 自建条目没有"内置版本",改了就回不去
200
+ })),
201
+ ]
202
+ const order = Array.isArray(data?.order) ? data.order : []
203
+ if (order.length === 0) return base
204
+ const byId = new Map(base.map((c) => [c.id, c]))
205
+ const out: ResolvedCategory[] = []
206
+ for (const id of order) {
207
+ const hit = byId.get(id)
208
+ if (hit === undefined) continue // 规则 2
209
+ out.push(hit)
210
+ byId.delete(id)
211
+ }
212
+ for (const c of base) {
213
+ if (byId.has(c.id)) out.push(c) // 规则 3
214
+ }
215
+ return out
216
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-router-core-ext-prompt",
3
+ "type": "module",
4
+ "description": "分层提示词扩展(随 dsh-router 核心分发)"
5
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `/router/api/ext` 路由的**出口判据**。
3
+ *
4
+ * ⚠️ 为什么需要它(2026-09-30 实测的「扩展一直加载中、然后卡死」):
5
+ * 我重构 op 分支时以为「成功路径统一落到末尾响应」,把 GET 的
6
+ * `writeJson(res, 200, {ok:true, enhancers:list})` 删了 —— 结果**没有任何地方
7
+ * 写响应** ⇒ 请求永不结束 ⇒ 面板一直转圈。
8
+ *
9
+ * ⚠️ **它躲过了全部现有闸门**:typecheck 通过(`writeJson` 少调一次不是类型错误)、
10
+ * 387 条单测全绿(没有一条从 HTTP 入口打过这个路由)、build 通过。
11
+ * 典型的「绕过入口的验证 = 没验入口」—— 判据全在函数级,缺一条**路由级**的。
12
+ *
13
+ * 做法:不启真服务器,直接读源码断言**每个 return 之前都有出口**这种形状 ——
14
+ * 太脆。改为**结构判据**:GET 与 PATCH 两条成功路径都必须能走到 `writeJson`。
15
+ */
16
+ import test from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { readFileSync, readdirSync } from 'node:fs'
19
+
20
+ const src = readFileSync(new URL('./index.ts', import.meta.url), 'utf8')
21
+
22
+ /** 取出 `/ext` 路由的整段源码。 */
23
+ function extRouteBody(): string {
24
+ const start = src.indexOf('route(`${ROUTER_API_BASE}/ext`')
25
+ assert.ok(start > 0, '找不到 /ext 路由')
26
+ // 到下一个 `route(` 之前
27
+ const next = src.indexOf('\n route(', start + 10)
28
+ return src.slice(start, next > 0 ? next : start + 4000)
29
+ }
30
+
31
+ test('★ /ext 路由必须有 GET 的响应出口(少写一次 = 请求永不结束)', () => {
32
+ const body = extRouteBody()
33
+ const writes = body.match(/writeJson\(res, 2\d\d/g) ?? []
34
+ assert.ok(writes.length >= 1,
35
+ '/ext 路由里一处 writeJson 都没有 ⇒ 请求永不返回(面板一直"加载中")')
36
+ // GET 成功路径:算完 list 之后必须写出
37
+ const listLine = body.indexOf('const list: ExtInfo[]')
38
+ assert.ok(listLine > 0, '找不到 GET 的 list 计算')
39
+ const after = body.slice(listLine)
40
+ // ⚠️ 窗口取 600 而非 200:出口上方有 5 行注释,窗口太窄会**误判成缺失**
41
+ // (我第一版 200 就红了,而代码是好的 —— 又一次「判据比被守的东西严」)。
42
+ assert.match(after.slice(0, 600), /writeJson\(res,\s*200/,
43
+ 'GET 算完 list 之后没有 writeJson ⇒ 响应不出口')
44
+ })
45
+
46
+ test('★ op 成功路径也必须带 enhancers(客户端靠它刷新)', () => {
47
+ const body = extRouteBody()
48
+ // op 块里那处:两侧都带 enhancers
49
+ const opWrites = body.match(/writeJson\(res,\s*200,\s*addedId[^)]*enhancers/g) ?? []
50
+ assert.ok(opWrites.length >= 1, 'op 成功没有回 enhancers ⇒ 增删改后界面停在旧状态')
51
+ })
52
+
53
+ test('注入:删掉 GET 的 writeJson ⇒ 本判据会红', () => {
54
+ // 反向自检:把出口拿掉,上面那条必须不成立。
55
+ const stripped = extRouteBody().replace(
56
+ /writeJson\(res,\s*200,\s*\{\s*ok:\s*true,\s*enhancers:\s*list\s*\}\)/,
57
+ '/* removed */',
58
+ )
59
+ const after = stripped.slice(stripped.indexOf('const list: ExtInfo[]'))
60
+ assert.doesNotMatch(after.slice(0, 600), /writeJson\(res,\s*200/,
61
+ '注入没生效:这个替换匹配不上(判据会静默全绿)')
62
+ })
63
+
64
+ // ── 源码目录的野文件(2026-09-30 实测:`.bak` 被提交进仓)────────
65
+ test('★ src 下不许有备份/临时文件(它们会被 `git add -A` 顺手带进仓)', () => {
66
+ // ⚠️ 实测经过:我用 `sed -i.bak` 做注入验证,备份文件 `content.ts.bak`
67
+ // **被 `git add src/ext-prompt/` 带进了提交**。
68
+ // 它没被任何代码引用(`.bak` 后缀),所以**测试、typecheck、build 全绿** ——
69
+ // 典型的「产物看起来对、仓里却多了一份东西」。
70
+ // 而且它危险在:下一个人看到 `content.ts.bak` 会以为是**第二份内容**,
71
+ // 正是准则里「反查重复:新旧成两份」要防的。
72
+ //
73
+ // `check-publish` 只管「patch / exports / files 白名单 / 源码清单」的一致性,
74
+ // **不管源码目录里的野文件** ⇒ 这里补上。
75
+ const junk = [...readdirSync(new URL('.', import.meta.url), { recursive: true } as never) as string[]]
76
+ .filter((f) => /\.(bak|orig|rej|tmp|swp)$/.test(f) || /~$/.test(f))
77
+ assert.deepEqual(junk, [], `源码目录里有临时/备份文件:${junk.join(', ')}`)
78
+ })
@@ -1,9 +1,12 @@
1
1
  /**
2
- * 宿主版本探测测试 —— 锁死「最近命中徽章仅在 >= 0.1.7 出现」这条判定。
2
+ * 宿主版本探测测试 —— 锁死那条**版本地板**(>= 0.2.0)。
3
3
  *
4
- * 起因:`conversation.composer.dock` 座位在 0.1.5 也在,但渲染在 InputBar 之后
5
- * 的独立块(位置不对);只有 0.1.7 才放进底部操作行。座位名相同没法靠契约区分,
6
- * 只能按宿主版本号判定——本测试就是这条判据的唯一守卫。
4
+ * 这条判据现在只有一个用途:**在老宿主上出声**。兼容分叉(三处 `>= 0.1.7`)随
5
+ * 0.2.0 单面编写一起删掉了,所以本测试守的是「低于地板会被判为不支持 → apply 时
6
+ * 打警告」这条报警链,而不是某个功能开关。
7
+ *
8
+ * 地板抬到 0.2.0 的那一刻,0.1.7 从「支持」翻成「不支持」——这条断言就是那次
9
+ * 翻面的记录:谁想再加回 0.1.x 兼容,本测试会先红。
7
10
  */
8
11
  import test from 'node:test'
9
12
  import assert from 'node:assert/strict'
@@ -11,7 +14,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
11
14
  import { tmpdir } from 'node:os'
12
15
  import { join } from 'node:path'
13
16
  import { pathToFileURL } from 'node:url'
14
- import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
17
+ import { detectHostVersion, isHostSupported } from './host-version.ts'
15
18
 
16
19
  /** 造一个「装了指定版本 dsh」的临时 profile,返回它的 file: URL。 */
17
20
  function profileWithDsh(version: string | null): string {
@@ -25,37 +28,27 @@ function profileWithDsh(version: string | null): string {
25
28
  }
26
29
 
27
30
  test('读得到版本:detectHostVersion 返回 profile 里装的 dsh 版本', () => {
28
- assert.equal(detectHostVersion(profileWithDsh('0.1.5-rc.3')), '0.1.5-rc.3')
29
- assert.equal(detectHostVersion(profileWithDsh('0.1.7-alpha.2')), '0.1.7-alpha.2')
31
+ assert.equal(detectHostVersion(profileWithDsh('0.1.7-rc.2')), '0.1.7-rc.2')
32
+ assert.equal(detectHostVersion(profileWithDsh('0.2.0-rc.1')), '0.2.0-rc.1')
30
33
  })
31
34
 
32
- test('支持位:>= 0.1.7 才为真(预发布标识忽略,按前导数字比)', () => {
33
- assert.equal(supportsLastHitDock(profileWithDsh('0.1.7-alpha.2')), true)
34
- assert.equal(supportsLastHitDock(profileWithDsh('0.1.7')), true)
35
- assert.equal(supportsLastHitDock(profileWithDsh('0.2.0')), true)
36
- assert.equal(supportsLastHitDock(profileWithDsh('1.0.0')), true)
37
- // 低于 0.1.7 → 不挂(0.1.5 位置不对,宁可不显示)
38
- assert.equal(supportsLastHitDock(profileWithDsh('0.1.5-rc.3')), false)
39
- assert.equal(supportsLastHitDock(profileWithDsh('0.1.6-alpha.2')), false)
40
- assert.equal(supportsLastHitDock(profileWithDsh('0.1.4')), false)
35
+ test('地板:>= 0.2.0 才算支持(预发布标识忽略,按前导数字比)', () => {
36
+ for (const v of ['0.2.0-rc.1', '0.2.0', '0.2.1', '1.0.0']) {
37
+ assert.equal(isHostSupported(profileWithDsh(v)), true, v)
38
+ }
41
39
  })
42
40
 
43
- test('isHost017Plus 与徽章支持位同源(三处兼容分叉共用一个判据)', () => {
44
- for (const v of ['0.1.7-alpha.2', '0.1.7', '0.2.0', '1.0.0']) {
45
- assert.equal(isHost017Plus(profileWithDsh(v)), true, v)
46
- assert.equal(supportsLastHitDock(profileWithDsh(v)), true, v)
47
- }
48
- for (const v of ['0.1.5-rc.3', '0.1.6-alpha.2', '0.1.4']) {
49
- assert.equal(isHost017Plus(profileWithDsh(v)), false, v)
50
- assert.equal(supportsLastHitDock(profileWithDsh(v)), false, v)
41
+ test('地板之下(0.1.5/0.1.6/0.1.7)判为不支持 —— 会打警告而不是静默走错路径', () => {
42
+ for (const v of ['0.1.5-rc.3', '0.1.6-alpha.2', '0.1.7-rc.2', '0.1.4']) {
43
+ assert.equal(isHostSupported(profileWithDsh(v)), false, v)
51
44
  }
52
45
  })
53
46
 
54
- test('读不到版本 → 一律当不支持(不抛、宁可不显示)', () => {
47
+ test('读不到版本 → 判为不支持(无法确认也值得说一声),且不抛', () => {
55
48
  assert.equal(detectHostVersion(profileWithDsh(null)), undefined)
56
- assert.equal(supportsLastHitDock(profileWithDsh(null)), false)
49
+ assert.equal(isHostSupported(profileWithDsh(null)), false)
57
50
  // 垃圾版本号
58
- assert.equal(supportsLastHitDock(profileWithDsh('not-a-version')), false)
51
+ assert.equal(isHostSupported(profileWithDsh('not-a-version')), false)
59
52
  // baseUrl 缺失(冷启动/单测)→ 回落 ~/.dsh/profiles/web,不抛
60
- assert.doesNotThrow(() => supportsLastHitDock(undefined))
53
+ assert.doesNotThrow(() => isHostSupported(undefined))
61
54
  })
@@ -1,37 +1,46 @@
1
1
  /**
2
- * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
2
+ * 宿主 DSH 版本探测 —— 现在只剩两件事:**报版本**(日志 + `/router/api/health`)与
3
+ * **守版本地板**。
3
4
  *
4
- * 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
5
- * ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
6
- * 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
7
- * profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
5
+ * ## 为什么不再有兼容分叉
8
6
  *
9
- * 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
10
- * 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
11
- * Config 驱动(无此方法)→ 按版本走对应注册路径。
12
- * 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
13
- * 块;0.1.7+ = 独立 role:'tool' 消息。
7
+ * 原来这里有三处 `>= 0.1.7` 的分叉(设置卡片注册路径、「最近命中」徽章座位、adapter
8
+ * 的工具结果消息形态),彼此独立判断、还容易误判。本插件现已按 **0.2.0 单面**编写,
9
+ * 三处的旧分支全部删除:
10
+ *
11
+ * 1. 设置卡片:0.1.5 走 `settings.installSection`,0.1.7+ 走导出的 volatile Config。
12
+ * 0.2.0 只有后者 → installSection 分支删除。
13
+ * 2. 「最近命中」徽章:0.1.5 渲染在 InputBar 之后的独立块(位置不对),0.1.7+ 才在
14
+ * composer 底部操作行。0.2.0 起无条件挂载,不再问支持位。
15
+ * 3. adapter 工具结果形态:旧的「user 内嵌 tool-result 块」0.1.7 起退役;0.2.0 的
16
+ * v3→v4 会话迁移还主动拒绝该包装,所以它到不了 adapter → 分支删除。
17
+ *
18
+ * ## 为什么仍要读版本
19
+ *
20
+ * 因为**跑在老宿主上必须出声**。删掉分叉不等于「老宿主也能跑」:0.1.x 上本插件会
21
+ * 静默走错路径(卡片不出、徽章错位、工具结果丢失),而用户看到的现象与「插件坏了」
22
+ * 无法区分。所以这里只保留一个判据 `isHostSupported`(>= 0.2.0),仅用于 apply 时
23
+ * 打一条醒目的警告 —— 不是兼容,是可诊断。
14
24
  *
15
25
  * 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
16
26
  * 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
17
- * 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
18
- * 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
27
+ * 会沿 profile 的 node_modules 向上解析到全局安装根。拿不到就回落直接拼路径,再不行
28
+ * 返回 undefined(按「无法确认」处理,不抛)。
19
29
  *
20
- * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
21
- * 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
30
+ * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层解析到的是插件自己
31
+ * 的依赖,不能代表宿主。
22
32
  */
23
33
  import { readFileSync } from 'node:fs'
24
34
  import { createRequire } from 'node:module'
25
35
  import { join } from 'node:path'
26
36
  import { profileDirOf } from './data-dir.ts'
27
37
 
28
- /** 各兼容分叉的版本门槛。 */
29
- /** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
30
- const V0_1_7: readonly number[] = [0, 1, 7]
38
+ /** 版本地板:低于此版本的宿主本插件不再兼容(0.1.5/0.1.6/0.1.7 全部落此下)。 */
39
+ const V0_2_0: readonly number[] = [0, 2, 0]
31
40
 
32
41
  /**
33
42
  * 解析并比较 semver(只取前导数字段,预发布标识忽略)。
34
- * `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
43
+ * `0.2.0-rc.1` → [0,2,0],`0.1.7-alpha.2` → [0,1,7]。
35
44
  * 返回负数/0/正数,语义同 `<`/`==`/`>`。
36
45
  */
37
46
  function compareVersion(a: readonly number[], b: readonly number[]): number {
@@ -43,7 +52,7 @@ function compareVersion(a: readonly number[], b: readonly number[]): number {
43
52
  return 0
44
53
  }
45
54
 
46
- /** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
55
+ /** `0.2.0-rc.1` → [0,2,0];解析失败返回 null。 */
47
56
  function parseVersion(raw: string): number[] | null {
48
57
  const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim())
49
58
  if (m === null) return null
@@ -53,11 +62,11 @@ function parseVersion(raw: string): number[] | null {
53
62
  /**
54
63
  * 探测 profile 里安装的 DSH 宿主版本。
55
64
  * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
56
- * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
65
+ * @returns 形如 `0.2.0-rc.1`;读不到返回 undefined
57
66
  */
58
67
  export function detectHostVersion(baseUrl?: string): string | undefined {
59
68
  const profile = profileDirOf(baseUrl)
60
- // 先走模块解析(能沿 node_modules 向上找到全局安装根),再回落直接拼路径。
69
+ // 先走模块解析(能沿 node_modules 向上找到全局安装根),再回落直接路径。
61
70
  const candidates: string[] = []
62
71
  try {
63
72
  candidates.push(createRequire(join(profile, 'noop.js')).resolve('@deepseek-ai/dsh/package.json'))
@@ -76,26 +85,17 @@ export function detectHostVersion(baseUrl?: string): string | undefined {
76
85
  return undefined
77
86
  }
78
87
 
79
- /** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
80
- function atLeast(baseUrl: string | undefined, min: readonly number[]): boolean {
88
+ /**
89
+ * 宿主是否在本插件支持的地板之上(>= 0.2.0)。
90
+ *
91
+ * **只用于报警,不用于分流** —— 低于地板时插件照常加载,但要在日志里说清「这台宿主
92
+ * 不再兼容、会出现什么症状」,把静默的错变成可查的错。读不到版本 → false(当作
93
+ * 「无法确认」,同样值得说一句)。
94
+ */
95
+ export function isHostSupported(baseUrl?: string): boolean {
81
96
  const v = detectHostVersion(baseUrl)
82
97
  if (v === undefined) return false
83
98
  const parsed = parseVersion(v)
84
99
  if (parsed === null) return false
85
- return compareVersion(parsed, min) >= 0
86
- }
87
-
88
- /**
89
- * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
90
- * - 「最近命中」徽章(composer.dock 位置正确)
91
- * - 设置命名空间(Config 驱动而非 installSection)
92
- * - adapter 的独立 role:'tool' 消息形态
93
- */
94
- export function isHost017Plus(baseUrl?: string): boolean {
95
- return atLeast(baseUrl, V0_1_7)
96
- }
97
-
98
- /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
99
- export function supportsLastHitDock(baseUrl?: string): boolean {
100
- return isHost017Plus(baseUrl)
100
+ return compareVersion(parsed, V0_2_0) >= 0
101
101
  }