dsh-skill-tag 0.1.2 → 0.1.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.
package/README.md
CHANGED
|
@@ -27,6 +27,53 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
27
27
|
| **打开技能所在文件夹** | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
|
|
28
28
|
| **自带一个 skill** | 安装后你的 agent 会获得 `tagging-skills`,能照着文档自己给技能打标签 |
|
|
29
29
|
|
|
30
|
+
## 界面预览
|
|
31
|
+
|
|
32
|
+
> 全部是真实实例截图(105 个技能 / 48 个标签 / 一个开了筛选的工作区),没有 mock。
|
|
33
|
+
> 图片源文件在 `images/`(不进 npm 包,README 里用 raw 绝对地址,GitHub 与 npm 都能显示)。
|
|
34
|
+
|
|
35
|
+
### 图 1 · 入口:会话行「…」→「管理标签」
|
|
36
|
+
|
|
37
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/session-menu.png" width="240" alt="会话行菜单里的管理标签入口">
|
|
38
|
+
|
|
39
|
+
对话级的配置就挂在会话行的「…」菜单最下面,不用去翻设置页。
|
|
40
|
+
|
|
41
|
+
### 图 2 · 入口:命令面板里的 `/skill-tag-manage`
|
|
42
|
+
|
|
43
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/command-palette.png" width="820" alt="命令面板中的管理对话标签命令">
|
|
44
|
+
|
|
45
|
+
在输入框敲 `/` 也能直接开「管理对话标签」——同一个界面,省一次点菜单。
|
|
46
|
+
|
|
47
|
+
### 图 3 · 打标签:设置 →「Skill 标签」
|
|
48
|
+
|
|
49
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/tag-settings.png" width="620" alt="Skill 标签管理页:新建标签、按来源分组的标签列表">
|
|
50
|
+
|
|
51
|
+
三个页签分工明确:**tag 联系 skill**(一个标签包含哪些技能)、**skill 联系 tag**
|
|
52
|
+
(一个技能拥有哪些标签)、**编辑标签组**(写表达式存模板)。
|
|
53
|
+
标签按来源分组(`来自:aegis 23 个 Skill`),每条带 `tagId` 和「全局删除」。
|
|
54
|
+
|
|
55
|
+
### 图 4 · 一个技能看它身上有哪些标签
|
|
56
|
+
|
|
57
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/skill-to-tags.png" width="640" alt="skill 联系 tag:左列技能、右列标签勾选">
|
|
58
|
+
|
|
59
|
+
左列 `技能 (105)` 每行标着它有几个标签;右边勾选**即时保存**,不用点确认。
|
|
60
|
+
这是配条件前最常开的一页:先看清楚"这个技能到底被哪些标签命中"。
|
|
61
|
+
|
|
62
|
+
### 图 5 · 工作区「管理标签」:左写条件,右就是下发名单
|
|
63
|
+
|
|
64
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/workspace-filter.png" width="900" alt="工作区管理标签双列界面">
|
|
65
|
+
|
|
66
|
+
左列是标签条件,下面实时显示**解析结果**和状态「有条件筛选(严格子集)」;
|
|
67
|
+
右列 `技能 (54)` 就是该工作区下发给模型的全部技能,每行可以「查看 tag」「禁用」,
|
|
68
|
+
📁 直接打开技能所在文件夹。底部还能「+ 精细化添加技能」「查看被禁用的技能」。
|
|
69
|
+
|
|
70
|
+
### 图 6 · 对话「管理标签(对话)」:可以有自己的一份
|
|
71
|
+
|
|
72
|
+
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/conversation-filter.png" width="900" alt="对话级管理标签界面与继承来源">
|
|
73
|
+
|
|
74
|
+
同一个工作区下的某个对话想单独收窄,就在这里改:它会记下**继承来源**与时间
|
|
75
|
+
(`改动不影响工作区`),左下角一键「继承工作区配置」覆盖回来。
|
|
76
|
+
|
|
30
77
|
## 安装
|
|
31
78
|
|
|
32
79
|
这是一个 DSH(DeepSeek Harness)**插件**,装进 profile 后随宿主启动加载。
|
|
@@ -97,6 +144,8 @@ sha256sum "$S/lib/client.js" "$D/lib/client.js" | cut -c1-16
|
|
|
97
144
|
- **skill 联系 tag** —— 选一个技能,勾它拥有哪些标签
|
|
98
145
|
- **编辑标签组** —— 写表达式、存成模板
|
|
99
146
|
|
|
147
|
+
界面长什么样见上方「图 3」「图 4」。
|
|
148
|
+
|
|
100
149
|
也可以让 agent 自己做:安装后 `tagging-skills` 这个 skill 会出现在技能列表里,
|
|
101
150
|
里面是完整的推导法 + API 调用纪律。**不要照抄文档里的标签名**(`aegis`、`office-skills`
|
|
102
151
|
只是某一台机器上的实例),按自己机器的技能清单推导。
|
|
@@ -114,10 +163,10 @@ node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴
|
|
|
114
163
|
### 2. 给工作区/对话配条件
|
|
115
164
|
|
|
116
165
|
- **工作区**:侧栏工作区行的「…」菜单 →「管理标签」
|
|
117
|
-
-
|
|
166
|
+
- **对话**:会话行的「…」菜单(图 1),或聊天框左下角「+」→「管理对话标签」(图 2)
|
|
118
167
|
|
|
119
168
|
左列写标签条件表达式,右列是命中结果(可手动添加 / 禁用 / 恢复)。改完立刻生效:
|
|
120
|
-
|
|
169
|
+
该对话下发给模型的技能列表就是右列那一份。两张双列界面见「图 5」「图 6」。
|
|
121
170
|
|
|
122
171
|
### 3. 表达式语法
|
|
123
172
|
|
|
@@ -132,6 +181,26 @@ node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴
|
|
|
132
181
|
表达式会被编译成 DNF(`{orTags, andGroups}`)再交给匹配引擎。
|
|
133
182
|
标签改名时,**所有引用它的表达式会被按 token 原位改写**,不会失效。
|
|
134
183
|
|
|
184
|
+
### 4. 路由型技能(router)别被拆散
|
|
185
|
+
|
|
186
|
+
有些技能的职责就是「帮你挑别的技能」——description 里出现 *Use when the user asks…*
|
|
187
|
+
*Router over the skills…* 这类措辞,还常带有 `skill-filesystem` 之类的下挂资源。
|
|
188
|
+
|
|
189
|
+
插件**只按名字屏蔽,从不改任何 `SKILL.md` 的内容**,所以路由器自己的提示词一个字都不会变。
|
|
190
|
+
会坏的是**依赖关系**:路由器可见、而它指向的技能被屏蔽时,模型照着提示去加载只会拿到
|
|
191
|
+
`skill "X" is not available for model invocation` —— 这一轮路由白跑,还可能让模型反复试。
|
|
192
|
+
|
|
193
|
+
所以配条件时请**把路由器和它的簇当成一个整体**(用簇标签一次圈住,别按领域标签零散地挑):
|
|
194
|
+
|
|
195
|
+
| 你想要的 | 表达式 |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| 整个簇(路由器 + 7 个 office 技能) | `routes:office` |
|
|
198
|
+
| 只要路由器本身,不要成员 | `router * routes:office` |
|
|
199
|
+
| 只要成员,不要路由器(自己挑,不需要分发提示) | `routes:office * !router` |
|
|
200
|
+
|
|
201
|
+
插件运行时不认识"谁是路由器"(router 只是标签约定),这一层耦合靠表达式自己保证完整。
|
|
202
|
+
选完可以用 `POST /skill-tag/api/workspaces/skills` 回读 `candidates`,确认路由器的簇成员没被拆散。
|
|
203
|
+
|
|
135
204
|
## HTTP API
|
|
136
205
|
|
|
137
206
|
挂在 `/skill-tag/api` 前缀下。所有路由**同时接受 GET 和 POST**
|
|
@@ -180,27 +249,29 @@ src/storage/ storage domain schema + repository
|
|
|
180
249
|
src/reveal.js 拉起系统文件管理器
|
|
181
250
|
skills/tagging-skills/ 随插件注册给宿主的一个 skill
|
|
182
251
|
tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
|
|
183
|
-
tests/ node:test,
|
|
252
|
+
tests/ node:test,192 项
|
|
184
253
|
docs/ 设计文档(见下)
|
|
254
|
+
images/ README 用的界面截图(不进 npm 包)
|
|
185
255
|
```
|
|
186
256
|
|
|
187
257
|
## 文档
|
|
188
258
|
|
|
189
259
|
| 文件 | 内容 |
|
|
190
260
|
| --- | --- |
|
|
191
|
-
| [docs/给技能打标签.md](docs
|
|
192
|
-
| [docs/标签组配置.md](docs
|
|
193
|
-
| [docs/管理标签.md](docs
|
|
194
|
-
| [docs/对话管理标签.md](docs
|
|
195
|
-
| [docs/技能发现与宿主对齐.md](docs
|
|
196
|
-
| [docs/
|
|
197
|
-
| [
|
|
261
|
+
| [docs/给技能打标签.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E7%BB%99%E6%8A%80%E8%83%BD%E6%89%93%E6%A0%87%E7%AD%BE.md) | **打标签的完整手册**:三轴设计、API 调用序列、错误对照表、交付检查清单 |
|
|
262
|
+
| [docs/标签组配置.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E6%A0%87%E7%AD%BE%E7%BB%84%E9%85%8D%E7%BD%AE.md) | 表达式编辑页的设计与验收 |
|
|
263
|
+
| [docs/管理标签.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E7%AE%A1%E7%90%86%E6%A0%87%E7%AD%BE.md) | 工作区双列配置 |
|
|
264
|
+
| [docs/对话管理标签.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E5%AF%B9%E8%AF%9D%E7%AE%A1%E7%90%86%E6%A0%87%E7%AD%BE.md) | 对话级配置与继承 |
|
|
265
|
+
| [docs/技能发现与宿主对齐.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/%E6%8A%80%E8%83%BD%E5%8F%91%E7%8E%B0%E4%B8%8E%E5%AE%BF%E4%B8%BB%E5%AF%B9%E9%BD%90.md) | 为什么"插件看到的技能数"曾经少于宿主,以及怎么对齐 |
|
|
266
|
+
| [docs/deny-shadow-覆盖率.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/deny-shadow-%E8%A6%86%E7%9B%96%E7%8E%87.md) | 为什么包带来的技能会**静默漏网**(`path` 为 null 的那批),以及宿主对影子候选的强校验、层/rank 语义 |
|
|
267
|
+
| [docs/skill联系tag与打开文件夹.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/docs/skill%E8%81%94%E7%B3%BBtag%E4%B8%8E%E6%89%93%E5%BC%80%E6%96%87%E4%BB%B6%E5%A4%B9.md) | 两个方向编辑 + 📁 按钮 |
|
|
268
|
+
| [CHANGELOG.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/CHANGELOG.md) | 按日期倒序的完整开发记录 |
|
|
198
269
|
|
|
199
270
|
## 开发
|
|
200
271
|
|
|
201
272
|
```bash
|
|
202
273
|
npm install
|
|
203
|
-
npm test #
|
|
274
|
+
npm test # 192 项
|
|
204
275
|
```
|
|
205
276
|
|
|
206
277
|
测试分两类,都在 `tests/` 下:
|
|
@@ -209,7 +280,7 @@ npm test # 183 项
|
|
|
209
280
|
- `__*.mjs` —— 手工脚本:HTTP 全链路冒烟(`__api-smoke`)、用迷你 React 运行时
|
|
210
281
|
执行真实组件并断言渲染结构(`__page-render` 等)
|
|
211
282
|
|
|
212
|
-
**两个 glob 都要**,`npm test` 已经配好;只跑 `tests/**/*.test.mjs` 会漏掉
|
|
283
|
+
**两个 glob 都要**,`npm test` 已经配好;只跑 `tests/**/*.test.mjs` 会漏掉 46 个用例。
|
|
213
284
|
|
|
214
285
|
界面预览可以在没有浏览器扩展时生成静态 HTML:
|
|
215
286
|
|
|
@@ -235,4 +306,4 @@ node docs/preview/render-preview.mjs # → docs/preview/标签组配置-prev
|
|
|
235
306
|
|
|
236
307
|
## 许可证
|
|
237
308
|
|
|
238
|
-
MIT — 见 [LICENSE](LICENSE)。
|
|
309
|
+
MIT — 见 [LICENSE](https://github.com/aawef/dsh-skill-manager-tags/blob/main/LICENSE)。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# deny-shadow 的覆盖率:为什么包带来的技能永远屏蔽不掉
|
|
2
|
+
|
|
3
|
+
> 2026-10-22。线上实测复现 + 修复。适用于 `lib/index.js` 的 `createFilterProvider` /
|
|
4
|
+
> `createUpstreamAdapter` / `createSkillDiscoverer`。
|
|
5
|
+
|
|
6
|
+
## 1. 现象
|
|
7
|
+
|
|
8
|
+
模型上下文里技能目录 71 条,插件口径 `universe 105 / visible 54 / denied 51`:
|
|
9
|
+
|
|
10
|
+
- 71 − 17 = 54 = 插件 `visible` → 数字自洽,说明**恰好 17 个本该被屏蔽的技能漏网**;
|
|
11
|
+
- 成功屏蔽 34 个,全部来自文件系统技能根(`~/.dsh/skills` 33 + `~/.agents/skills` 1);
|
|
12
|
+
- 漏网 17 个,全部来自别的 provider / npm 包 / 运行时:
|
|
13
|
+
|
|
14
|
+
| 来源 | 数量 | 名字 |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| aegis(bundled) | 4 | using-aegis, update-aegis, writing-skills, communicating-concisely |
|
|
17
|
+
| mattpocock(custom) | 4 | ask-matt, research, teach, writing-for-agents |
|
|
18
|
+
| archify | 1 | archify |
|
|
19
|
+
| dsh-skill-tag(本插件) | 1 | tagging-skills |
|
|
20
|
+
| office(bundled,`path` 为 null) | 7 | office, office-docx, office-export-pdf, office-pptx, office-recalculate, office-render, office-xlsx |
|
|
21
|
+
|
|
22
|
+
分界线极干净:**`path` 落在文件系统技能根的 → 被屏蔽;由别的 provider 送进来的 → 全部漏网。**
|
|
23
|
+
|
|
24
|
+
失效是**静默**的:插件日志、右列界面、`/skill-tag/api/skills` 全都对(universe 105),
|
|
25
|
+
只有模型侧少了那 17 条影子。
|
|
26
|
+
|
|
27
|
+
## 2. 根因
|
|
28
|
+
|
|
29
|
+
### 2.1 发射范围 = 本次可见的 summary
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
// 修前
|
|
33
|
+
for (const summary of summaries) {
|
|
34
|
+
if (denied.has(summary.name)) shadows.push({ ...summary, invocation: DENY })
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
只看得见的手才发影子。**看不见的名字,就算在 `denied` 里也一个字节都不发。**
|
|
39
|
+
|
|
40
|
+
### 2.2 范围为何会变窄:重入守卫把宿主快照整段跳过
|
|
41
|
+
|
|
42
|
+
`createUpstreamAdapter.summaries()` 在 `nesting.depth > 0` 时跳过
|
|
43
|
+
`hostRegistry.snapshot()`,只剩扫盘结果:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
if (hostRegistry && nesting.depth === 0) { ... await hostRegistry.snapshot(...) }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2.3 守卫为何会被占住:与发现器共用一份计数器
|
|
50
|
+
|
|
51
|
+
修前 `apply()` 里只有一份 `snapshotGuard`,同时交给 `createUpstreamAdapter` 与
|
|
52
|
+
`createSkillDiscoverer`。于是任何**另一个** snapshot 在飞 —— 设置页 discoverer、
|
|
53
|
+
skill-explorer / skills-manager 的 UI 快照、另一个 agent 的收集、`domain/changed`
|
|
54
|
+
引发的并发刷新 —— 都会把否认路径挤成"只看得到扫盘结果"。
|
|
55
|
+
|
|
56
|
+
触发是**时序相关**的,不是永久失效,所以更像偶发:同一时刻
|
|
57
|
+
`GET /skill-tag/api/skills` 仍返回 105(那条路走的是发现器)。
|
|
58
|
+
|
|
59
|
+
## 3. 宿主语义(修复必须遵守)
|
|
60
|
+
|
|
61
|
+
`@deepseek-ai/dsh-skill`(asar 0.2.1-rc.2,源码核实):
|
|
62
|
+
|
|
63
|
+
| 事实 | 位置 |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `layers = [global, ...chainLayers(scope)]`,逐层 `merged.set(name, entry)` → **近层整条覆盖同名**(不是按字段合并) | `collectFresh` |
|
|
66
|
+
| 层内才按 `rank` 升序去重(`compareIndexedCandidates`:rank → providerOrder → localOrder) | `collectLayer` / `compareIndexedCandidates` |
|
|
67
|
+
| `RUNTIME_RANK = 250`,`BUNDLED_SKILL_RANK = 600` | 同包常量 |
|
|
68
|
+
| `validateCandidate` 强制:`name` 匹配 `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`、`description` 非空字符串、`invocation` 是对象、`source` 字符串、`rank` 有限数、`provider` 必须等于 provider 自己的名字 | `validateCandidate` |
|
|
69
|
+
| 候选校验失败 → `provider.list()` 抛错 → 宿主只记一条 `skill provider ... skipped` 并**丢弃该 provider 的全部候选** | `listLayerCandidates` 的 try/catch |
|
|
70
|
+
| 模型侧口径:`snapshot({cwd, signal, scope: agent}).skills.filter(isModelInvocable)` | `dsh-tool-skill` |
|
|
71
|
+
|
|
72
|
+
## 4. 修复
|
|
73
|
+
|
|
74
|
+
三件事一起做(`lib/index.js`):
|
|
75
|
+
|
|
76
|
+
1. **影子按名字发**:`list()` 分两段 —— 看得见的照抄 summary;看不见的直接造一条
|
|
77
|
+
最小合法候选(`name` / `description` / `source` / `rank` / `provider` / `invocation`
|
|
78
|
+
齐全)。屏蔽是按键名生效的,不需要知道那名字长什么样。
|
|
79
|
+
2. **两个出口各用一份计数器**:递归那圈只在 adapter 内部,`createSkillDiscoverer`
|
|
80
|
+
不在回路里;共用只会互相连坐。发现器另加同 key 并发合并。
|
|
81
|
+
3. **观测**:发生补发时打一行 warn(`shadow backfill ... backfilled=N`,
|
|
82
|
+
降级时再带 `hostSnapshot=skipped(nested)`);健康路径不刷日志。
|
|
83
|
+
`denied` 里的脏名字也会单独 warn,而不是让整批影子被宿主丢掉。
|
|
84
|
+
|
|
85
|
+
配套细节:
|
|
86
|
+
|
|
87
|
+
- `rank` 统一取 **0**,不照抄上游 rank。office 是 600,若同层里另有 rank 250 的
|
|
88
|
+
同名候选,影子会被顶掉 —— 屏蔽随即失效。
|
|
89
|
+
- 上游 `summaries()` 抛错时**不再 `return []`**:返回空增量等于把全部技能放行,
|
|
90
|
+
正是本 bug 的一个分支。现在降级为"只用 `denied` 补影子"。
|
|
91
|
+
- `source` 兜底为 `"skill-tag"`:上游 summary 缺 `source` 时宿主会以"非字符串"整段丢弃。
|
|
92
|
+
|
|
93
|
+
## 5. 验证
|
|
94
|
+
|
|
95
|
+
`tests/shadow-coverage.test.mjs`(8 条):
|
|
96
|
+
|
|
97
|
+
- 守卫被占 / 宿主 `snapshot` 直接抛错时,`denied` 里 22 个名字**全部**拿到影子;
|
|
98
|
+
- 且造出来的候选逐个通过宿主 `validateCandidate` 的等价校验;
|
|
99
|
+
- 脏名字只跳过自己,不炸整批;
|
|
100
|
+
- 健康路径不刷日志、降级路径必留痕;
|
|
101
|
+
- 同一名字只发一条影子。
|
|
102
|
+
|
|
103
|
+
`tests/runtime.test.mjs` 另锁住"另一个出口在飞时否认路径仍要问宿主"。
|
|
104
|
+
|
|
105
|
+
仓外的 `probe-nesting.mjs`(假 hostRegistry 23 技能 / 扫盘 7 个 / denied 全量)
|
|
106
|
+
修前 `CASE B: emitted=7 missed=16`,修后 `emitted=23 missed=0`。
|
|
107
|
+
|
|
108
|
+
## 6. 未决
|
|
109
|
+
|
|
110
|
+
1. **补发会造出"幽灵影子"**:`denied` 里有、但本机已卸载的名字(历史标签残留)也会
|
|
111
|
+
拿到一条影子。它 `modelInvocable:false`,模型侧不可见(被 `isModelInvocable` 过滤),
|
|
112
|
+
只可能在技能列表 UI 里多一条「blocked by skill-tag」。要彻底消除需要给
|
|
113
|
+
`skill_links` 加路径/存在性证据,暂不做。
|
|
114
|
+
2. `recomputeDenials` 的 `universe` 在降级那一趟仍然偏窄(`denied` 靠
|
|
115
|
+
`computeDeniedSkillIds` 兜底把已打标签的补了回来)。未打标签、且这一趟看不见的
|
|
116
|
+
技能不会被判 deny —— 下一趟正常快照会纠正,且不影响已知名单。
|
|
117
|
+
3. ~~`provider.get()` 对影子名字会回问宿主 `get`,而宿主选中的又是影子自己 →
|
|
118
|
+
理论上可自递归。实际不可达(影子两个 invocation 都是 false,模型与 `/命令`
|
|
119
|
+
都调不到),暂不改。~~
|
|
120
|
+
**2026-10-22 更正:可达,已修。** 模型侧走 `skill` 工具确实调不到(先 `list()`,
|
|
121
|
+
影子 `modelInvocable:false` 直接抛错),但 `dsh-tool-skill` 的 `agent/pre-step`
|
|
122
|
+
对 `/name` 手势是**先 `get` 再判 `userInvocable`**(line 909),于是人打一轮
|
|
123
|
+
`/<被屏蔽的技能名>` 就会触发 `registry.get → provider.get → registry.get`
|
|
124
|
+
的无限回环(async,栈不涨、Promise 不 settle → 静默卡死)。
|
|
125
|
+
现在 `get()` 对 denied 名字直接返回 deny 定义(`content: ""`),不再回问宿主。
|
|
@@ -109,24 +109,36 @@ createSkillDiscoverer({ hostRegistry, rootsScan, agents })
|
|
|
109
109
|
> 重新收集一遍 → 再次 `list()` → 再次 `summaries()` → 无限递归。
|
|
110
110
|
> 所以嵌套时直接跳过宿主快照,只用自扫描(fs 扫描无重入)。
|
|
111
111
|
|
|
112
|
-
#### 4.2.1
|
|
112
|
+
#### 4.2.1 两个出口各用一份计数器(2026-10-22 修正)
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
> 早先的写法是**共用**一份 `snapshotGuard`,理由写的是"这一圈跨两个工厂函数"。
|
|
115
|
+
> 那个理由是错的,而且代价很大:共用之后,设置页/UI 快照只要在飞,否认路径就被
|
|
116
|
+
> 连坐判成"嵌套" → 跳过宿主快照 → 只看得见扫盘结果 → 包带来的技能全部漏网。
|
|
117
|
+
> 详见 `docs/deny-shadow-覆盖率.md`。
|
|
117
118
|
|
|
119
|
+
递归那一圈其实**只在 adapter 自己内部**:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
provider.list() → freshen() → recomputeDenials() → upstream.summaries()
|
|
123
|
+
→ hostRegistry.snapshot() ← adapter 自己的守卫在这里 +1
|
|
124
|
+
→ 宿主 collect → 收集到本插件自己的 provider
|
|
125
|
+
→ provider.list() → freshen()(refreshing 里已有本 sessionId,直接返回)
|
|
126
|
+
→ upstream.summaries() ← 守卫已 > 0,跳过宿主快照 → 收敛
|
|
118
127
|
```
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
|
|
129
|
+
`createSkillDiscoverer` 不在这条回路里(它不是 provider,宿主的 snapshot 不会
|
|
130
|
+
回调它),所以它的计数器与 deny 路径无关。`apply()` 里现在各自建一份:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const upstreamGuard = { depth: 0 }; // → createUpstreamAdapter
|
|
134
|
+
const discoverGuard = { depth: 0 }; // → createSkillDiscoverer
|
|
124
135
|
```
|
|
125
136
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
另外发现器里做了**同 key 并发合并**(`inFlight`):设置页一次会并发打好几个
|
|
138
|
+
接口,cwd + conversationId 相同的请求共用一次宿主快照,避免"自己把自己判成嵌套"。
|
|
139
|
+
|
|
140
|
+
即使某次真的降级了(守卫被占 / 宿主抛错),**屏蔽结果也不再受影响** ——
|
|
141
|
+
`provider.list()` 会按 `denied` 补齐看不见的名字,见 4.4。
|
|
130
142
|
|
|
131
143
|
#### 4.2.2 否认路径也要带 `scope`
|
|
132
144
|
|
|
@@ -141,6 +153,34 @@ createSkillDiscoverer({ hostRegistry, rootsScan, agents })
|
|
|
141
153
|
`convPayload` 全部 await 化;新增第三参 `hint = {conversationId, workspaceId}`
|
|
142
154
|
透传给发现器。
|
|
143
155
|
|
|
156
|
+
### 4.4 deny-shadow 按**名字**发射,不按"这一趟看得见谁"
|
|
157
|
+
|
|
158
|
+
`createFilterProvider.list()` 原来只遍历 `upstream.summaries()` 的结果:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
for (const summary of summaries) if (denied.has(summary.name)) shadows.push(...)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
于是**发射范围 = 本次能看到的 summary**。任何一次"看不见"的降级(守卫被占、
|
|
165
|
+
宿主快照抛错)都会让 `denied` 里那些包带来的技能一条影子都收不到 —— 它们是原样
|
|
166
|
+
透给模型,而不是被屏蔽。这就是 aegis / mattpocock / archify / office /
|
|
167
|
+
tagging-skills 那 17 个长期漏网的原因(详见 `deny-shadow-覆盖率.md`)。
|
|
168
|
+
|
|
169
|
+
现在分两段:
|
|
170
|
+
|
|
171
|
+
1. 看得见的:照抄上游 summary(保留 `path` / `description`),只换 `invocation`
|
|
172
|
+
与 `provider`,`rank` 取 `0`;
|
|
173
|
+
2. 看不见的:给 `denied` 里的每个名字**造一条最小合法候选** —— 屏蔽是按键名
|
|
174
|
+
生效的,不需要知道它长什么样。
|
|
175
|
+
|
|
176
|
+
`rank: 0` 是硬约束:宿主只在**层内**按 `rank` 升序去重(`compareIndexedCandidates`),
|
|
177
|
+
跨层是"近层整条覆盖"。照抄上游 rank(office = `BUNDLED_SKILL_RANK` 600)时,
|
|
178
|
+
同层里若有 rank 更小的同名候选,影子会被顶掉。
|
|
179
|
+
|
|
180
|
+
`denied` 里的历史脏名字(带空格 / 大写 / 中文)必须先过宿主的 kebab-case 校验
|
|
181
|
+
再发 —— 一个不合法候选会让**整个** `provider.list()` 抛错,宿主随即丢弃本插件的
|
|
182
|
+
全部贡献(日志 `skill provider "skill-tag" skipped`),比漏一个更糟。
|
|
183
|
+
|
|
144
184
|
## 5. 约束(踩过的坑)
|
|
145
185
|
|
|
146
186
|
- **cordis 服务必须声明式注入**:`export const inject` 里没有的服务,读 `ctx.xxx`
|
package/lib/index.js
CHANGED
|
@@ -25,10 +25,36 @@ import { scanSkillRoots } from "../src/skill-scan.js";
|
|
|
25
25
|
import { loadBundledSkill } from "../src/skill-doc.js";
|
|
26
26
|
|
|
27
27
|
const PROVIDER_NAME = "skill-tag";
|
|
28
|
+
/** 影子候选的 source(宿主要求是字符串,且只用于展示/排序以外的标识)。 */
|
|
29
|
+
const PROVIDER_SOURCE = "skill-tag";
|
|
28
30
|
|
|
29
31
|
const ON = { modelInvocable: true, userInvocable: true };
|
|
30
32
|
const DENY = { modelInvocable: false, userInvocable: false };
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 宿主对 provider 返回的候选是**强校验**的(@deepseek-ai/dsh-skill validateCandidate):
|
|
36
|
+
* name 必须匹配 /^[a-z0-9]+(?:-[a-z0-9]+)*$/、description 非空字符串、
|
|
37
|
+
* source 字符串、rank 有限数、provider 必须等于本 provider 的名字、
|
|
38
|
+
* invocation 必须是对象且两个字段都是 boolean。
|
|
39
|
+
* 任一候选不合法 → **整个 provider.list() 抛错** → 宿主把本插件的贡献整段丢弃
|
|
40
|
+
* (日志:"skill provider ... skipped")→ 一个影子都不发,全部技能漏网。
|
|
41
|
+
* 所以「凭空造影子」时必须先过这一关,脏名字宁可丢掉也不能让它炸掉整批。
|
|
42
|
+
*/
|
|
43
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
44
|
+
|
|
45
|
+
function isSkillName(name) {
|
|
46
|
+
return typeof name === "string" && SKILL_NAME_RE.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 影子在**同一层内**的排序键(compareIndexedCandidates:rank 升序,小的先胜)。
|
|
51
|
+
* 取 0 而不是沿用上游 rank 的原因:宿主只在层内按 rank 去重,跨层是"近层整条覆盖"。
|
|
52
|
+
* 如果影子照抄上游 rank(office 是 BUNDLED_SKILL_RANK=600),同层里若有别的
|
|
53
|
+
* provider 用更小的 rank 提供同名候选(RUNTIME_RANK=250 一类的 agent 层 provider),
|
|
54
|
+
* 影子会被那条候选顶掉 —— 屏蔽随即失效。0 保证层内必胜。
|
|
55
|
+
*/
|
|
56
|
+
const SHADOW_RANK = 0;
|
|
57
|
+
|
|
32
58
|
/**
|
|
33
59
|
* Build the per-agent filter provider over a session's policy snapshot.
|
|
34
60
|
* Mirrors proto/lib/index.js createFilterProvider: emits deny-shadows ONLY —
|
|
@@ -41,6 +67,7 @@ const DENY = { modelInvocable: false, userInvocable: false };
|
|
|
41
67
|
* @param {Set<string>} denied denied skill names for this session (live set)
|
|
42
68
|
* @param {((options:any)=>Promise<void>)|undefined} [ensureFresh]
|
|
43
69
|
* Lazily (re)compute `denied` right before it is consulted.
|
|
70
|
+
* @param {((msg:string)=>void)|undefined} [logWarn] 观测出口(方案 C),可缺省。
|
|
44
71
|
*
|
|
45
72
|
* 为什么必须有它(2026-10-20 bug):`denied` 原先只在 `domain/changed`
|
|
46
73
|
* 事件里被填充,而那个事件由**写操作**触发。新对话第一次列技能时没有任何
|
|
@@ -48,7 +75,7 @@ const DENY = { modelInvocable: false, userInvocable: false };
|
|
|
48
75
|
* `domain/changed` 才开始过滤。用户看到的就是"第一条消息不受控,第二条才受控"。
|
|
49
76
|
* 把刷新挂到 provider 的读路径上,第一次 `list()` 就已经是正确口径。
|
|
50
77
|
*/
|
|
51
|
-
export function createFilterProvider(sessionId, upstream, denied, ensureFresh) {
|
|
78
|
+
export function createFilterProvider(sessionId, upstream, denied, ensureFresh, logWarn) {
|
|
52
79
|
async function freshen(options) {
|
|
53
80
|
if (typeof ensureFresh !== "function") return;
|
|
54
81
|
// 这里抛错只会让本次降级为"沿用旧集合",绝不冒回宿主。
|
|
@@ -59,46 +86,103 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh) {
|
|
|
59
86
|
async list(options) {
|
|
60
87
|
await freshen(options);
|
|
61
88
|
// 宿主会 await 这里;抛出的 rejection 同样可能升级成 fatal。屏蔽技能
|
|
62
|
-
// 是"锦上添花"
|
|
63
|
-
let summaries;
|
|
89
|
+
// 是"锦上添花",上游扫不动时也不能什么都不发(那样等于全部放行)。
|
|
90
|
+
let summaries = [];
|
|
64
91
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
92
|
+
const got = await upstream.summaries(options);
|
|
93
|
+
if (Array.isArray(got)) summaries = got;
|
|
94
|
+
} catch { summaries = []; }
|
|
95
|
+
|
|
96
|
+
// ── 第一段:看得见的技能,照抄它的元信息做影子 ────────────────────
|
|
97
|
+
// 为什么要照抄:宿主 catalog 里这个条目的 description / path / whenToUse
|
|
98
|
+
// 还是上游那份,影子只换掉 invocation 与 provider,右列能看出"它被屏蔽了"。
|
|
69
99
|
const shadows = [];
|
|
100
|
+
const emitted = new Set();
|
|
70
101
|
for (const summary of summaries) {
|
|
71
|
-
if (denied.has(summary.name))
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
102
|
+
if (!summary || !denied.has(summary.name)) continue;
|
|
103
|
+
shadows.push({
|
|
104
|
+
...summary,
|
|
105
|
+
provider: PROVIDER_NAME,
|
|
106
|
+
source: typeof summary.source === "string" && summary.source !== "" ? summary.source : PROVIDER_SOURCE,
|
|
107
|
+
description: "[blocked by skill-tag] " + (summary.description || summary.name),
|
|
108
|
+
invocation: DENY,
|
|
109
|
+
rank: SHADOW_RANK,
|
|
110
|
+
});
|
|
111
|
+
emitted.add(summary.name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 第二段:denied 里但这一趟看不见的名字(本 bug 的根因补丁)──────
|
|
115
|
+
// 2026-10-22:原实现只遍历 summaries,于是"发射范围 = 本次能看到的
|
|
116
|
+
// summary"。当重入守卫被占住(另一个 snapshot 在飞)时宿主快照被整段
|
|
117
|
+
// 跳过,summaries 只剩扫盘结果 → 由别的 provider / npm 包 / 运行时送进来
|
|
118
|
+
// 的技能(aegis / mattpocock / archify / office / tagging-skills)
|
|
119
|
+
// 一条影子都没有,原样漏给模型(线上实测 17 个)。
|
|
120
|
+
// 修法:屏蔽是**按名字**生效的,不需要知道那名字长什么样 —— 看不见的
|
|
121
|
+
// 名字直接造一条最小合法候选,就能在近层整条覆盖上游那份。
|
|
122
|
+
let backfilled = 0;
|
|
123
|
+
const invalid = [];
|
|
124
|
+
for (const name of denied) {
|
|
125
|
+
if (emitted.has(name)) continue;
|
|
126
|
+
if (!isSkillName(name)) { invalid.push(name); continue; }
|
|
127
|
+
shadows.push({
|
|
128
|
+
name,
|
|
129
|
+
description: `[blocked by skill-tag] ${name}`,
|
|
130
|
+
provider: PROVIDER_NAME,
|
|
131
|
+
source: PROVIDER_SOURCE,
|
|
132
|
+
rank: SHADOW_RANK,
|
|
133
|
+
invocation: DENY,
|
|
134
|
+
});
|
|
135
|
+
emitted.add(name);
|
|
136
|
+
backfilled += 1;
|
|
137
|
+
}
|
|
138
|
+
// 方案 C:把这一趟的口径暴露出来。只在"确实发生了补发"或"有脏名字被
|
|
139
|
+
// 丢掉"时出声,健康路径一条日志都不写。
|
|
140
|
+
if (typeof logWarn === "function") {
|
|
141
|
+
try {
|
|
142
|
+
if (backfilled > 0) {
|
|
143
|
+
logWarn(`[skill-tag] shadow backfill session=${sessionId}`
|
|
144
|
+
+ ` visible=${summaries.length} denied=${denied.size}`
|
|
145
|
+
+ ` shadowed=${emitted.size} backfilled=${backfilled}`
|
|
146
|
+
+ `${upstream.lastDegraded === true ? " hostSnapshot=skipped(nested)" : ""}`);
|
|
147
|
+
}
|
|
148
|
+
if (invalid.length > 0) {
|
|
149
|
+
logWarn(`[skill-tag] denied names skipped (not kebab-case): ${invalid.slice(0, 8).join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
} catch { /* 观测不得影响主路径 */ }
|
|
80
152
|
}
|
|
81
153
|
return shadows;
|
|
82
154
|
},
|
|
83
155
|
async get(candidate, options) {
|
|
84
156
|
await freshen(options);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
// 被屏蔽的名字:**绝不**再回问宿主。
|
|
158
|
+
//
|
|
159
|
+
// 2026-10-22 实测的坑:宿主 registry.get(name) 的流程是
|
|
160
|
+
// collect() → 选中胜出候选(在我们的 agent 层里就是这条影子)
|
|
161
|
+
// → 调**影子自己的** provider.get(candidate)
|
|
162
|
+
// 而我们原先在这里调用 upstream.getDefinition → hostRegistry.get(name)
|
|
163
|
+
// → 又选中影子 → 又是我们 → registry.get 被无限反复调用。
|
|
164
|
+
// 一轮 `/<被屏蔽的技能名>` 就能撞上:dsh-tool-skill 的 agent/pre-step
|
|
165
|
+
// 对 `/name` 手势是**先 get 再判 invocation**(lib 内 line 909),
|
|
166
|
+
// 所以这条路真实可达,后果是 pre-step 永不 resolve(界面卡住)。
|
|
167
|
+
//
|
|
168
|
+
// 影子本来就没有正文可给(DENY + content:""),直接造定义即可;
|
|
169
|
+
// 形状必须满足宿主 validateDefinition:name/description/source/provider
|
|
170
|
+
// 非空字符串、invocation 是对象、content 是字符串。
|
|
92
171
|
if (denied.has(candidate.name)) {
|
|
93
172
|
return {
|
|
94
|
-
|
|
173
|
+
name: candidate.name,
|
|
174
|
+
description: `[blocked by skill-tag] ${candidate.description || candidate.name}`,
|
|
95
175
|
provider: PROVIDER_NAME,
|
|
96
|
-
|
|
176
|
+
source: PROVIDER_SOURCE,
|
|
97
177
|
invocation: DENY,
|
|
98
178
|
content: "",
|
|
99
179
|
};
|
|
100
180
|
}
|
|
101
|
-
|
|
181
|
+
try {
|
|
182
|
+
return await upstream.getDefinition(candidate, options);
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
102
186
|
},
|
|
103
187
|
};
|
|
104
188
|
}
|
|
@@ -118,14 +202,21 @@ export function createUpstreamAdapter({ rootsScan, hostRegistry, guard = { depth
|
|
|
118
202
|
* 重新收集一遍 → 再次 list() → 再次 summaries() → 无限递归。
|
|
119
203
|
* 所以嵌套时直接跳过宿主快照,只用自扫描(fs 扫描无重入)。
|
|
120
204
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
205
|
+
* 2026-10-22 修正:**不要再和 createSkillDiscoverer 共用一个计数器**。
|
|
206
|
+
* 共用的后果是设置页那条读路径把计数器占住时,模型侧的否认路径也被迫降级
|
|
207
|
+
* 成"只看得见扫盘结果",于是包带来的技能全部漏网(线上 17 个)。
|
|
208
|
+
* 递归那圈本来就只在 adapter 自己内部(snapshot → 宿主 collect → 本 provider
|
|
209
|
+
* 的 list → freshen → 本 adapter 的 summaries),一份 adapter 自己的计数器
|
|
210
|
+
* 就足以截断,discoverer 的计数器与它无关。
|
|
124
211
|
*/
|
|
125
212
|
const nesting = guard;
|
|
213
|
+
/** 本 adapter 最近一次 summaries() 是否跳过了宿主快照(只做观测用)。 */
|
|
214
|
+
let lastDegraded = false;
|
|
126
215
|
return {
|
|
216
|
+
get lastDegraded() { return lastDegraded; },
|
|
127
217
|
async summaries(options) {
|
|
128
218
|
const opts = options ?? {};
|
|
219
|
+
lastDegraded = false;
|
|
129
220
|
// 三段都可能抛(fs 权限、宿主 registry 未就绪、signal aborted)。
|
|
130
221
|
// 调用方是宿主的 provider 链,这里降级成"只看到能看到的那些"。
|
|
131
222
|
let scanned = [];
|
|
@@ -144,6 +235,10 @@ export function createUpstreamAdapter({ rootsScan, hostRegistry, guard = { depth
|
|
|
144
235
|
});
|
|
145
236
|
snapSkills = Array.isArray(snap?.skills) ? snap.skills : [];
|
|
146
237
|
} catch { snapSkills = []; } finally { nesting.depth -= 1; }
|
|
238
|
+
} else if (hostRegistry) {
|
|
239
|
+
// 有宿主但被嵌套挡住 —— 这一趟只看得到扫盘结果。调用方(provider.list)
|
|
240
|
+
// 会靠 denied 集合把看不见的名字补成影子,这里留个痕迹便于排查。
|
|
241
|
+
lastDegraded = true;
|
|
147
242
|
}
|
|
148
243
|
const byName = new Map();
|
|
149
244
|
for (const s of snapSkills) if (s?.name) byName.set(s.name, s);
|
|
@@ -268,6 +363,8 @@ export function runtimeDeniedSkills(ws, universe, candidates) {
|
|
|
268
363
|
*/
|
|
269
364
|
export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null, guard = { depth: 0 } }) {
|
|
270
365
|
const nesting = guard;
|
|
366
|
+
/** 同 key 的并发请求合并成一次宿主快照(设置页刚打开时一次会打好几个接口)。 */
|
|
367
|
+
const inFlight = new Map();
|
|
271
368
|
/** conversationId(= sessionId)→ agent,用来取宿主 snapshot 的 scope。 */
|
|
272
369
|
function agentFor(conversationId) {
|
|
273
370
|
if (!conversationId || !agents || typeof agents.list !== "function") return null;
|
|
@@ -275,6 +372,33 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
|
|
|
275
372
|
try { list = agents.list() ?? []; } catch { return null; }
|
|
276
373
|
return list.find((a) => sessionIdOfAgent(a) === conversationId) ?? null;
|
|
277
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* 问一次宿主,拿它的 skills。
|
|
377
|
+
* @returns {Promise<object[]|null>} null = 这一趟没问成(守卫被占 / 宿主抛错 / 未就绪)
|
|
378
|
+
*/
|
|
379
|
+
function snapshotSkills(cwd, hint, key) {
|
|
380
|
+
const pending = (async () => {
|
|
381
|
+
if (!hostRegistry || typeof hostRegistry.snapshot !== "function") return null;
|
|
382
|
+
if (nesting.depth > 0) return null;
|
|
383
|
+
nesting.depth += 1;
|
|
384
|
+
try {
|
|
385
|
+
const scope = agentFor(hint?.conversationId);
|
|
386
|
+
const snap = await hostRegistry.snapshot({
|
|
387
|
+
...(cwd ? { cwd } : {}),
|
|
388
|
+
...(scope ? { scope } : {}),
|
|
389
|
+
});
|
|
390
|
+
return Array.isArray(snap?.skills) ? snap.skills : [];
|
|
391
|
+
} catch {
|
|
392
|
+
return null; // registry 未就绪 → 调用方退回自扫描
|
|
393
|
+
} finally { nesting.depth -= 1; }
|
|
394
|
+
})();
|
|
395
|
+
inFlight.set(key, pending);
|
|
396
|
+
void pending.then(
|
|
397
|
+
() => { if (inFlight.get(key) === pending) inFlight.delete(key); },
|
|
398
|
+
() => { if (inFlight.get(key) === pending) inFlight.delete(key); },
|
|
399
|
+
);
|
|
400
|
+
return pending;
|
|
401
|
+
}
|
|
278
402
|
return async function discoverSkills(cwd, hint) {
|
|
279
403
|
const out = new Map();
|
|
280
404
|
const put = (s) => {
|
|
@@ -288,18 +412,10 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
|
|
|
288
412
|
});
|
|
289
413
|
};
|
|
290
414
|
// 1) 宿主注册表(权威,与模型侧同源)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const snap = await hostRegistry.snapshot({
|
|
296
|
-
...(cwd ? { cwd } : {}),
|
|
297
|
-
...(scope ? { scope } : {}),
|
|
298
|
-
});
|
|
299
|
-
const list = Array.isArray(snap?.skills) ? snap.skills : [];
|
|
300
|
-
for (const s of list) put(s);
|
|
301
|
-
} catch { /* registry 未就绪 → 退回自扫描 */ } finally { nesting.depth -= 1; }
|
|
302
|
-
}
|
|
415
|
+
const key = `${cwd ?? ""}::${hint?.conversationId ?? ""}`;
|
|
416
|
+
const pending = inFlight.get(key) ?? snapshotSkills(cwd, hint, key);
|
|
417
|
+
const hostSkills = await pending;
|
|
418
|
+
if (Array.isArray(hostSkills)) for (const s of hostSkills) put(s);
|
|
303
419
|
// 2) 自扫描补缺(不同名才补)
|
|
304
420
|
if (rootsScan) {
|
|
305
421
|
try {
|
|
@@ -591,7 +707,7 @@ export async function apply(ctx, config) {
|
|
|
591
707
|
// list() → 再次进 freshen,但 `refreshing` 里有本 sessionId,
|
|
592
708
|
// 第二趟直接返回,不会递归。
|
|
593
709
|
await refreshBySession(sessionId, options?.cwd ?? agentCwd, agent);
|
|
594
|
-
});
|
|
710
|
+
}, (message) => ctx.logger?.warn?.(message));
|
|
595
711
|
}),
|
|
596
712
|
),
|
|
597
713
|
);
|
|
@@ -602,17 +718,27 @@ export async function apply(ctx, config) {
|
|
|
602
718
|
}
|
|
603
719
|
|
|
604
720
|
// 两个出口(设置页的 discoverer 与否认路径的 upstream)问的是同一个
|
|
605
|
-
// `ctx.skills
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
721
|
+
// `ctx.skills`,但**重入计数器必须各用一份**。
|
|
722
|
+
//
|
|
723
|
+
// 2026-10-22 修正(此前是共用一份):共用时,设置页那条读路径(或
|
|
724
|
+
// skill-explorer / skills-manager 的 UI 快照、另一个 agent 的收集、并发刷新)
|
|
725
|
+
// 只要有一个 snapshot 在飞,否认路径就被一起判成"嵌套" → 跳过宿主快照 →
|
|
726
|
+
// 只看得见扫盘结果 → 由别的 provider / npm 包 / 运行时送进来的技能全部漏网。
|
|
727
|
+
// 线上实测:34 个被屏蔽,17 个(aegis / mattpocock / archify / office /
|
|
728
|
+
// tagging-skills)永远穿透,而且完全静默。
|
|
729
|
+
//
|
|
730
|
+
// 现在递归只可能发生在 adapter 自己内部(snapshot → 宿主 collect → 本 provider
|
|
731
|
+
// 的 list → freshen → 本 adapter 的 summaries),一份 adapter 自己的计数器就够;
|
|
732
|
+
// 而且 provider.list 在看不见时会按 denied 补齐影子,压根不依赖这次快照。
|
|
733
|
+
const upstreamGuard = { depth: 0 };
|
|
734
|
+
const discoverGuard = { depth: 0 };
|
|
609
735
|
|
|
610
736
|
const upstream = createUpstreamAdapter({
|
|
611
737
|
hostRegistry: ctx.skills ?? null,
|
|
612
738
|
// rootsScan: host plane cannot see preset-layer providers; scan the
|
|
613
739
|
// official skill roots ourselves (skill-explorer pattern, discovery §5.4).
|
|
614
740
|
rootsScan: (options) => scanSkillRoots(options?.cwd, config?.customSkillDirs ?? []),
|
|
615
|
-
guard:
|
|
741
|
+
guard: upstreamGuard,
|
|
616
742
|
});
|
|
617
743
|
|
|
618
744
|
ctx.effect(() => () => { controls.clear(); sessionDenials.clear(); });
|
|
@@ -689,7 +815,7 @@ export async function apply(ctx, config) {
|
|
|
689
815
|
hostRegistry: ctx.skills ?? null,
|
|
690
816
|
rootsScan: (options) => scanSkillRoots(options?.cwd ?? null, config?.customSkillDirs ?? []),
|
|
691
817
|
agents: (() => { try { return ctx.get("agents") ?? null; } catch { return null; } })(),
|
|
692
|
-
guard:
|
|
818
|
+
guard: discoverGuard,
|
|
693
819
|
}),
|
|
694
820
|
// 会话 → 工作区 id:宿主 workspaceRegistry 是权威来源
|
|
695
821
|
(sessionId) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-tag",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
"patch": "./cordis.patch.yml"
|
|
41
41
|
},
|
|
42
42
|
"client": {
|
|
43
|
-
"inject": [
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
46
|
+
],
|
|
44
47
|
"platform": "web"
|
|
45
48
|
}
|
|
46
49
|
},
|