dsh-skill-tag 0.1.2 → 0.1.3

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
 
@@ -180,27 +229,29 @@ src/storage/ storage domain schema + repository
180
229
  src/reveal.js 拉起系统文件管理器
181
230
  skills/tagging-skills/ 随插件注册给宿主的一个 skill
182
231
  tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
183
- tests/ node:test,183 项
232
+ tests/ node:test,192 项
184
233
  docs/ 设计文档(见下)
234
+ images/ README 用的界面截图(不进 npm 包)
185
235
  ```
186
236
 
187
237
  ## 文档
188
238
 
189
239
  | 文件 | 内容 |
190
240
  | --- | --- |
191
- | [docs/给技能打标签.md](docs/给技能打标签.md) | **打标签的完整手册**:三轴设计、API 调用序列、错误对照表、交付检查清单 |
192
- | [docs/标签组配置.md](docs/标签组配置.md) | 表达式编辑页的设计与验收 |
193
- | [docs/管理标签.md](docs/管理标签.md) | 工作区双列配置 |
194
- | [docs/对话管理标签.md](docs/对话管理标签.md) | 对话级配置与继承 |
195
- | [docs/技能发现与宿主对齐.md](docs/技能发现与宿主对齐.md) | 为什么"插件看到的技能数"曾经少于宿主,以及怎么对齐 |
196
- | [docs/skill联系tag与打开文件夹.md](docs/skill联系tag与打开文件夹.md) | 两个方向编辑 + 📁 按钮 |
197
- | [CHANGELOG.md](CHANGELOG.md) | 按日期倒序的完整开发记录 |
241
+ | [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 调用序列、错误对照表、交付检查清单 |
242
+ | [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) | 表达式编辑页的设计与验收 |
243
+ | [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) | 工作区双列配置 |
244
+ | [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) | 对话级配置与继承 |
245
+ | [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) | 为什么"插件看到的技能数"曾经少于宿主,以及怎么对齐 |
246
+ | [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 语义 |
247
+ | [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) | 两个方向编辑 + 📁 按钮 |
248
+ | [CHANGELOG.md](https://github.com/aawef/dsh-skill-manager-tags/blob/main/CHANGELOG.md) | 按日期倒序的完整开发记录 |
198
249
 
199
250
  ## 开发
200
251
 
201
252
  ```bash
202
253
  npm install
