dsh-toolbox-ui 1.6.5 → 1.6.6
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.
- package/README.en.md +4 -3
- package/README.md +4 -3
- package/docs/design.md +40 -10
- package/docs/installation.md +5 -5
- package/docs/skills.md +56 -10
- package/lib/client.js +143 -70
- package/lib/index.js +102 -88
- package/lib/skills-file-ops.js +304 -107
- package/lib/skills-frontmatter.js +48 -15
- package/lib/skills-registry.js +163 -43
- package/lib/typert.js +1 -0
- package/lib/whats-new.js +25 -0
- package/package.json +1 -1
|
@@ -24,6 +24,19 @@ const LEGACY_INVOCATION_KEYS = new Map([
|
|
|
24
24
|
['userInvocable', 'user-invocable'],
|
|
25
25
|
])
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 两个调用面字段的**唯一一份**语义声明:字段名 + 缺省值。
|
|
29
|
+
*
|
|
30
|
+
* 为什么必须只有一份:`disable-model-invocation` 的缺省是 false(不禁止),
|
|
31
|
+
* `user-invocable` 的缺省是 true(可调用)——**方向相反**。写「停用/启用」要同时动这两个字段,
|
|
32
|
+
* 任何一处把缺省方向记反,结果都是把「停用」写成「启用」,而且文件照样被改写、没有报错。
|
|
33
|
+
* 所以字段名与缺省值只在这里声明,写入方(skills-file-ops)与展示方都从这里取。
|
|
34
|
+
*/
|
|
35
|
+
export const INVOCATION_FIELDS = {
|
|
36
|
+
modelInvocable: { key: 'disable-model-invocation', absentMeans: false, inverted: true },
|
|
37
|
+
userInvocable: { key: 'user-invocable', absentMeans: true, inverted: false },
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* frontmatter 的字节范围——**解析与改写共用的唯一一套边界判据**。
|
|
29
42
|
*
|
|
@@ -178,13 +191,20 @@ function isTopLevelKey(text, key) {
|
|
|
178
191
|
/**
|
|
179
192
|
* 把 frontmatter 里的一个顶层布尔字段改成目标值,其余字节原样保留。
|
|
180
193
|
*
|
|
194
|
+
* ⚠️ `absentMeans` 是**字段自己的语义**,不是实现细节:DSH 的两个调用面字段缺省方向**相反**——
|
|
195
|
+
* `disable-model-invocation` 缺省 = false(不禁止),`user-invocable` 缺省 = true(可调用)。
|
|
196
|
+
* 于是「表达目标值」的最干净写法也不同:与缺省同向就**删掉那一行**(缺省即它),
|
|
197
|
+
* 反向才写字面量。两个字段共用这一处实现,方向由参数给——写反了会让「停用」变成「启用」,
|
|
198
|
+
* 而且两个方向都会**静默**生效(文件照样被改写,只是语义反了)。
|
|
199
|
+
*
|
|
181
200
|
* @param {string} raw - 技能文件全文。
|
|
182
|
-
* @param {string} key -
|
|
183
|
-
* @param {boolean} value -
|
|
201
|
+
* @param {string} key - 顶层字段名。
|
|
202
|
+
* @param {boolean} value - 目标**语义值**(不是要写进文件的字面量)。
|
|
203
|
+
* @param {boolean} [absentMeans=false] - 该字段缺席时等价于什么值。
|
|
184
204
|
* @returns {{ text: string, changed: boolean }} changed 为 false 时 text 与输入逐字节相同。
|
|
185
205
|
* @throws {Error} 形状读不准时抛错,绝不猜一个改法。
|
|
186
206
|
*/
|
|
187
|
-
export function setFrontmatterBoolean(raw, key, value) {
|
|
207
|
+
export function setFrontmatterBoolean(raw, key, value, absentMeans = false) {
|
|
188
208
|
const parsed = parseSkillFrontmatter(raw)
|
|
189
209
|
if (!parsed.ok) throw new Error(`无法改写 frontmatter:${parsed.message}`)
|
|
190
210
|
|
|
@@ -202,24 +222,37 @@ export function setFrontmatterBoolean(raw, key, value) {
|
|
|
202
222
|
} catch (error) {
|
|
203
223
|
throw new Error(`无法改写 ${key}:${error instanceof Error ? error.message : String(error)}。宿主会因此跳过整条技能,请先手工修正这一行`)
|
|
204
224
|
}
|
|
225
|
+
// 「当前值」也按字段自己的缺省方向解读(缺省方向由调用方给,见函数头)。
|
|
226
|
+
const currentValue = current === undefined ? absentMeans : current
|
|
205
227
|
const desired = value === true
|
|
206
|
-
if (
|
|
228
|
+
if (currentValue === desired) return { text: raw, changed: false }
|
|
229
|
+
// 目标值恰好等于该字段的缺省 → **删掉那一行**(缺席即它),而不是写一个与缺省同向的字面量:
|
|
230
|
+
// 后者会让「开关不留痕迹」这条不变量失效(开→关→开 的往返不再逐字节还原原文)。
|
|
231
|
+
//
|
|
232
|
+
// ⚠️ 这两个概念必须分开:`deleteLine` 是**怎么做**,`desired` 才是**写什么值**。
|
|
233
|
+
// 曾经把「要不要写字面量」这个布尔直接当成值插进文本,于是 `user-invocable`(缺省 true)
|
|
234
|
+
// 的「关掉」被写成 `user-invocable: true`——一个语义完全相反的字段,而且照样返回 changed:true。
|
|
235
|
+
const deleteLine = desired === absentMeans
|
|
207
236
|
|
|
208
237
|
const lines = frontmatterLines(raw, parsed.span)
|
|
209
238
|
const at = lines.findIndex((line) => isTopLevelKey(line.text, key))
|
|
210
239
|
if (at < 0) {
|
|
211
|
-
// ⚠️
|
|
240
|
+
// ⚠️ 追加或删除的前提是「键**真的**不在」。行扫描认不出的形状是存在的:引号键(`"key": true`)与
|
|
212
241
|
// 流式映射(`{key: true}`)解析器都认得、宿主也读得到(面板照它显示权限),
|
|
213
|
-
// 而 isTopLevelKey
|
|
214
|
-
// frontmatter
|
|
215
|
-
//
|
|
242
|
+
// 而 isTopLevelKey 匹配不上那一行。此时两个判据分叉,**任何方向都不能动手**:
|
|
243
|
+
// · 追加 → 写出**第二个**同名键 → YAML 重复键让整份 frontmatter 报废(宿主跳过整条技能);
|
|
244
|
+
// · 想删掉那一行 → 根本找不到行。
|
|
245
|
+
// 曾经这一支对「目标值恰好等于缺省」的方向返回 changed:false,那是在**谎报「已经是目标态」**
|
|
246
|
+
// ——解析出来的当前值与目标值明明是相反的(否则上面早就 no-op 返回了)。所以只取**解析结果**
|
|
247
|
+
// 作判据:键在,就拒绝并说清原因。
|
|
216
248
|
if (Object.hasOwn(parsed.data, key)) {
|
|
217
249
|
throw new Error(`无法改写 ${key}:这个键写在解析器认得、逐行改写认不出的形状里(引号键或流式映射),请手工修改`)
|
|
218
250
|
}
|
|
219
|
-
//
|
|
220
|
-
//
|
|
251
|
+
// 键确实不在 → 走到这里只可能要求写字面量(等于缺省的话上面已经 no-op 返回了)。
|
|
252
|
+
// 追加到 frontmatter 块**末尾**:插在 name/description 之前会把后面每个字段的 diff 都推一行,
|
|
253
|
+
// 而这里没有任何理由去动别人的字段。
|
|
221
254
|
return {
|
|
222
|
-
text: raw.slice(0, parsed.span.closingStart) + `${key}:
|
|
255
|
+
text: raw.slice(0, parsed.span.closingStart) + `${key}: ${desired}` + parsed.span.eol + raw.slice(parsed.span.closingStart),
|
|
223
256
|
changed: true,
|
|
224
257
|
}
|
|
225
258
|
}
|
|
@@ -242,16 +275,16 @@ export function setFrontmatterBoolean(raw, key, value) {
|
|
|
242
275
|
throw new Error(`无法改写 ${key}:这个字段的值带 YAML 锚点(&),别处可能正引用它,请手工修改`)
|
|
243
276
|
}
|
|
244
277
|
|
|
245
|
-
if (
|
|
246
|
-
//
|
|
278
|
+
if (!deleteLine) {
|
|
279
|
+
// 能走到这里只可能是「当前值已在缺省侧、要写到非缺省侧」:就地把值换成目标字面量,
|
|
247
280
|
// 键的位置不动。值是块标量或带行尾注释时不动手——那两种改法都得猜。
|
|
248
281
|
if (rest.trim() === '' || rest.includes('#')) {
|
|
249
282
|
throw new Error(`无法改写 ${key}:这一行不是「键: 简单值」的形状,请手工修改`)
|
|
250
283
|
}
|
|
251
|
-
const replaced = content.slice(0, colon + 1) +
|
|
284
|
+
const replaced = content.slice(0, colon + 1) + ` ${desired}` + (hasCr ? '\r' : '')
|
|
252
285
|
return { text: raw.slice(0, line.start) + replaced + raw.slice(line.end), changed: true }
|
|
253
286
|
}
|
|
254
|
-
//
|
|
287
|
+
// 目标值 = 该字段的缺省:整行连同它自己的换行一起删掉,其余行与行尾风格不变。
|
|
255
288
|
return { text: raw.slice(0, line.start) + raw.slice(line.end + 1), changed: true }
|
|
256
289
|
}
|
|
257
290
|
|
package/lib/skills-registry.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 本层只读:不写盘、不做启停(那些在 lib/skills-file-ops.js)。
|
|
8
8
|
|
|
9
|
-
import { access, realpath, stat } from 'node:fs/promises'
|
|
9
|
+
import { access, readdir, realpath, stat } from 'node:fs/promises'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { dirname, join, resolve } from 'node:path'
|
|
12
12
|
import { readSkillDocument, resolveSkillTarget, SkillNotFoundError } from './skills-file-ops.js'
|
|
@@ -54,54 +54,154 @@ async function physicalDirectory(path) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* 从宿主 summary
|
|
57
|
+
* 从宿主 summary 推出技能的**根**、**形态**与**磁盘条目**。
|
|
58
58
|
*
|
|
59
59
|
* 为什么必须推而不是问:`ctx.skills` **不暴露 roots()**(实测公开面只有
|
|
60
60
|
* registerProvider/register/list/snapshot/get/collect/...;roots() 在 filesystem provider 内部)。
|
|
61
|
-
* 能用的线索只有 `resourceBase`。
|
|
61
|
+
* 能用的线索只有 `resourceBase` 与 `path`。
|
|
62
62
|
*
|
|
63
|
-
*
|
|
63
|
+
* ⚠️ 核心不变量:**技能名(frontmatter 的 name)不参与定位**。
|
|
64
|
+
* 宿主允许磁盘条目名与之不一致(实测 `dir-name/SKILL.md` 里写 `name: dir-alias` 照收),
|
|
65
|
+
* 按名字推位置会让这类技能在面板里所有操作都失败(真机实测 6 种形态里 4 种不可操作)。
|
|
64
66
|
*
|
|
65
|
-
*
|
|
66
|
-
* 扁平文件:locator.path = `<根>/<name>.md`, locator.directory = `<根>`
|
|
67
|
-
*
|
|
68
|
-
* ⚠️ 关键:**形态由技能文件名决定**(`SKILL.md` vs `<name>.md`),而**根由 `resourceBase` 决定**。
|
|
69
|
-
* 不要去比较两个字段的路径——它们来自不同的路径口径,链接下必然分叉(真机缺陷,issue #5):
|
|
67
|
+
* 两个字段的**口径不同**(这是真机缺陷 issue #5 的根因,不要拿它们互相做路径比较):
|
|
70
68
|
* `resourceBase.path` = `locator.directory` —— **展示路径**(配置原样,配置写链接就是链接)
|
|
71
69
|
* `path` = `fs.processPath(target)` —— **realpath**(fs-local 里就是 targetKey)
|
|
72
|
-
* 之前两版实现都栽在这里:先按字面比较,再改成「realpath 规范化后比较」——都还是**路径比较**,
|
|
73
|
-
* 于是「技能目录是链接」修好了,「**扁平技能的文件本身是链接**」照样失效(宿主自己的用例就钉着
|
|
74
|
-
* 这两种形状:skill-filesystem.spec.ts 的 linked-dir 与 linked-flat)。而符号链接指向外部同步库
|
|
75
|
-
* 正是本仓库用户的常见做法。
|
|
76
70
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
71
|
+
* 三个字段怎么定:
|
|
72
|
+
*
|
|
73
|
+
* **形态**:`path` 的末段是 `SKILL.md` → 目录捆绑;以 `.md` 结尾 → 扁平候选。
|
|
74
|
+
* 宿主对目录 entry 固定构造 `<目录>/SKILL.md`,所以那个文件名是铁证;扁平则是
|
|
75
|
+
* 「名字以 `.md` 结尾」(`discoverRoot` 的 `entry.name.endsWith('.md')`),**与技能名无关**。
|
|
76
|
+
*
|
|
77
|
+
* **根**:两种形态都取 `resourceBase.path`(捆绑形态下再上一级——那时 resourceBase 指的是
|
|
78
|
+
* 技能目录本身)。这是宿主给的**展示路径**,也正是写盘要用的那个(对 realpath 改名/删除
|
|
79
|
+
* 就是动用户同步库里的数据,不是动这个链接)。
|
|
80
|
+
*
|
|
81
|
+
* **条目**:
|
|
82
|
+
* · 目录捆绑:`resourceBase.path` 的末段。宿主构造的是 `directory = <根>/<条目>`,
|
|
83
|
+
* 所以末段**就是磁盘目录名**(junction 时是 junction 的展示名)。
|
|
84
|
+
* · 扁平:**在根下按 realpath 匹配 `path`** 得到的那一项(见 matchFlatEntry)。
|
|
85
|
+
* 不能取 `path` 的末段:文件本身是链接时,`path` 是**目标文件**的 realpath,
|
|
86
|
+
* 其末段与根下那个链接名可能不同,而我们要操作的是**链接**。
|
|
87
|
+
* 匹配不到(根读不了、或那个文件根本不在根下)就**不填根**——宁可不填也不猜,
|
|
88
|
+
* 猜错会让启停/删除作用在错误的目录上,那是本层最危险的失败模式。
|
|
79
89
|
*
|
|
80
90
|
* 返回的 root 一律用**展示路径**(`resolveTargetReference` 要求它与 skillTargetsView 算出的
|
|
81
91
|
* 目标精确相等,返回 realpath 会让所有文件操作对不上)。
|
|
82
|
-
* 认不出形态就返回空:宁可不填,也不猜一个根——猜错会让启停/删除作用在错误的目录上。
|
|
83
92
|
*
|
|
84
93
|
* @param {object} summary - 宿主 SkillSummary。
|
|
85
|
-
* @returns {{ root?: string, kind?: 'bundle'|'flat' }}
|
|
94
|
+
* @returns {Promise<{ root?: string, kind?: 'bundle'|'flat', entry?: string }>}
|
|
86
95
|
*/
|
|
87
|
-
function locateSkill(summary) {
|
|
96
|
+
async function locateSkill(summary) {
|
|
88
97
|
const base = summary?.resourceBase?.path
|
|
89
98
|
const path = summary?.path
|
|
90
99
|
if (typeof base !== 'string' || base === '' || typeof path !== 'string' || path === '') return {}
|
|
91
100
|
|
|
92
101
|
// 文件名取自宿主给的 realpath,只看最后一段(分隔符两种都要吃)。
|
|
93
|
-
const file =
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
const file = lastSegment(path)
|
|
103
|
+
|
|
104
|
+
// 目录捆绑:宿主对**目录** entry 固定构造 `<目录>/SKILL.md`,所以这个文件名是形态的铁证。
|
|
105
|
+
if (file === BUNDLE_FILE) {
|
|
106
|
+
const directory = lastSegment(base)
|
|
107
|
+
if (directory === '') return {}
|
|
108
|
+
return { root: dirname(base), kind: 'bundle', entry: directory }
|
|
109
|
+
}
|
|
110
|
+
if (!file.endsWith('.md')) return {}
|
|
111
|
+
|
|
112
|
+
// 扁平:以「根下的直接条目里,有哪个的真实路径就等于宿主报的 path」为准。
|
|
113
|
+
const matched = await matchFlatEntry(base, path)
|
|
114
|
+
if (matched !== undefined) return { root: base, kind: 'flat', entry: matched }
|
|
115
|
+
// 扫不了或没命中:只有「宿主自己的构造规则直接成立」时才认——即 path 的目录部分
|
|
116
|
+
// 字面等于 base(非链接、根也没被链接改写时就是这样)。两者不一致就**不填根**,
|
|
117
|
+
// 因为那时我们无法证明这个文件就在这个根下(例如宿主永远不会产出的 `a/b/c.md`)。
|
|
118
|
+
if (sameDirectory(dirname(path), base)) return { root: base, kind: 'flat', entry: file }
|
|
102
119
|
return {}
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
/** 路径的最后一段(两种分隔符都吃)。`'/'` 结尾会得到空串,调用方据此判不可用。 */
|
|
123
|
+
function lastSegment(path) {
|
|
124
|
+
return toSlashes(path).split('/').pop() ?? ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 在一个根下找出**真实路径等于宿主所报 `path`** 的那个扁平条目。
|
|
129
|
+
*
|
|
130
|
+
* 为什么需要这一步:扁平技能的文件本身是符号链接、且链接名与目标文件名不同时,
|
|
131
|
+
* 宿主报的 `path` 是目标的 realpath,无法从它反推出根下那个**链接**的名字——
|
|
132
|
+
* 而写盘必须作用在链接上(对 realpath 改名/删除就是动用户同步库里的数据)。
|
|
133
|
+
* 所以这里扫一遍根(宿主发现只有一层,所以只看直接子条目),把名字**查**出来。
|
|
134
|
+
*
|
|
135
|
+
* 这是查证不是猜测:只有 realpath 精确相等才认,认不出就返回 undefined,
|
|
136
|
+
* 由调用方按「证明不了它在这个根下」处理。
|
|
137
|
+
*
|
|
138
|
+
* @param {string} root - 展示路径的根。
|
|
139
|
+
* @param {string} target - 宿主报的 realpath。
|
|
140
|
+
* @returns {Promise<string|undefined>}
|
|
141
|
+
*/
|
|
142
|
+
async function matchFlatEntry(root, target) {
|
|
143
|
+
const wanted = toSlashes(target)
|
|
144
|
+
let entries
|
|
145
|
+
try {
|
|
146
|
+
entries = await readdir(root)
|
|
147
|
+
} catch {
|
|
148
|
+
// 根读不了(不存在/无权限):交给调用方走字面兜底,不在这里编一个条目。
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (typeof entry !== 'string' || !entry.endsWith('.md')) continue
|
|
153
|
+
const real = await realpath(join(root, entry)).catch(() => undefined)
|
|
154
|
+
if (real !== undefined && toSlashes(real) === wanted) return entry
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 两个路径的**目录部分**是否字面相等(比较用,绝不喂给 fs)。 */
|
|
160
|
+
function sameDirectory(left, right) {
|
|
161
|
+
return toSlashes(left) === toSlashes(right)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 把一个 wire 上的**技能引用**解析成服务端认可的文件目标。
|
|
166
|
+
*
|
|
167
|
+
* wire 送的是 `{ source, root, projectPath, entry }`——**没有路径**。两道核对都在服务端:
|
|
168
|
+
* ① `root` 必须等于服务端自己算出来的某个根(resolveTargetReference)——
|
|
169
|
+
* 「写入限定在技能根内」这条不变量因此不只由客户端保证;
|
|
170
|
+
* ② `entry` 必须是**单个路径段**(resolveSkillTarget 里校验)。
|
|
171
|
+
* 条目名不要求是 kebab-case:宿主对磁盘条目名没有任何语法要求,收窄它会重新
|
|
172
|
+
* 制造「合法的技能操作不了」那一类缺陷。
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ `kind` 只是**提示**,不是判据:形态由 `resolveSkillTarget` **看磁盘**决定
|
|
175
|
+
* (`<根>/<条目>/SKILL.md` 存在就是目录捆绑,否则那条路径本身)。wire 上的 `kind` 报错
|
|
176
|
+
* 不会让操作作用到别的地方——拼哪条路径由磁盘事实说了算,越界由 `entry` + `root` 保证。
|
|
177
|
+
*
|
|
178
|
+
* 抽成一处:这段解析此前在 detail / setEnabled / setModelInvocable / removeSkill 里
|
|
179
|
+
* 各写了一遍、四处独立演化——本轮「按技能名拼路径」的缺陷正是这样扩散出去的。
|
|
180
|
+
*
|
|
181
|
+
* ⚠️ 刻意做成模块级函数而**不是** gateway 的方法:这些 Remote 在用例里是用
|
|
182
|
+
* `prototype.method.call({ ctx }, …)` 直接调的,挂在 `this` 上的助手在那里根本不存在
|
|
183
|
+
* (真机实测报 `this.resolveSkillReference is not a function`)。依赖 `this` 的写法
|
|
184
|
+
* 会让「能被单测」与「能被生产调用」变成两种形状。
|
|
185
|
+
*
|
|
186
|
+
* @param {object} ctx - 宿主上下文。
|
|
187
|
+
* @param {object} payload - wire 载荷(含 source / root / projectPath / entry / kind)。
|
|
188
|
+
* @returns {Promise<object>} resolveSkillTarget 的结果。
|
|
189
|
+
*/
|
|
190
|
+
export async function resolveSkillReference(ctx, payload) {
|
|
191
|
+
const target = await resolveTargetReference(ctx, {
|
|
192
|
+
source: payload?.source,
|
|
193
|
+
root: payload?.root,
|
|
194
|
+
projectPath: payload?.projectPath,
|
|
195
|
+
})
|
|
196
|
+
return await resolveSkillTarget({
|
|
197
|
+
entry: String(payload?.entry ?? ''),
|
|
198
|
+
kind: payload?.kind,
|
|
199
|
+
root: target.root,
|
|
200
|
+
scope: target.scope,
|
|
201
|
+
writable: target.writable !== false,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
105
205
|
/** 取宿主 skills 服务;宿主的服务名就是 'skills'(dsh-skill 的 super(ctx, "skills"))。 */
|
|
106
206
|
function skillsService(ctx) {
|
|
107
207
|
return typeof ctx?.get === 'function' ? ctx.get('skills') : undefined
|
|
@@ -161,21 +261,26 @@ function isUnreadable(error) {
|
|
|
161
261
|
* 一行的调用权限——**一律来自技能文件本身**,不取宿主的发现缓存。
|
|
162
262
|
*
|
|
163
263
|
* 为什么不能用宿主 summary 里的那份:summary 是**发现缓存**(同一个文件被改写后它继续报旧值,
|
|
164
|
-
*
|
|
165
|
-
*
|
|
264
|
+
* 真机实测复现过)。而调用权限正是本面板要**写**的东西:写盘后显示回退,用户看到的是
|
|
265
|
+
* 「点了没反应」;更糟的是把缓存里的旧值当事实展示。
|
|
166
266
|
* 行与详情因此都从这个文件的字节里解析——它们与用户看到的正文同源。
|
|
167
267
|
*
|
|
168
268
|
* 读不到就是 `null`(不知道):宿主缓存可能指向一个已经被删/搬走的技能,此时「能不能被模型调用」
|
|
169
269
|
* 没有答案,编一个布尔值就是让面板撒谎。
|
|
170
270
|
*
|
|
171
|
-
* @param {{
|
|
271
|
+
* @param {{ entry?: string, root?: string, kind?: 'bundle'|'flat', scope: 'global'|'project' }} row
|
|
172
272
|
* @returns {Promise<{ modelInvocable: boolean|null, userInvocable: boolean|null }>}
|
|
173
273
|
*/
|
|
174
274
|
async function invocationOf(row) {
|
|
175
275
|
const unknown = { modelInvocable: null, userInvocable: null }
|
|
176
|
-
if (typeof row?.
|
|
276
|
+
if (typeof row?.entry !== 'string' || typeof row?.root !== 'string' || row.root === '') return unknown
|
|
177
277
|
try {
|
|
178
|
-
const target = await resolveSkillTarget({
|
|
278
|
+
const target = await resolveSkillTarget({
|
|
279
|
+
entry: row.entry,
|
|
280
|
+
kind: row.kind,
|
|
281
|
+
root: row.root,
|
|
282
|
+
scope: row.scope,
|
|
283
|
+
})
|
|
179
284
|
const { invocation } = await readSkillDocument(target)
|
|
180
285
|
return {
|
|
181
286
|
modelInvocable: invocation?.modelInvocable ?? null,
|
|
@@ -190,25 +295,39 @@ async function invocationOf(row) {
|
|
|
190
295
|
|
|
191
296
|
/**
|
|
192
297
|
* 把宿主的一条 SkillSummary 投影成面板行。
|
|
298
|
+
*
|
|
299
|
+
* 「已停用」在本层**派生**,不另存:宿主没有 disabled 字段,而「两个调用面都关掉」
|
|
300
|
+
* (`modelInvocable === false && userInvocable === false`)就是 VS Code / Copilot 官方文档里
|
|
301
|
+
* 叫 “Disabled skills” 的那一档。行上的 enabled 因此是那两个值的函数——
|
|
302
|
+
* **没有第三个状态要存,也就没有东西会与文件对不上**(旧版把状态存在文件名里,
|
|
303
|
+
* 于是要自己扫盘、还要防两个数组同身份双行)。
|
|
304
|
+
*
|
|
305
|
+
* 调用权限**不读** summary.invocation:那一份来自宿主的发现缓存(见 invocationOf 的说明)。
|
|
306
|
+
*
|
|
193
307
|
* @param {object} summary - 宿主 SkillSummary。
|
|
194
308
|
* @param {{ cwd?: string }} options - 该条目所属的查询作用域。
|
|
195
309
|
* @returns {Promise<object>} 面板行。
|
|
196
310
|
*/
|
|
197
311
|
async function toView(summary, { cwd }) {
|
|
198
|
-
const located = locateSkill(summary)
|
|
312
|
+
const located = await locateSkill(summary)
|
|
199
313
|
const scope = scopeOfSource(summary.source)
|
|
200
|
-
|
|
201
|
-
const
|
|
314
|
+
const invocation = await invocationOf({ entry: located.entry, root: located.root, kind: located.kind, scope })
|
|
315
|
+
const enabled = invocation.modelInvocable === false && invocation.userInvocable === false ? false
|
|
316
|
+
: invocation.modelInvocable === null || invocation.userInvocable === null ? null
|
|
317
|
+
: true
|
|
202
318
|
return {
|
|
203
319
|
name: summary.name,
|
|
204
320
|
description: summary.description ?? '',
|
|
205
321
|
...(summary.whenToUse !== undefined ? { whenToUse: summary.whenToUse } : {}),
|
|
206
322
|
...invocation,
|
|
323
|
+
// 派生态:三态(true / false / null=不知道)。null 不假装成「已停用」——
|
|
324
|
+
// 文件读不出来时「两个面是否都关了」没有答案。
|
|
325
|
+
enabled,
|
|
207
326
|
source: summary.source ?? 'unknown',
|
|
208
327
|
provider: summary.provider ?? 'unknown',
|
|
209
328
|
scope,
|
|
210
|
-
//
|
|
211
|
-
...(located.root !== undefined ? { root: located.root, kind: located.kind } : {}),
|
|
329
|
+
// 根/形态/条目推不出来就不填(上层据此提示「无法定位」,而不是作用在错的目录上)。
|
|
330
|
+
...(located.root !== undefined ? { root: located.root, kind: located.kind, entry: located.entry } : {}),
|
|
212
331
|
...(cwd !== undefined ? { cwd } : {}),
|
|
213
332
|
...(summary.path !== undefined ? { path: summary.path } : {}),
|
|
214
333
|
// bundled 根只读(SPEC 约束 3):启停与删除都必须被拒。
|
|
@@ -338,12 +457,12 @@ function rankOfSource(source) {
|
|
|
338
457
|
}
|
|
339
458
|
|
|
340
459
|
/**
|
|
341
|
-
* 加载单个技能正文的旧路径:**已被 detail
|
|
460
|
+
* 加载单个技能正文的旧路径:**已被 detail 的按条目读取取代**(2026-10-10)。
|
|
342
461
|
*
|
|
343
462
|
* 为什么删掉而不是留着:`ctx.skills.get(name)` 按 rank 返回**生效者**,与面板
|
|
344
|
-
* 「(
|
|
463
|
+
* 「(根, 条目) 精确操作」的模型(D8)冲突——点被遮蔽的那行会读到生效者的正文。
|
|
345
464
|
* 现在的 detail 走 `resolveTargetReference` + `resolveSkillTarget` + `readSkillDocument`,
|
|
346
|
-
*
|
|
465
|
+
* 既精确到根与条目,又能顺带从同一份字节里给出调用权限。
|
|
347
466
|
*/
|
|
348
467
|
|
|
349
468
|
// ── 安装目标(skillTargets)───────────────────────────────────────────────
|
|
@@ -562,9 +681,10 @@ async function resolveTargetReference(ctx, reference) {
|
|
|
562
681
|
* 一行技能的**唯一身份**:`source \0 cwd \0 name`。
|
|
563
682
|
*
|
|
564
683
|
* ⚠️ 必须与客户端 `lib/client.js` 的 `skillIdentity` 是**同一个元组**:客户端拿它当行 key,
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
684
|
+
* 服务端拿它保证同一个身份最多出现一行(当前只剩两条产出路径:宿主目录,以及旧机制残留的
|
|
685
|
+
* 迁移读取——后者会跳过已出现过的身份)。两边各有一条用例把**同一个字面量**钉死
|
|
686
|
+
* (test/skills-host.test.mjs 与 test/client-behavior.test.mjs),只改一边就会红
|
|
687
|
+
* ——重复 key 的后果是静默的(生产版 React 不报警告,只是重复渲染)。
|
|
568
688
|
*/
|
|
569
689
|
const skillRowKey = (row) => `${row?.source || 'unknown'}\u0000${row?.cwd || ''}\u0000${row?.name || ''}`
|
|
570
690
|
|
package/lib/typert.js
CHANGED
|
@@ -107,6 +107,7 @@ export const TYPERT = {
|
|
|
107
107
|
mvs('detail', [skillPayloadParam], 'SkillDetailResult'),
|
|
108
108
|
mvs('setEnabled', [skillPayloadParam], 'SkillMutationResult'),
|
|
109
109
|
mvs('setModelInvocable', [skillPayloadParam], 'SkillMutationResult'),
|
|
110
|
+
mvs('setUserInvocable', [skillPayloadParam], 'SkillMutationResult'),
|
|
110
111
|
// ⚠️ 不能叫 `remove`:客户端适配器(dsh-api-gateway 的 RemoteNamespaceService)自己就有原型
|
|
111
112
|
// 方法 `remove(kind, method, token)`,`ctx.remote.$mount` 会把「与命名空间服务自身成员重名」
|
|
112
113
|
// 的 endpoint 直接拒掉——报 `client api: method "skillsManager/remove" conflicts with its
|
package/lib/whats-new.js
CHANGED
|
@@ -244,4 +244,29 @@ export const WHATS_NEW = [
|
|
|
244
244
|
},
|
|
245
245
|
],
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
version: '1.6.6',
|
|
249
|
+
items: [
|
|
250
|
+
{
|
|
251
|
+
text: '**「启停」不再改你的文件名**:以前停用是把 `SKILL.md` 改名成 `SKILL.md.disabled`。那个后缀不是任何工具认识的形态——而技能常放在 `~/.agents/skills`,VS Code / Copilot、Codex 也读那里,改名会让它们以为你的技能坏了。现在停用写的是**宿主原生的两个字段**(`disable-model-invocation: true` + `user-invocable: false`,也就是 VS Code 官方文档里叫 “Disabled skills” 的那一档):模型不会自动用它、你也不能手动调用它,而文件仍在原处、别的工具照常认得。旧版本留下的 `.disabled` 文件仍会列出来并支持一键恢复',
|
|
252
|
+
where: '技能列表(停用/启用)',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
text: '**文件名与技能名不一致的技能,以前在面板里一个都用不了**:DSH 的技能名取自文件里的 `name`,而磁盘上的文件/目录名允许与它不同。面板以前按**技能名**去拼磁盘路径,于是这类技能虽然模型能正常使用,面板里点什么都报「找不到技能」——读正文、启停、删除全废。现在一律按**宿主报告的真实位置**定位(根 + 那个根下的磁盘条目),与名字无关。实测六种形态里有四种曾经完全不可操作',
|
|
256
|
+
where: '技能列表与详情(所有操作)',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
text: '**已停用的技能不再需要面板自己扫盘**:以前停用后技能会从宿主目录里消失,面板只好自己去翻 `.disabled` 文件——那是第二份真值来源,也是「同一行重复出现」「链接技能停用后消失」这些问题的根源。现在停用不改文件、宿主照样报告它,面板看到的与宿主报告的是同一份东西,那类缺陷在结构上不再可能',
|
|
260
|
+
where: '技能列表',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
text: '**改完不用重启了(以前面板让你重启,其实是白重启)**:宿主用文件监听盯住技能根,你改完技能通常**不到一秒**就生效(实测 100~300ms)——旧提示写「重启 DSH 最稳妥」会让你白重启一次。面板本身照旧在动作后与切回标签页时重取(宿主有个 `skills/change` 事件,但不在它转发的白名单里,第三方插件收不到,所以做不到实时推送)',
|
|
264
|
+
where: '技能详情的开关提示、docs/skills.md',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
text: '**「用户可调用」现在也能就地开关**:以前详情页只有「模型自动调用」一个开关,「用户可调用」只能看——于是你要是原本只想要「仅我手动可调用」那一档(模型别自动用),点过「停用→启用」之后它会被重置,而**面板上没有开关能拨回来**,只能去编辑技能文件。现在两个调用面各有一个开关,四种组合(都能用 / 仅手动 / 仅模型 / 停用)都能在面板里拨',
|
|
268
|
+
where: '技能详情(调用权限)',
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
247
272
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-toolbox-ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.6",
|
|
4
4
|
"description": "Responsive MCP management UI and skills manager for DeepSeek Harness Web — floating panel, JSON import, profile-backed Host Remote management, and a skill market with a hard license gate.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|