dsh-skill-tag 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,10 +110,13 @@ dsh plugin --profile <your-profile> add github:aawef/dsh-skill-manager-tags
110
110
 
111
111
  > 依赖 Node ≥ 20;需要 DSH `0.2.0-rc.1` 及以上(见 `engines.dsh`)。
112
112
  >
113
- > 宿主的 `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-storage-domain` 都声明为
114
- > **peerDependency**,由 DSH 本体提供,**不要自己安装**:本插件会 `import` 它的 `defineDomain`
115
- > 来注册存储域,必须与宿主使用同一份模块实例,装成 `dependencies` 会把旧版本提到 profile
116
- > 顶层、遮蔽宿主版本,导致 `workspaceController` 等服务无法上台(详见 CHANGELOG 2026-10-21)。
113
+ > 宿主的 `@deepseek-ai/cordis` 声明为 **peerDependency**,由 DSH 本体提供,**不要自己安装**。
114
+ >
115
+ > 存储域 spec 由本插件 `src/storage/domain-spec.js` 本地构造(等价于宿主的 `defineDomain` /
116
+ > `domainTable`),运行时通过 `ctx.storageDomain.open()` 交给宿主打开,**不 import 任何宿主内核包**。
117
+ > 这样 profile 里不会出现与宿主同名的第二份模块副本:早期版本曾 `import`
118
+ > `@deepseek-ai/dsh-storage-domain`,宿主升级(如 `0.2.1-rc.2` → `rc.3`)时那份旧副本会被判定
119
+ > 不兼容并拒绝加载,导致存储域整体不启动、会话与工作区列表全空(详见 CHANGELOG 2026-10-24)。
117
120
 
118
121
  ### 本地开发式安装
119
122
 
@@ -181,6 +184,26 @@ node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴
181
184
  表达式会被编译成 DNF(`{orTags, andGroups}`)再交给匹配引擎。
182
185
  标签改名时,**所有引用它的表达式会被按 token 原位改写**,不会失效。
183
186
 
187
+ ### 4. 路由型技能(router)别被拆散
188
+
189
+ 有些技能的职责就是「帮你挑别的技能」——description 里出现 *Use when the user asks…*
190
+ *Router over the skills…* 这类措辞,还常带有 `skill-filesystem` 之类的下挂资源。
191
+
192
+ 插件**只按名字屏蔽,从不改任何 `SKILL.md` 的内容**,所以路由器自己的提示词一个字都不会变。
193
+ 会坏的是**依赖关系**:路由器可见、而它指向的技能被屏蔽时,模型照着提示去加载只会拿到
194
+ `skill "X" is not available for model invocation` —— 这一轮路由白跑,还可能让模型反复试。
195
+
196
+ 所以配条件时请**把路由器和它的簇当成一个整体**(用簇标签一次圈住,别按领域标签零散地挑):
197
+
198
+ | 你想要的 | 表达式 |
199
+ | --- | --- |
200
+ | 整个簇(路由器 + 7 个 office 技能) | `routes:office` |
201
+ | 只要路由器本身,不要成员 | `router * routes:office` |
202
+ | 只要成员,不要路由器(自己挑,不需要分发提示) | `routes:office * !router` |
203
+
204
+ 插件运行时不认识"谁是路由器"(router 只是标签约定),这一层耦合靠表达式自己保证完整。
205
+ 选完可以用 `POST /skill-tag/api/workspaces/skills` 回读 `candidates`,确认路由器的簇成员没被拆散。
206
+
184
207
  ## HTTP API
185
208
 
186
209
  挂在 `/skill-tag/api` 前缀下。所有路由**同时接受 GET 和 POST**
@@ -229,7 +252,7 @@ src/storage/ storage domain schema + repository
229
252
  src/reveal.js 拉起系统文件管理器
230
253
  skills/tagging-skills/ 随插件注册给宿主的一个 skill
231
254
  tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
232
- tests/ node:test,192 项
255
+ tests/ node:test,194 项
233
256
  docs/ 设计文档(见下)
234
257
  images/ README 用的界面截图(不进 npm 包)
235
258
  ```
@@ -251,7 +274,7 @@ images/ README 用的界面截图(不进 npm 包)
251
274
 
252
275
  ```bash
253
276
  npm install