203
- npm test # 183 项
254
+ npm test # 192 项
204
255
  ```
205
256
 
206
257
  测试分两类,都在 `tests/` 下:
@@ -209,7 +260,7 @@ npm test # 183 项
209
260
  - `__*.mjs` —— 手工脚本:HTTP 全链路冒烟(`__api-smoke`)、用迷你 React 运行时
210
261
  执行真实组件并断言渲染结构(`__page-render` 等)
211
262
 
212
- **两个 glob 都要**,`npm test` 已经配好;只跑 `tests/**/*.test.mjs` 会漏掉 45 个用例。
263
+ **两个 glob 都要**,`npm test` 已经配好;只跑 `tests/**/*.test.mjs` 会漏掉 46 个用例。
213
264
 
214
265
  界面预览可以在没有浏览器扩展时生成静态 HTML:
215
266
 
@@ -235,4 +286,4 @@ node docs/preview/render-preview.mjs # → docs/preview/标签组配置-prev
235
286
 
236
287
  ## 许可证
237
288
 
238
- MIT — 见 [LICENSE](LICENSE)。
289
+ MIT — 见 [LICENSE](https://github.com/aawef/dsh-skill-manager-tags/blob/main/LICENSE)。
@@ -0,0 +1,119 @@
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
+ 都调不到),暂不改。
@@ -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
- 否认路径(`recomputeDenials` → `upstream.summaries`)和设置页路径
115
- (`createSkillDiscoverer`)问的是**同一个** `ctx.skills`,而这一圈重入
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
- 设置页 /skills(带 scope)
120
- → hostRegistry.snapshot()
121
- → 收集到本插件的 per-agent provider
122
- → provider.list() → freshen()
123
- → recomputeDenials() → upstream.summaries() ← 又要问宿主
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
- 各自一个计数器就互相看不见,于是递归到爆栈。`apply()` 里现在建一份
127
- `const snapshotGuard = { depth: 0 }`,同时传给 `createUpstreamAdapter` 与
128
- `createSkillDiscoverer`;两个工厂的函数签名都是 `guard = { depth: 0 }`
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,24 +86,69 @@ 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
- summaries = await upstream.summaries(options);
66
- } catch {
67
- return [];
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
- shadows.push({
73
- ...summary,
74
- provider: PROVIDER_NAME,
75
- description: "[blocked by skill-tag] " + (summary.description ?? summary.name),
76
- invocation: DENY,
77
- rank: typeof summary.rank === "number" && Number.isFinite(summary.rank) ? summary.rank : 400,
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
  },
@@ -118,14 +190,21 @@ export function createUpstreamAdapter({ rootsScan, hostRegistry, guard = { depth
118
190
  * 重新收集一遍 → 再次 list() → 再次 summaries() → 无限递归。
119
191
  * 所以嵌套时直接跳过宿主快照,只用自扫描(fs 扫描无重入)。
120
192
  *
121
- * `guard` 与 createSkillDiscoverer 共用(同一份 apply() 里的两个出口都问
122
- * 同一个 registry),否则「设置页带 scope 快照 → 我们的 provider → 否认路径
123
- * 再快照」会在两个计数器之间互相看不见。
193
+ * 2026-10-22 修正:**不要再和 createSkillDiscoverer 共用一个计数器**。
194
+ * 共用的后果是设置页那条读路径把计数器占住时,模型侧的否认路径也被迫降级
195
+ * 成"只看得见扫盘结果",于是包带来的技能全部漏网(线上 17 个)。
196
+ * 递归那圈本来就只在 adapter 自己内部(snapshot → 宿主 collect → 本 provider
197
+ * 的 list → freshen → 本 adapter 的 summaries),一份 adapter 自己的计数器
198
+ * 就足以截断,discoverer 的计数器与它无关。
124
199
  */
125
200
  const nesting = guard;
201
+ /** 本 adapter 最近一次 summaries() 是否跳过了宿主快照(只做观测用)。 */
202
+ let lastDegraded = false;
126
203
  return {
204
+ get lastDegraded() { return lastDegraded; },
127
205
  async summaries(options) {
128
206
  const opts = options ?? {};
207
+ lastDegraded = false;
129
208
  // 三段都可能抛(fs 权限、宿主 registry 未就绪、signal aborted)。
130
209
  // 调用方是宿主的 provider 链,这里降级成"只看到能看到的那些"。
131
210
  let scanned = [];
@@ -144,6 +223,10 @@ export function createUpstreamAdapter({ rootsScan, hostRegistry, guard = { depth
144
223
  });
145
224
  snapSkills = Array.isArray(snap?.skills) ? snap.skills : [];
146
225
  } catch { snapSkills = []; } finally { nesting.depth -= 1; }
226
+ } else if (hostRegistry) {
227
+ // 有宿主但被嵌套挡住 —— 这一趟只看得到扫盘结果。调用方(provider.list)
228
+ // 会靠 denied 集合把看不见的名字补成影子,这里留个痕迹便于排查。
229
+ lastDegraded = true;
147
230
  }
148
231
  const byName = new Map();
149
232
  for (const s of snapSkills) if (s?.name) byName.set(s.name, s);
@@ -268,6 +351,8 @@ export function runtimeDeniedSkills(ws, universe, candidates) {
268
351
  */
269
352
  export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null, guard = { depth: 0 } }) {
270
353
  const nesting = guard;
354
+ /** 同 key 的并发请求合并成一次宿主快照(设置页刚打开时一次会打好几个接口)。 */
355
+ const inFlight = new Map();
271
356
  /** conversationId(= sessionId)→ agent,用来取宿主 snapshot 的 scope。 */
272
357
  function agentFor(conversationId) {
273
358
  if (!conversationId || !agents || typeof agents.list !== "function") return null;
@@ -275,6 +360,33 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
275
360
  try { list = agents.list() ?? []; } catch { return null; }
276
361
  return list.find((a) => sessionIdOfAgent(a) === conversationId) ?? null;
277
362
  }
363
+ /**
364
+ * 问一次宿主,拿它的 skills。
365
+ * @returns {Promise<object[]|null>} null = 这一趟没问成(守卫被占 / 宿主抛错 / 未就绪)
366
+ */
367
+ function snapshotSkills(cwd, hint, key) {
368
+ const pending = (async () => {
369
+ if (!hostRegistry || typeof hostRegistry.snapshot !== "function") return null;
370
+ if (nesting.depth > 0) return null;
371
+ nesting.depth += 1;
372
+ try {
373
+ const scope = agentFor(hint?.conversationId);
374
+ const snap = await hostRegistry.snapshot({
375
+ ...(cwd ? { cwd } : {}),
376
+ ...(scope ? { scope } : {}),
377
+ });
378
+ return Array.isArray(snap?.skills) ? snap.skills : [];
379
+ } catch {
380
+ return null; // registry 未就绪 → 调用方退回自扫描
381
+ } finally { nesting.depth -= 1; }
382
+ })();
383
+ inFlight.set(key, pending);
384
+ void pending.then(
385
+ () => { if (inFlight.get(key) === pending) inFlight.delete(key); },
386
+ () => { if (inFlight.get(key) === pending) inFlight.delete(key); },
387
+ );
388
+ return pending;
389
+ }
278
390
  return async function discoverSkills(cwd, hint) {
279
391
  const out = new Map();
280
392
  const put = (s) => {
@@ -288,18 +400,10 @@ export function createSkillDiscoverer({ hostRegistry, rootsScan, agents = null,
288
400
  });
289
401
  };
290
402
  // 1) 宿主注册表(权威,与模型侧同源)
291
- if (hostRegistry && typeof hostRegistry.snapshot === "function" && nesting.depth === 0) {
292
- try {
293
- nesting.depth += 1;
294
- const scope = agentFor(hint?.conversationId);
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
- }
403
+ const key = `${cwd ?? ""}::${hint?.conversationId ?? ""}`;
404
+ const pending = inFlight.get(key) ?? snapshotSkills(cwd, hint, key);
405
+ const hostSkills = await pending;
406
+ if (Array.isArray(hostSkills)) for (const s of hostSkills) put(s);
303
407
  // 2) 自扫描补缺(不同名才补)
304
408
  if (rootsScan) {
305
409
  try {
@@ -591,7 +695,7 @@ export async function apply(ctx, config) {
591
695
  // list() → 再次进 freshen,但 `refreshing` 里有本 sessionId,
592
696
  // 第二趟直接返回,不会递归。
593
697
  await refreshBySession(sessionId, options?.cwd ?? agentCwd, agent);
594
- });
698
+ }, (message) => ctx.logger?.warn?.(message));
595
699
  }),
596
700
  ),
597
701
  );
@@ -602,17 +706,27 @@ export async function apply(ctx, config) {
602
706
  }
603
707
 
604
708
  // 两个出口(设置页的 discoverer 与否认路径的 upstream)问的是同一个
605
- // `ctx.skills`,必须共用一份重入计数器:设置页带 scope 快照 → 宿主收集到
606
- // 本插件的 per-agent provider → freshen → 否认路径再快照,这一圈跨两个
607
- // 工厂函数,各自一个计数器就互相看不见。
608
- const snapshotGuard = { depth: 0 };
709
+ // `ctx.skills`,但**重入计数器必须各用一份**。
710
+ //
711
+ // 2026-10-22 修正(此前是共用一份):共用时,设置页那条读路径(或
712
+ // skill-explorer / skills-manager 的 UI 快照、另一个 agent 的收集、并发刷新)
713
+ // 只要有一个 snapshot 在飞,否认路径就被一起判成"嵌套" → 跳过宿主快照 →
714
+ // 只看得见扫盘结果 → 由别的 provider / npm 包 / 运行时送进来的技能全部漏网。
715
+ // 线上实测:34 个被屏蔽,17 个(aegis / mattpocock / archify / office /
716
+ // tagging-skills)永远穿透,而且完全静默。
717
+ //
718
+ // 现在递归只可能发生在 adapter 自己内部(snapshot → 宿主 collect → 本 provider
719
+ // 的 list → freshen → 本 adapter 的 summaries),一份 adapter 自己的计数器就够;
720
+ // 而且 provider.list 在看不见时会按 denied 补齐影子,压根不依赖这次快照。
721
+ const upstreamGuard = { depth: 0 };
722
+ const discoverGuard = { depth: 0 };
609
723
 
610
724
  const upstream = createUpstreamAdapter({
611
725
  hostRegistry: ctx.skills ?? null,
612
726
  // rootsScan: host plane cannot see preset-layer providers; scan the
613
727
  // official skill roots ourselves (skill-explorer pattern, discovery §5.4).
614
728
  rootsScan: (options) => scanSkillRoots(options?.cwd, config?.customSkillDirs ?? []),
615
- guard: snapshotGuard,
729
+ guard: upstreamGuard,
616
730
  });
617
731
 
618
732
  ctx.effect(() => () => { controls.clear(); sessionDenials.clear(); });
@@ -689,7 +803,7 @@ export async function apply(ctx, config) {
689
803
  hostRegistry: ctx.skills ?? null,
690
804
  rootsScan: (options) => scanSkillRoots(options?.cwd ?? null, config?.customSkillDirs ?? []),
691
805
  agents: (() => { try { return ctx.get("agents") ?? null; } catch { return null; } })(),
692
- guard: snapshotGuard,
806
+ guard: discoverGuard,
693
807
  }),
694
808
  // 会话 → 工作区 id:宿主 workspaceRegistry 是权威来源
695
809
  (sessionId) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-slots"],
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-runtime",
45
+ "@deepseek-ai/dsh-client-ui-slots"
46
+ ],
44
47
  "platform": "web"
45
48
  }
46
49
  },