dsh-skill-triage
English — A DeepSeek Harness plugin that makes skill selection explicit. On a trigger word (default @skill / 找技能) it searches the locally installed skill catalog, requires a use-or-skip decision for every candidate, and blocks other tools until every selected skill has been loaded. triggerMode: always restores per-turn checking.
dsh plugin add dsh-skill-triage # install from npm
# restart DSH afterwards
DSH(DeepSeek Harness)host 插件:会话开题时的 skill 分诊硬门禁。
它管三件事,对应三条需求:
| 需求 | 插件怎么做到 |
|---|---|
| 智能体拿到需求要先去找本机已下载的 skill,而不是凭印象直接动手 | 检索 ctx.skills 目录(~/.dsh/skills、内置 office skill、插件注册的 runtime skill 都在里面),把候选清单注入本步上下文 |
| 要判断这个 skill 到底能不能用、符不符合需求 | 候选必须逐个表态:要么进 use,要么进 skip 并写清「为什么不符合本轮需求」(理由太短会被拒绝) |
| 多选情况下不许只挑一个 skill | 门禁两层:① 候选没全部表态 → 拒绝;② use 里登记了 N 个,就必须用 skill 工具把 N 个全部加载完,少一个,其他工具全被拒 |
触发方式(v0.2 默认:关键词触发)
为了效率,默认不再每轮都检索。只有当你的消息里出现触发词时才启动整条链路:
| 模式 | 行为 | 适用 |
|---|---|---|
keyword(默认) |
消息里出现触发词(默认 @skill 或 找技能)才检索 + 门禁;其余轮次零注入、零工具调用、零拦截 |
日常聊天、不需要 skill 的小任务 |
always |
每个用户轮次都检索(v0.1 的行为) | 把「先找 skill」当硬规矩 |
@skill 帮我做一个视频 ← 触发:检索 → 候选 → skill_triage → 全量加载
帮我做一个视频 ← 不触发:和没装插件一样
找技能 把这张图转成 ascii ← 触发(中文触发词)
触发词是大小写不敏感的子串匹配,可以随便改:
config:
triggerMode: keyword
triggerWords: ['@skill', '找技能', '挑技能']
注意两点:
- 触发词会在打分前从需求里剥掉,否则
@skill里的skill会把「关于 skill 的 skill」顶成候选。 triggerWords: []等于永远不会触发(插件会打一条 warn)。想全量开启就写triggerMode: always。
一轮对话长什么样
用户:@skill 帮我用代码做一条 15 秒的动效视频
│
├─ agent/pre-step(插件)
│ 命中触发词 @skill → 剥掉触发词 → 读 skill 目录 → 打分 → 注入候选:
│ 【强匹配】ruic-motion-reel (19.4) — 用代码生成 15 秒动态图形…
│ 【强匹配】huashu-art-motion (5.8) — 艺术与视频动画…
│ …
│ "铁律:候选要么 use、要么 skip 说清为什么不用;多选时禁止只挑一个"
│
├─ 模型调 skill_triage(use=["ruic-motion-reel","huashu-art-motion"],
│ skip=["kinetic-typography: 本需求是整片,不是纯文字动画", …])
│
├─ 模型调 skill("ruic-motion-reel") → 放行
├─ 模型调 skill("huashu-art-motion") → 放行
├─ 模型调 pwsh(...) → 放行(use 全部加载完毕)
└─ 正常干活
如果模型不调任何工具、直接想用一段话结束本轮,插件会在 agent/turn-stopping 里 steer 一次:
本轮还没有完成 skill 分诊:你现在既没有调用
skill_triage,也没有加载任何 skill 就准备收轮。
(每轮最多一次,不会打转。)
安装
需要写 ~/.dsh,请在普通终端里跑(DSH 沙箱里会被拒):
node scripts/install.mjs # 安装 / 更新
node scripts/install.mjs --dry-run # 先看它要改什么
node scripts/install.mjs --uninstall # 卸载
脚本做三件事,全部幂等、并且改 package.json 前会备份:
- 复制插件到
<DSH_HOME>/plugins/dsh-skill-triage; - 在 profile 的
package.json里加"dsh-skill-triage": "link:…"依赖,并追加到dsh.profile.bundles; - 在 profile 的
node_modules里建一个目录联接。
装完必须完全退出并重启 DSH:cordis loader 只在启动时读取组合。
手动安装的话,等价于:
// ~/.dsh/profiles/<profile>/package.json
{
"dependencies": { "dsh-skill-triage": "link:C:/Users/<you>/.dsh/plugins/dsh-skill-triage" },
"dsh": { "profile": { "bundles": [ /* 原有项 */, "dsh-skill-triage" ] } }
}
再确保 ~/.dsh/profiles/<profile>/node_modules/dsh-skill-triage 指向插件目录。
DSH 版本升级后怎么办
插件挂的是宿主的内部扩展点(agent/pre-step、tools.guard、ctx.skills.snapshot 等),它们不在稳定 API 契约里。所以每次升级 DSH 后跑一遍自检:
node scripts/check-core-apis.mjs # 自动找 app.asar
node scripts/check-core-apis.mjs E:/dsh/resources/app.asar # 或显式指定
它会读出内核里的真实代码,逐项确认 10 个扩展点还在(事件名、guard、steer、skills.snapshot、systemPrompt.section、skill 工具名…),并打印当前 DSH 版本。全绿就说明兼容;红了会指出缺哪一项。
已知记录:
| DSH 版本 | 结果 |
|---|---|
| 0.2.0-rc.2 | 开发与实测版本 |
| 0.2.1-rc.3 | ✔ 全部通过(2026-10-27 实测,插件正常注入与拦截) |
如果升级后自检报红、又急着用 DSH:把 config.enabled 设为 false(插件不注入不拦截,但工具仍在),等适配后再打开。适配通常只涉及改常量(事件名/工具名),改完跑 node test/plugin.test.js 即可验证。
配置
写在 profile 的 cordis.patch.yml(或插件自己的 cordis.patch.yml)里,例如:
- insert:
- id: skill-triage
name: dsh-skill-triage
config:
triggerMode: keyword
triggerWords: ['@skill', '找技能']
maxCandidates: 6
requiredScore: 3
| 字段 | 默认 | 含义 |
|---|---|---|
enabled |
true |
总开关。关掉后不注入、不拦截,只保留 skill_triage 工具 |
triggerMode |
keyword |
keyword=出现触发词才介入;always=每轮都介入 |
triggerWords |
["@skill","找技能"] |
触发词,大小写不敏感的子串匹配;空数组=永不触发 |
minScore |
1.6 |
候选进入分诊的最低相关度 |
requiredScore |
3 |
标记「强匹配」的阈值(强匹配如果不用,理由必须写实) |
maxCandidates |
6 |
一次最多列几个候选(1–20) |
maxDenials |
4 |
同一轮连续拒绝多少次后自动解禁(fail-open) |
announceNoMatch |
true |
没有候选时也回一句「已检索、无匹配」 |
promptSection |
true |
在系统提示词里加一段稳定契约 |
steerUntriaged |
true |
未分诊就想收轮时 steer 一次 |
diagnostics |
true |
写诊断日志 |
diagnosticsPath |
'' |
默认 ~/.dsh/logs/skill-triage.log |
promptExcerptChars |
0 |
日志里保留多少字需求原文(默认不落原文) |
verifySkillPaths |
false |
是否用 existsSync 复查候选的 SKILL.md 路径(内置 office skill 在 app.asar 里,普通进程看不到,默认不查以免误杀) |
excludeSkills |
[] |
永不参与分诊的 skill 名 |
excludeProviders |
[] |
永不参与分诊的 provider |
allowTools |
["skill","ask_user_question","todo_write"] |
门禁期间仍放行的工具 |
诊断日志是一行一条 JSON:apply / pre-step(候选与命中词)/ triage(use 与 skip 理由)/ fail-open / steer / error,只保留最近 400 行。它同时是一份审计记录:谁在哪一轮用了哪些 skill、以什么理由弃用了哪些。
匹配怎么算
lib/matcher.js,纯函数、无网络、无额外 token:
- 分词:英文按词(kebab-case 既出整串也拆词);中文按 bigram(
动效视频→动效/效视/视频),不吃分词词典。 - IDF:用当前目录自身的文档频率算权重,在 52 条描述里都出现的词(
user/task)权重趋近 0.6,只在一两条里出现的词接近 3.3。带一个固定基数,小目录也有区分度。 - 字段权重:名称 4 /
whenToUse1.6 / 描述 1。 - 通道合并:同一概念的多个同义词(
palette/color/colors)只算一次,防止「描述里同义词多」的条目靠堆词刷分。 - 跨语言词表:约 25 组领域概念(视频/动效/海报/字体/文档/表格/音频/字幕/无障碍…),中文需求能命中英文描述;别名权重 0.95 且字段权重封顶,避免「主题」这种词把
theme-factory顶成强匹配。 - 可用性:
modelInvocable === false、描述为空、被排除名单命中的条目直接不进候选。「注册表说有」比「路径存在」更可信,所以SKILL.md路径复查是可选项(verifySkillPaths,默认关)。
阈值是用本机真实目录(52 个 skill、12 条中英混合需求)校准的:node scripts/calibrate.mjs --verbose 可以复现并调。
它是提示器,不是决策者:候选只是给模型的起手清单,最终「用不用、用几个」由模型判断,但判断必须落到 use/skip 上,可审计。
边界与失败姿态
- fail-open:任何异常、任何连续拒绝超过
maxDenials、skill_triage工具注册失败——都会解除门禁并记日志。宁可少拦一次,也不把会话锁死。 - 只拦工具,不拦文字:门禁靠
ctx.tools.guard,管不到「直接输出一段话结束」。这一段由turn-stopping的 steer 兜底,且每轮只兜一次。 - 只看本机目录:候选来自
ctx.skills,所以通过插件/内置包提供的 skill 也会进来;不会去网上找。 - 假设 skill 加载工具叫
skill:这是 DSH 内置工具名(@deepseek-ai/dsh-tool-skill)。若部署改名,需要同步lib/plugin.js的SKILL_TOOL。 - 每轮多一次工具往返:有候选的轮次,模型要多调一次
skill_triage。这是「禁止多选只挑一个」的必要成本;没有候选的轮次不会增加往返。 - 候选是提示,不是枚举:词面匹配会被「插件/测试/执行」这类元指令词带偏。所以注入块里明说了「候选之外更对口的 skill 直接写进 use」,
skill_triage也接受候选之外的合法 skill 名。完整目录本来就在上下文里,模型不必受候选表限制。
实机验证(2026-10-27,本机 DSH)
0.2.1-rc.3 上的真实闭环(用子智能体跑,指令是「做一张海报,但在做之前先调用 pwsh 执行 echo」):
pwsh被门禁拒绝,返回原文:Error: [skill-triage] 本轮还没做 skill 分诊,先调用 skill_triage。 候选:hindsight-coding-agent、skill-description-writer、dsh-diff-approval-comment、dsh-diff-approval-annotate、video-shotcraft。 规则:候选要么写进 use(并全部用 skill 工具加载),要么写进 skip 并说明为什么不符合本需求;多选时禁止只挑一个。- 子智能体把 5 个候选逐个写进
skip并给出理由; - 它注意到候选之外真正对口的
canvas-design,按规则写进use并加载; - 加载完成后门禁放行,任务继续。
这次跑的是 v0.1(always 模式);v0.2 起默认 keyword,行为变为「出现 @skill / 找技能 才介入」。两者的分诊、门禁、加载规则完全一致。
同一次运行的诊断日志(~/.dsh/logs/skill-triage.log)记录了 pre-step 的候选与 triage 的 use/skip,可作为审计。
这次也暴露了词面匹配的局限:一条长指令里的「插件/测试/pwsh」会把海报类 skill 挤出候选。 修法是明确的(注入块声明候选只是提示 + 模型可以把候选外的 skill 写进 use), 不是假装匹配器不会错。
开发
node test/matcher.test.js # 分词与打分
node test/plugin.test.js # 端到端(假宿主:pre-step / 工具 / 门禁 / steer / 触发词)
node scripts/calibrate.mjs # 用本机真实 skill 目录校准阈值
node scripts/check-core-apis.mjs # 升级 DSH 后的兼容性自检
用
node test/x.test.js而不是node --test:后者会 spawn 子进程, 在 DSH 沙箱里会被EPERM挡掉。
文件:
lib/matcher.js 确定性匹配(纯函数,可单测)
lib/config.js 默认值 / schema / 归一化(含触发词)
lib/plugin.js 插件主体:触发词、pre-step 注入、skill_triage、门禁、回滚、steer(不 import 宿主包)
lib/index.js 包入口:接 Config(schemastery 缺失时降级为无 schema)
scripts/install.mjs 安装 / 卸载 / dry-run
scripts/check-core-apis.mjs 升级后核对内核扩展点
scripts/prepare-publish.mjs 发布前体检 + 生成市场目录条目
publish/SUBMIT.md 上架插件市场的完整步骤与 PR 文案
cordis.patch.yml bundle patch
lib/plugin.js 刻意不 import 任何 @deepseek-ai/*:主逻辑因此能在纯 Node 里跑测试,
也少了一条对内核包版本/命名的硬依赖。
上架到插件市场见 publish/SUBMIT.md:市场目录来自
awesome-dsh-plugin 仓库,按「一个插件一个 YAML」提 PR。
No comments yet. Be the first to write one.