254
- npm test # 192 项
277
+ npm test # 194 项
255
278
  ```
256
279
 
257
280
  测试分两类,都在 `tests/` 下:
@@ -114,6 +114,12 @@ skill-explorer / skills-manager 的 UI 快照、另一个 agent 的收集、`dom
114
114
  2. `recomputeDenials` 的 `universe` 在降级那一趟仍然偏窄(`denied` 靠
115
115
  `computeDeniedSkillIds` 兜底把已打标签的补了回来)。未打标签、且这一趟看不见的
116
116
  技能不会被判 deny —— 下一趟正常快照会纠正,且不影响已知名单。
117
- 3. `provider.get()` 对影子名字会回问宿主 `get`,而宿主选中的又是影子自己 →
117
+ 3. ~~`provider.get()` 对影子名字会回问宿主 `get`,而宿主选中的又是影子自己 →
118
118
  理论上可自递归。实际不可达(影子两个 invocation 都是 false,模型与 `/命令`
119
- 都调不到),暂不改。
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: ""`),不再回问宿主。
package/lib/index.js CHANGED
@@ -154,23 +154,35 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
154
154
  },
155
155
  async get(candidate, options) {
156
156
  await freshen(options);
157
- let base;
158
- try {
159
- base = await upstream.getDefinition(candidate, options);
160
- } catch {
161
- return undefined;
162
- }
163
- if (!base) return undefined;
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 是字符串。
164
171
  if (denied.has(candidate.name)) {
165
172
  return {
166
- ...base,
173
+ name: candidate.name,
174
+ description: `[blocked by skill-tag] ${candidate.description || candidate.name}`,
167
175
  provider: PROVIDER_NAME,
168
- description: `[blocked by skill-tag] ${base.description ?? candidate.description}`,
176
+ source: PROVIDER_SOURCE,
169
177
  invocation: DENY,
170
178
  content: "",
171
179
  };
172
180
  }
173
- return base;
181
+ try {
182
+ return await upstream.getDefinition(candidate, options);
183
+ } catch {
184
+ return undefined;
185
+ }
174
186
  },
175
187
  };
176
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,14 +52,13 @@
52
52
  "test:unit": "node --test \"tests/*.test.mjs\""
53
53
  },
54
54
  "peerDependencies": {
55
- "@deepseek-ai/cordis": ">=4.0.2 <5",
56
- "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.3.0 <1.0.0"
55
+ "@deepseek-ai/cordis": ">=4.0.2 <5"
57
56
  },
58
57
  "dependencies": {
59
58
  "zod": "^3.25.76"
60
59
  },
61
60
  "engines": {
62
61
  "node": ">=20",
63
- "dsh": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.1 <1.0.0"
62
+ "dsh": ">=0.2.0-rc.1 <1.0.0"
64
63
  }
65
64
  }
@@ -0,0 +1,73 @@
1
+ // skill-tag-plugin — 本地 domain spec 构造器
2
+ //
3
+ // 为什么不用 @deepseek-ai/dsh-storage-domain:
4
+ //
5
+ // 宿主已经通过 ctx.storageDomain.open(spec) 把 domain 能力注入进来了,插件真正
6
+ // 需要的只是把 spec 构造宿主认识的形状:
7
+ //
8
+ // { name, version, tables: { <tableName>: { valueSchema } }, global?, layout?, ... }
9
+ //
10
+ // 而宿主的 defineDomain 只做「校验 + 原样返回」,domainTable 只做
11
+ // `{ valueSchema: schema }`。为了这两个纯函数去 import 内核包,会让 pnpm 必须
12
+ // 在 profile 里再装一份 @deepseek-ai/dsh-storage-domain 实体,与宿主内置的同名
13
+ // 模块形成「同一模块两份不同版本」——宿主每次升级(rc.2 → rc.3)都会因为旧副本
14
+ // 被运行时判定不兼容而拒绝加载该插件,导致存储域整体不启动、会话与工作区列表
15
+ // 全空。该故障在 0.2.0-rc.2 时代已经发生过一次(见 profiles/desktop/
16
+ // pnpm-workspace.yaml 的 historical 注释),当时的做法是挪版本号 + 人工提醒,
17
+ // 属于在地基上缝补。
18
+ //
19
+ // 现在改为本地等价实现,插件对宿主内核包零 import 依赖:升级宿主不再需要同步
20
+ // 任何版本号,也不再有双份模块。
21
+ //
22
+ // 校验规则与宿主保持一致(宿主 @deepseek-ai/dsh-storage 的 UNIT_NAME_RE 为
23
+ // /^[a-z][a-z0-9_]*$/;domain 名与表名都必须匹配)。宿主在 open() 时仍会二次
24
+ // 校验,这里的校验只是让配置错误在模块加载时就暴露。
25
+
26
+ const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/;
27
+
28
+ /**
29
+ * 声明一张表:宿主只要求 { valueSchema } 这一层包装。
30
+ * @param {import("zod").ZodTypeAny} schema - 行记录的 zod schema
31
+ */
32
+ export function domainTable(schema) {
33
+ return { valueSchema: schema };
34
+ }
35
+
36
+ /**
37
+ * 声明一个 domain:校验后原样返回 spec(与宿主 defineDomain 行为一致)。
38
+ * @param {object} spec - { name, version, tables, global?, layout?, compatibleVersions?, invalidRecords? }
39
+ */
40
+ export function defineDomain(spec) {
41
+ if (!UNIT_NAME_RE.test(spec.name)) {
42
+ throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`);
43
+ }
44
+ if (!Number.isInteger(spec.version) || spec.version < 0) {
45
+ throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`);
46
+ }
47
+ for (const compat of spec.compatibleVersions ?? []) {
48
+ if (!Number.isInteger(compat) || compat < 0 || compat >= spec.version) {
49
+ throw new Error(
50
+ `domain '${spec.name}' compatibleVersions entries must be non-negative integers below version ${spec.version}, got ${compat}`,
51
+ );
52
+ }
53
+ }
54
+ if (spec.layout !== void 0 && spec.layout !== "single" && spec.layout !== "per-record") {
55
+ throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${spec.layout}`);
56
+ }
57
+ if (spec.invalidRecords !== void 0 && spec.invalidRecords !== "backup-and-skip") {
58
+ throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present, got ${spec.invalidRecords}`);
59
+ }
60
+ for (const table of Object.keys(spec.tables)) {
61
+ if (!UNIT_NAME_RE.test(table)) {
62
+ throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`);
63
+ }
64
+ }
65
+ // 宿主的 global schema 不接受 null:null 是介质的「从未写入」哨兵,
66
+ // 存进去的 null 无法与「不存在」区分,重新打开会静默回落到 initial。
67
+ if (spec.global !== void 0 && spec.global.schema.safeParse(null).success) {
68
+ throw new Error(
69
+ `domain '${spec.name}' global schema must not accept null: null is the medium's "never written" sentinel`,
70
+ );
71
+ }
72
+ return spec;
73
+ }
@@ -1,8 +1,11 @@
1
1
  // skill-tag-plugin — storage domain schema (spec §12: 9 entity classes)
