dsh-skill-tag
给 DSH 的技能(skill)打标签,然后用一条布尔表达式在「工作区 / 对话」两级
选出真正要下发给模型的技能子集。
装了几十上百个 skill 之后,真正的痛点不是"怎么分类",而是"这次对话只想让模型
看到哪几个"。全部下发会撑爆上下文、也会让路由互相打架;手工一条条开关又太累。
这个插件把这件事变成一条表达式:
pdf + docx # 只要处理文档的技能
office * plugin # 只要 office 包带来的、且是插件相关
(aegis + tdd) * !wip # 两个都要,且排除 wip
它能做什么
| 能力 | 说明 |
|---|
| 全局标签 | 建/改名/删除标签,标签之间无层级 |
| 标签组模板 | 把表达式存成模板(前端组、只留本机技能…),可复用、可求值 |
| 布尔表达式 | +=OR、*=AND、* 优先、支持括号;改标签名会自动改写所有引用它的表达式 |
| 工作区级筛选 | 每个工作区一套条件,作为该工作区新建对话的初始模板;改工作区不影响已存在的对话 |
| 对话级筛选 | 每个对话有自己的快照:新建时继承一次,之后冻结;未配置过的对话 = 空条件 = 全部技能;可一键「继承工作区配置」重新拉取 |
| 禁用 / 手动添加 | 在标签命中之外额外放行某个技能,或强行屏蔽某个(即使它命中) |
| 技能 ↔ 标签双向编辑 | 「tag 联系 skill」和「skill 联系 tag」两个方向都能改 |
| 打开技能所在文件夹 | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
| 自带一个 skill | 安装后你的 agent 会获得 tagging-skills,能照着文档自己给技能打标签 |
界面预览
全部是真实实例截图(105 个技能 / 48 个标签 / 一个开了筛选的工作区),没有 mock。
图片源文件在 images/(不进 npm 包,README 里用 jsDelivr 绝对地址,GitHub 与 npm 都能显示;
之所以不走 raw.githubusercontent.com,是因为部分网络到该域名的 IPv6 不通,图片会间歇性加载失败)。
图 1 · 入口:会话行「…」→「管理标签」
会话行菜单里的管理标签入口
对话级的配置就挂在会话行的「…」菜单最下面,不用去翻设置页。
图 2 · 入口:命令面板里的 /skill-tag-manage
命令面板中的管理对话标签命令
在输入框敲 / 也能直接开「管理对话标签」——同一个界面,省一次点菜单。
图 3 · 打标签:设置 →「Skill 标签」
Skill 标签管理页:新建标签、按来源分组的标签列表
三个页签分工明确:tag 联系 skill(一个标签包含哪些技能)、skill 联系 tag
(一个技能拥有哪些标签)、编辑标签组(写表达式存模板)。
标签按来源分组(来自:aegis 23 个 Skill),每条带 tagId 和「全局删除」。
图 4 · 一个技能看它身上有哪些标签
skill 联系 tag:左列技能、右列标签勾选
左列 技能 (105) 每行标着它有几个标签;右边勾选即时保存,不用点确认。
右列会把该技能已有的标签排在列表最前面(并保持它们原来的相对顺序),所以不用滚动
去列表里找;顶部搜索框过滤之后,"已拥有优先"依然成立。
这是配条件前最常开的一页:先看清楚"这个技能到底被哪些标签命中"。
图 5 · 工作区「管理标签」:左写条件,右就是下发名单
工作区管理标签双列界面
左列是标签条件,下面实时显示解析结果和状态「有条件筛选(严格子集)」;
右列标题是 技能 (54) · 模型可见 48:前一个数是命中表达式的全部技能,
后一个数是其中真正下发给模型的那些。两者不等时标题下方会多出橙色说明,
列出差额的去向——X 个只能 /名字 手动调用(SKILL.md 里写了 disable-model-invocation: true)
和 Y 个宿主没注册(扫描到了但宿主没装),对应行也会带 仅手动 / 未注册 徽标并置灰。
还有第三种状态 暂不可判定:这一趟没拿到完整的宿主视角(比如该会话没在运行,
宿主的 skill registry 只给了全局层),此时"没返回"不等于"没注册",这些技能带灰色虚线徽标、
不置灰,也不计入 模型可见 N。这三种状态的区别与成因见下一节。
每行都可以「查看 tag」「禁用」,📁 直接打开技能所在文件夹。列表顶部的搜索框只过滤显示
(标题会变成 技能 (匹配/总数)),不改动实际下发的技能集合。底部还能「+ 精细化添加技能」
「查看被禁用的技能」。
图 6 · 对话「管理标签(对话)」:可以有自己的一份
对话级管理标签界面与继承来源
同一个工作区下的某个对话想单独收窄,就在这里改:它会记下继承来源与时间
(改动不影响工作区),左下角一键「继承工作区配置」覆盖回来。
技能注册的三种状态:已注册 / 未注册 / 暂不可判定
界面右列标题 技能 (全部) · 模型可见 N 里的 N,只数已被宿主确认注册的技能。
而"未注册"和"暂不可判定"是两件完全不同的事,别把它们读成一句:
| 状态 | 判据 | 界面表现 | 是否计入 模型可见 N |
|---|
| 已注册 | 这一趟宿主快照里返回了这个名字 | 正常显示 | ✅ 计入 |
| 未注册 | 宿主视角完整,但没返回这个名字 | 置灰 + 橙色「未注册」 | ❌ 不计入 |
| 暂不可判定 | 这次没取到完整的宿主视角 | 灰色虚线徽标,不置灰 | ❌ 不计入 |
「宿主快照」是什么
插件判断一个技能"模型能不能看到",是去问宿主:"这条技能你注册了吗?" ——
宿主对这个问题的一次完整回答,就是一次宿主快照(宿主 ctx.skills.snapshot()
返回的技能清单)。没取到快照=这一问没有得到一个可信的完整回答,
于是那批技能既不该标"已注册"(没证据),也不该标"未注册"(更没有证据)。
为什么会取不到完整快照
宿主的技能注册表是分层的:global 层 + 按 agent / preset 展开的层。
不带 scope 去问时,宿主只回 global 层。而覆盖用户级技能根
(~/.dsh/skills、~/.agents/skills)的那些 provider 恰好注册在 agent 层里 ——
一旦 scope 传不下去,这些名字就整段不在回答里,"没返回"于是被误当成"没注册"。
这是 0.1.15 修掉的缺陷(issue #1):此前同一份本机文件,在有 live agent 的对话里是
"已注册",在没在跑的对话里就变成"宿主没注册,模型侧根本不存在"——仅仅换一个对话,
结论就翻转。
可能的触发原因,按实际出现频率排序:
- 该会话没有在运行——历史对话、已关闭的标签页、或刚切过来还没起 agent。
agents.list() 里找不到它,scope 传不下去,宿主只给 global 层。这时列表顶部会
标注「该会话未运行,以下为全局视角」。
- 会话 ID 对不上——浮层传的是
session-<uuid>,宿主 agent 暴露的 id 若是另一个
字符串(宿主侧依次取 session.id / session.header.id / agent.id),同样等于取不到 agent。
- 宿主自己报
complete: false——宿主明确说这一趟没收集全(某个 provider 还在加载、
某个根扫描失败)。插件直接采用宿主的结论,整批降级为不可判定。
- 快照抛错或并发守卫被占——
snapshotSkills() 返回 null,插件退回纯文件系统自扫描;
这时它更没资格断言"宿主没注册"。
- 宿主确实没注册——这种情况"暂不可判定"是保守过头了。但只要拿到完整视角,
插件就会给出明确的「未注册」,不会一直挂着。
想看确定结论怎么办
- 把对话切到正在运行的那个会话再看——它能拿到完整视角,用户级技能会恢复成"已注册"。
- 设置页那种不带
conversationId 的口径本身就是全局视角,出现"暂不可判定"是预期行为。
- 视角不完整时,
POST /skill-tag/api/skills/purge-stale 会直接返回 409 INCOMPLETE_VIEW
而拒绝执行——因为它要靠"未注册"这个结论去删关联记录,视角不可信时删了就是误删。
安装
这是一个 DSH(DeepSeek Harness)插件,装进 profile 后随宿主启动加载。
一条命令
dsh plugin --profile <your-profile> add dsh-skill-tag
也可以不走 npm,直接从 GitHub 装:
dsh plugin --profile <your-profile> add github:aawef/dsh-skill-manager-tags
dsh plugin 本质是把参数转发给 profile 目录里的 pnpm,它不会替你启用插件 ——
装完还要在 profile 的 package.json 的 dsh.profile.bundles 数组里加上 "dsh-skill-tag":
{
"dsh": {
"profile": {
"bundles": [ "...", "dsh-skill-tag" ]
}
}
}
重启 DSH 后生效(设置页会出现「Skill 标签」一项)。
官方 DSH 桌面端的 desktop profile 归应用独占,命令行改不动它 ——
桌面端请在 Settings → Plugins 或插件市场里安装。
依赖 Node ≥ 20;需要 DSH 0.2.0-rc.1 及以上(见 engines.dsh)。
宿主的 @deepseek-ai/cordis 声明为 peerDependency,由 DSH 本体提供,不要自己安装。
存储域 spec 由本插件 src/storage/domain-spec.js 本地构造(等价于宿主的 defineDomain /
domainTable),运行时通过 ctx.storageDomain.open() 交给宿主打开,不 import 任何宿主内核包。
这样 profile 里不会出现与宿主同名的第二份模块副本:早期版本曾 import
@deepseek-ai/dsh-storage-domain,宿主升级(如 0.2.1-rc.2 → rc.3)时那份旧副本会被判定
不兼容并拒绝加载,导致存储域整体不启动、会话与工作区列表全空(详见 CHANGELOG 2026-10-24)。
本地开发式安装
运行时加载的是 profile 里 node_modules/<插件名> 那一份,不是你的源码目录。两种接法:
- junction(推荐):让
~/.dsh/profiles/web/my-plugins/<插件名> 指向你的源码目录,
pnpm 会把它硬链接进 node_modules —— 三个路径是同一个 inode、同一份文件,
改完内容立刻生效,不需要复制;只有新增/删除文件才要在 profile 里重跑一次 pnpm install。
- 物理拷贝:如果当初是手动
cp 进去的,那就是两份独立文件,改完必须再 cp 一次并校验哈希:
S=/path/to/your/dsh-skill-tag
D=~/.dsh/profiles/<profile>/node_modules/dsh-skill-tag
cp "$S/lib/client.js" "$D/lib/client.js"
sha256sum "$S/lib/client.js" "$D/lib/client.js" | cut -c1-16
生效规则(很重要,搞混了会以为"改了没用"):
| 文件 | 改完之后 |
|---|
lib/client.js | 刷新页面即可(宿主 no-cache 加载) |
lib/index.js、src/**、cordis.patch.yml | 重启桌面进程 |
怎么用
1. 打标签
设置 →「Skill 标签」,三个按钮:
- tag 联系 skill —— 选一个标签,勾它包含哪些技能
- skill 联系 tag —— 选一个技能,勾它拥有哪些标签
- 编辑标签组 —— 写表达式、存成模板
界面长什么样见上方「图 3」「图 4」。
也可以让 agent 自己做:安装后 tagging-skills 这个 skill 会出现在技能列表里,
里面是完整的推导法 + API 调用纪律。不要照抄文档里的标签名(aegis、office-skills
只是某一台机器上的实例),按自己机器的技能清单推导。
或者直接跑一键脚本(先 dry-run):
node tools/auto-tag.mjs http://127.0.0.1:<PORT> # 只看计划,不落地
node tools/auto-tag.mjs http://127.0.0.1:<PORT> --apply # 落地来源轴
node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴一起
脚本幂等,重跑应输出 tagsCreated: 0。
2. 给工作区/对话配条件
- 工作区:侧栏工作区行的「…」菜单 →「管理标签」
- 对话:会话行的「…」菜单(图 1),或聊天框左下角「+」→「管理对话标签」(图 2)
左列写标签条件表达式,右列是命中结果(可手动添加 / 禁用 / 恢复)。改完立刻生效:
该对话下发给模型的技能列表就是右列里模型可见的那一份(标题的第二段数字)。
两张双列界面见「图 5」「图 6」。
3. 表达式语法
| 语法 | 含义 |
|---|
A + B | OR |
A * B | AND |
(A + B) * C | 括号;* 优先级高于 + |
"my tag" | 标签名含空格或 + * ( ) 时加引号(所以起名请用 kebab-case) |
| 空串 | 无条件 = 全部技能(合法的"不筛选") |
表达式会被编译成 DNF({orTags, andGroups})再交给匹配引擎。
标签改名时,所有引用它的表达式会被按 token 原位改写,不会失效。
4. 路由型技能(router)别被拆散
有些技能的职责就是「帮你挑别的技能」——description 里出现 Use when the user asks…
Router over the skills… 这类措辞,还常带有 skill-filesystem 之类的下挂资源。
插件只按名字屏蔽,从不改任何 SKILL.md 的内容,所以路由器自己的提示词一个字都不会变。
会坏的是依赖关系:路由器可见、而它指向的技能被屏蔽时,模型照着提示去加载只会拿到
skill "X" is not available for model invocation —— 这一轮路由白跑,还可能让模型反复试。
所以配条件时请把路由器和它的簇当成一个整体(用簇标签一次圈住,别按领域标签零散地挑):
| 你想要的 | 表达式 |
|---|
| 整个簇(路由器 + 7 个 office 技能) | routes:office |
| 只要路由器本身,不要成员 | router * routes:office |
| 只要成员,不要路由器(自己挑,不需要分发提示) | routes:office * !router |
插件运行时不认识"谁是路由器"(router 只是标签约定),这一层耦合靠表达式自己保证完整。
选完可以用 POST /skill-tag/api/workspaces/skills 回读 candidates,确认路由器的簇成员没被拆散。
HTTP API
挂在 /skill-tag/api 前缀下。所有路由同时接受 GET 和 POST
(q(k) = body[k] ?? url.searchParams.get(k)),所以浏览器直接敲 URL 也能调试。
响应一律是 JSON;出错是 {ok:false, error, code}。
| 路由 | 方法 | 主要参数 | 说明 |
|---|
/state | GET | — | 全量快照:tags / skillLinks / templates / workspaces / conversations |
/skills | GET | cwd | 可打标签的技能清单(带 path)。带 cwd 才能扫到项目级 .dsh/skills |
/skills/tags | GET/POST | skillName, tagIds | 读/写一个技能拥有的标签。写是整体覆盖,见下方警告 |
/skills/reveal | POST | skillName 或 path | 拉起文件管理器定位该技能 |
/tags | POST | tagId, name | 建标签(同名会报 already exists) |
/tags/rename | POST | tagId, name | 改名并同步改写所有表达式 |
/tags/link | POST | skillId, tagIds | 关联 |
/tags/unlink | POST | skillId | 解除关联 |
/tags/delete | POST | tagId | 全局删除,返回 affectedWorkspaces |
/templates/validate | GET/POST | expression | 校验 → {ok, errors, normalized, dnf, config} |
/templates/evaluate | GET/POST | templateId | 求值(要 templateId,不是 expression) |
/templates/expand | GET/POST | templateId | 展开 |
/templates /templates/update /templates/delete | POST | — | 模板 CRUD |
/workspaces/expression | POST | workspaceId, |
警告:/skills/tags 的写是整体覆盖。写之前必须先 GET 读回旧 tagIds
再合并,否则会清掉已有标签。这是最容易踩的坑。
目录结构
lib/index.js 宿主侧入口(ESM):注册 filter provider / storage domain / HTTP API / 自带 skill
lib/client.js 浏览器侧界面(__ModuleLoader__ 工厂,无构建步骤)
src/domain/
expr.js 表达式引擎:tokenize / parse / toDnf / dnfToConfig / validateExpression
match.js 匹配:matches / candidatesFor
origin.js 来源轴推导(从 path 推出这个技能是哪个包带来的)
conv.js 对话四区状态(加载/豁免/未加载/屏蔽)
workspace.js 工作区视图
src/api.js HTTP 路由
src/service.js 服务层(CRUD + 改名同步 + 迁移)
src/storage/ storage domain schema + repository
src/reveal.js 拉起系统文件管理器
skills/tagging-skills/ 随插件注册给宿主的一个 skill
tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
tests/ node:test,194 项
docs/ 设计文档(见下)
images/ README 用的界面截图(不进 npm 包)
文档
开发
npm install
npm test # 194 项
测试分两类,都在 tests/ 下:
*.test.mjs —— 单元/领域测试,被 node --test 自动收集
__*.mjs —— 手工脚本:HTTP 全链路冒烟(__api-smoke)、用迷你 React 运行时
执行真实组件并断言渲染结构(__page-render 等)
两个 glob 都要,npm test 已经配好;只跑 tests/**/*.test.mjs 会漏掉 46 个用例。
界面预览可以在没有浏览器扩展时生成静态 HTML:
node docs/preview/render-preview.mjs # → docs/preview/标签组配置-preview.html
(产物已在 .gitignore 里排除。)
改插件时的几条硬约束
这几条都是踩过的坑,源码注释里也写了:
- 宿主服务是声明式注入的:没写进
export const inject 的服务,ctx.<service>
读属性会直接抛 cannot get property "x" without inject。而且
ctx.x ?? ctx.get("x") 这种兜底照样抛 —— 左侧求值时就炸了。只能 ctx.get() + try/catch。
- 注册给宿主的回调里抛异常 = host fatal(整个桌面进程挂掉)。
domain/changed 监听器、provider 的 list/get、apply() 同步栈,全部 self-contain。
- 新增 HTTP 路由必须重启桌面进程,否则新路由 404 —— 这个 404 极易被误判成
"路由名写错了"。
ctx.effect(() => fn()) 的 thunk 立刻执行,它的返回值才是 dispose。
写成 ctx.effect(() => domain.close()) 会在装配阶段就把 domain 关掉。
许可证
MIT — 见 LICENSE。