2
2
  //
3
- // Backed by @deepseek-ai/dsh-storage-domain defineDomain (evidence:
4
- // docs/evidence/extract-dsh-storage-domain.js — zod record schemas, KvTable
5
- // get/put/delete/update/entries, domain/changed events, caller closes).
3
+ // Spec 形状由本仓库 src/storage/domain-spec.js 本地构造,刻意不 import 宿主内核包
4
+ // @deepseek-ai/dsh-storage-domain(原因见该文件头部注释)。运行时通过
5
+ // ctx.storageDomain.open(skillTagDomain) 交给宿主打开。
6
+ //
7
+ // KvTable 能力(get/put/delete/update/entries、domain/changed 事件、caller closes)
8
+ // 来自宿主注入的 domain handle,见 docs/evidence/extract-dsh-storage-domain.js。
6
9
  //
7
10
  // Entity → table mapping (§12 table, one row per entity unless noted):
8
11
  // tags 全局标签 {tagId, name, createdAt} key=tagId
@@ -15,7 +18,7 @@
15
18
  // "不要求必须用四张独立数据表"; they must persist, which they do here.)
16
19
 
17
20
  import { z } from "zod";
18
- import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
21
+ import { defineDomain, domainTable } from "./domain-spec.js";
19
22
 
20
23
  // ── record schemas ──────────────────────────────────────────────────────────
21
24