dsh-skill-router
Manifest valid★ 1On-demand skill tools for DeepSeek Harness: registers skill_search / skill_load / skill_ref so the model searches a staged skill library first and loads a skill body only when it needs it, keeping the
dsh-skill-router
English | 中文
Agent 驱动的技能发现与按需加载。 给 DeepSeek Harness 的 Host 插件:新增 skill_search / skill_load / skill_ref 三个工具,让 agent 在需要时自己去技能库里检索并加载技能。
dsh plugin --profile web add github:ZiYuan258/dsh-skill-router
装之前认准两件事。
一、owner 必须是
ZiYuan258。 这个名字下并存 8 个 GitHub 仓库(我是其中最新的一个)。其中若干走自动注入路线:在模型回答前读用户消息,命中就把技能正文塞进提示词。二、本插件不做自动注入——这是有意的。 它把候选摆出来,加载哪个由 agent 自己决定;
README底部有一张同名仓库的对照表。
你只有十几个技能?这个插件不适合你。 一次工作通常只用到几个技能,所以技能少的时候,让它们常驻目录反而更省——目录本来就是 DSH 的原生机制。 这个插件解决的是另一个问题:技能多到装不进目录。
你需要它吗
判断标准只有两条:库有多大,以及每个任务用几个。
| 你的情况 | 怎么办 |
|---|---|
| 技能 < ~30 个 | 不需要本插件。 全部常驻,目录成本可控,原生 skill 工具直接能用 |
| 技能几十到上百,且每个任务只用几个 | 本插件的典型场景。 常驻留高频的十来个,其余进库按需检索 |
| 有多个上游仓库、上千个技能 | 最需要。 全量常驻不可行(1000 条 ≈ 每轮 15 万字符),但你又不想丢掉其中任何一个 |
| 只想"技能自动触发"、库其实很小 | 不需要。 那是 pre-step 路由插件的领域,不是这个 |
一句话:它是为"库大、但每次只用几个"设计的。如果你把常用技能都常驻了,它的收益就是负的——因为你多付了工具 schema 的钱。
它解决什么技术问题
DSH 把会话技能目录注入到每一次模型请求里(dsh-tool-skill 在 agent/pre-step 发出一条件 source.kind='skill-catalog' 的 user message)。所以:
- 常驻数量的成本是持续性的:每多一个常驻技能,每一轮都要付它的名字与描述。1000 个技能 ≈ 每轮 150k 字符,与任务是否相关无关。
- 但目录同时是模型唯一的技能入口:内置
skill工具通过ctx.skills.list()解析名字,只认文件系统 provider 扫到的根目录。这些根之外的东西对模型完全不存在——一个放着 1000+ 技能的库,等于没有。
于是只有两个选项:要么全塞进目录(每轮都贵),要么全放在库外(模型够不着)。本插件提供第三个:库留在库外,agent 需要时自己检索、自己加载。代价从"每轮固定"变成"用到才付"。
成本(实测,不是估计)
工具驱动不是免费的,它有两笔开销:
| 开销 | 实测 | 性质 |
|---|---|---|
| 常驻工具 schema | 3,603 B ≈ 1,001 token/轮 | 固定,不随库增长——这是与目录注入最本质的区别 |
| 一次检索往返 | 1 次 tool call + 约 1,533 B ≈ 426 token(limit=12) | 按需,与"目录已注入、模型直接挑"相比多出的一步 |
对照:常驻 28 个技能实测 1,541 token/轮。把 1,541 + 3,603 B 换成"只留六个常驻 + 工具",净额才划算——所以库小的时候别用(见上一节)。
省往返的两个动作:limit 调小(或 names_only: true,返回体积约降 60%)、名字已知时直接 skill_load,跳过检索。
工作原理
数据流
用户消息
│
├─ 常驻技能(DSH 原生) ← 每轮注入目录,自动触发
│
└─ 库内技能(本插件)
模型判断需要专门知识
│
├─ skill_search ← 关键词检索索引(不读技能正文)
│ 返回:名字 / 仓库 / copies / 绝对路径
│
├─ skill_load ← 只加载选中的那几个,包成 <skill_content> + base directory
│
└─ skill_ref ← 只见真需要某个 references/ 或 scripts/ 文件时,单独读它
关键点:检索与加载分离。skill_search 只查索引,从不读技能正文;skill_load 只读你点名的。所以"搜 12 条"的代价是元数据,"加载 2 个"的代价才是正文。
索引是契约,不是缓存
插件读 <工作区>/.skill-src/skill-index.tsv——制表符分隔,一行一个技能:
| 列 | 必填 | 说明 |
|---|---|---|
repo | 是 | 上游目录名,用于消歧与过滤 |
relpath | 是 | 相对仓库根的子路径,与 repo 拼出 SKILL.md 的位置 |
name | 是 | 技能名,skill_load 的键 |
description | 是 | 检索语料,也是给模型看的说明 |
files | 否 | 该技能目录的文件数 |
KB | 否 | 体积 |
whenToUse | 否 | 触发措辞,以高于描述的权重参与检索(实测参考库 1025 个 SKILL.md 里 0 个带它——"没有"是常态) |
设计取舍:为什么是 TSV 而不是 YAML/JSON——索引由机器生成,TSV 最不容易出结构歧义;而 YAML 恰好会被这次会话咬过(frontmatter 里的块标量 >-/|- 会带着标记漏进描述)。解析器手写(插件零导入,不能用 node:path/CSV 库),按 CSV 规则处理引号,表头按形状识别而不认字面量,所以加列不会破坏旧文件:6 列索引照常工作,缺列读作空字符串。
索引位置从会话工作目录往上最多找 8 层,没有任何盘符写死。找不到时工具会说明并列出找过哪里。
检索:加权 AND + 诚实降级
评分是逐关键词累加,权重顺序体现信息密度:
| 命中字段 | 权重 | 理由 |
|---|---|---|
name | +100 | 技能名是最强信号 |
whenToUse | +40 | 它按定义就是触发措辞 |
description | +24 | 描述性散文 |
path(repo/relpath) | +6 | 弱信号,但能救"按仓库找"的查询 |
| 名字完全相等 | +400 | 精确命中断层领先 |
排序键依次是:是否全词命中 → 命中词数 → 总分 → 名字命中数 → 名字长度 → 字典序(确定性,同样输入永远同样顺序)。
默认是严格 AND(每个关键词都要命中)。当 AND 结果为空且关键词多于一个时,才尝试部分匹配:候选必须命中"除一个以外的全部"关键词,否则宁可回答"没找到",并置 fallback: "weak"。命中的部分匹配带 matchCount、结果里 strict: 0,模型看到的头部也写成"0 exact match(es); N partial match(es)"——近似命中永远不会被伪装成真命中;单个关键词不降级(没有可降级的余地)。
这条阈值是实测逼出来的:在 1026 行的参考库上,
test setup config helper原本返回 1026 条,绝大多数只共享一个常见词;收紧后是 7 条。同一实验里make a movie会因make/a命中全库,所以a、the、make、use这类无区分度的词在分词阶段就被丢弃(tokenize里的STOP_WORDS)。
explain: true 时可看到分数构成,诊断"为什么搜不到":
- beta-gadgets [beta-skills]
why: widgets: -; beta: +130 (name+description+path)
重名:确定性优先于猜测
由多个上游拼起来的库经常同名多份——有的仓库把每个技能同时放在 skills/、plugins/<name>/skills/ 和 antigravity/skills/ 下(参考库里 test-driven-development 有 5 份)。
处理方式:skill_search 报 copies 把歧义暴露出来;skill_load 接受 repo 消歧;不给提示时按确定性规则选(路径最浅者胜,即 skills/<name> 优先于 plugins/<x>/skills/<name>);repo 过滤没命中时列出真正拥有它的仓库,而不是悄悄回退到别的仓库。
查找顺序与上限
skill_load 先库(.skill-src)后常驻目录(ctx.skills),source 字段标明最终用了哪边。上限:单次 8 个名字、单个技能正文 120,000 字符、附带文件清单 8 项。正文超限会被截断并明确上报(truncated: true + 原始长度 + 可读全文的路径),不静默丢弃。
路径包含性
skill_ref 在任何 I/O 之前对归一化路径做包含性校验,../ 到不了文件系统。已知局限:该校验是词法的、不感知符号链接(参考库符号链接数为 0,故目前是理论风险)。resolvePath 已导出并直接单测——只通过工具间接验证的安全规则,等于一条可能悄悄失效的规则。
安装
需要 DSH 与一个 profile。dsh.engines.dsh 声明 >=0.1.5-rc.1——那是已验证可用的版本,不是"需要这么新":本插件只用到 ctx.tools.register 与 ctx.fs.* 这一小组 API,无事件钩子、无 import;更早的版本我没有验证过,不想过度声明兼容性。ctx.get / ctx.effect / ctx.skills 全部是可选的,缺失时降级而不是崩溃(由 test/minimal-host.mjs 钉住)。
# 从 git 安装(推荐)
dsh plugin --profile <profile> add github:ZiYuan258/dsh-skill-router
# 或用下载的 release tarball / 本地检出
dsh plugin --profile <profile> add /absolute/path/to/dsh-skill-router
重启一次 DSH,然后在工具列表里确认三个工具都在。卸载:
dsh plugin --profile <profile> remove dsh-skill-router
不发布到 registry。 DSH 只要能把包装上就组合得出插件,git URL 或本地路径已经足够—— 所以本包
private: true。离线或隔离环境的安装包挂在 GitHub releases 上。
⚠️ 名字先认准:dsh-skill-router 有多个同名仓库
GitHub 上这个名字下并存 8 个仓库(本仓库是其中最新的一个,创建于 2026-09-23),装错就是装了另一个插件:
| 本仓库 | 另一类同名插件 | |
|---|---|---|
| 安装命令 | github:ZiYuan258/dsh-skill-router | github:lau4tin1/dsh-skill-router(占着 npm 裸名 dsh-skill-router) |
| 机制 | 工具驱动:模型自己调 skill_search / skill_load / skill_ref | 自动路由 + 自动注入:回答前用本地模型给任务与技能做向量,按分差挑出相关的,把全文塞进提示词 |
| 要解决的问题 | 库里的技能对模型不可见 | 模型该用技能时没用(注意力漏掉) |
| 谁决定用哪个 | agent:它看完候选自己选,可以一个都不选 | 插件:路由命中即注入 |
| 依赖 | 零依赖、零导入 | 各自不同,有的需要本地模型或 embedding |
两者立场相反,这是本插件有意的设计,不是缺陷。 见下方「任务感知的技能发现」一节:那一层只做发现,且当前只测量不注入。
同类里还有 MJorgin/dsh-skill-router(rule-first pre-step 路由)、Phantomcyber-ai/dsh-skill-router(intent-level 自动路由)等;也有若干只是占名或未完成的仓库。装之前核对 owner 是 ZiYuan258。
若你的工具报告本仓库"不可访问",先分清是哪种:GitHub 对未认证 API 限流 60 次/小时, 超限返回
403 API rate limit exceeded,而网页与 raw 文件仍然正常——dsh plugin add走的正是后者。
使用方法
零、装完就能用(不需要先准备任何东西)
插件自带一个入门技能库(5 个,覆盖"先搜再动手 / 用证据说话 / 带着证据调试 / 先定范围 / 把结果讲清楚")。你没有自己的库时,它就是这个库——重启 DSH 之后 skill_search 立刻有东西可搜:
skill_search "debug"
→ debug-with-evidence ← 来自插件自带的入门库
返回值里会带 starterLibrary: true,以及 library: 指向插件包内的路径。看到这个标记就说明你读的是入门库,不是自己的库——这两种情况的诊断方向完全不同,所以它必须能分辨。
入门技能不会被塞进常驻目录。 它们在插件包的
resources/starter-skills/下,走的是skill_search/skill_load这条路,一个字节都不进每轮的模型目录。把入门技能放进.dsh/skills/会立刻让它们每轮进上下文——那正好毁掉这个插件的全部意义。test/starter-library.mjs里有一条断言专门守这件事。
自带 5 个而不是 1000 个是有意的:这个插件解决的问题就是"大库不要常驻"。往包里塞一个大库会同时带来包体、更新、许可证与版本绑定四类问题。想要真正的能力覆盖,就把你自己的库接上去(下面第一节)。
一、把库放到哪里
插件从会话工作目录往上最多 8 层找 .skill-src/skill-index.tsv。所以惯例是把库放在工作区根:
<你的工作区>\ ← 你在这里开 DSH 会话
├─ .skill-src\ ← 库的根,插件找的就是这个名字
│ ├─ skill-index.tsv ← 索引(下一步生成)
│ ├─ remotion-skills\ ← 一个上游仓库 = 一个顶层目录
│ │ └─ skills\remotion-create\
│ │ └─ SKILL.md
│ └─ trailofbits-skills\
│ └─ plugins\semgrep\skills\semgrep\
│ └─ SKILL.md
├─ .dsh\skills\ ← DSH 常驻区(插件不碰这里)
└─ AGENTS.md
两条硬性约定:
- 目录名必须是
.skill-src。 前导点让它对 DSH 的 skill 扫描器不可见——这正是"库不占目录成本"的机制。若命名为skills/或放进.dsh/skills/,DSH 会把里面的技能全部注入每轮上下文,插件就白装了。 - 它必须在会话 cwd 的同级或上级。 若你的 DSH 会话开在
<你的工作区>\projects\foo,插件会向上找到<你的工作区>\.skill-src——这没问题。 - 内部结构随意。 插件只要求"某层的目录名等于
repo列、其下路径等于relpath列、最后是SKILL.md"。上游仓库那种skills/、plugins/<name>/skills/、antigravity/skills/混排的布局原样放着即可。
二、库在别处(别的盘 / 别的目录)
插件按 cwd/.skill-src 找,所以库不在工作区里时,在工作区放一个目录链接指过去即可。已实测可用(Windows junction / POSIX symlink,node tools/check-link-support.mjs 可自行复验):
# Windows:junction 不需要管理员权限
New-Item -ItemType Junction -Path "D:\work\.skill-src" -Target "E:\skills-archive"
# Linux / macOS
ln -s /mnt/skills-archive "/home/me/work/.skill-src"
链接下 skill_search 与 skill_load 都正常。已知局限:返回的 path 是链接下的路径,不是真实路径——排查问题时若需要真身,用 dir 或 ls -l 看链接目标。
不要用 DSH 的
customSkillDirs来指向这个库。那个配置的作用是把技能注册成常驻,会立刻让全部技能进入每轮目录——与这个插件的目标正好相反。
三、生成索引
索引由谁生成都行——只要能产出那几列。参考实现(PowerShell,适用于把多个上游仓库检出一到同一目录):
node tools/build-index.mjs <你的库根> # 生成 / 覆盖 <库根>/skill-index.tsv
node tools/build-index.mjs <你的库根> --check # 只报告是否与磁盘一致(不一致则退出码 1)
node tools/doctor.mjs --root <你的库根> # 体检:路径 / 技能数 / 过期 / 重名 / 索引状态
它零依赖、跨平台,做四件事:递归找 SKILL.md、解析 YAML frontmatter、按仓库根算出 relpath(不是库根——多一层就全部找不到文件,这个坑它第一版踩过)、用真正的 TSV 转义写出(描述含制表符/引号/换行不会坏列)。whenToUse 只在至少一行有时才写第 7 列。
这次生成之后就不必再手工碰索引了。 库内容变了重跑一次即可;忘了重跑的话 doctor 会告诉你(它会比对"磁盘上有"与"索引里有",两个方向都报)。
为什么不再推荐"自己写生成器"(原来那段 PowerShell 参考实现)
原来这里是一段要你复制、自己改 $root 的 PowerShell。它能用,但有两个问题:
- 索引格式是插件的内部数据模型,不该是安装流程的一部分;
- 它只扫一层(
$root\<repo>\**)、不产出whenToUse、漏掉没有 frontmatter 的技能——写这段时的参考库里有 3 个这样的技能(微软 monorepo 里),于是它们永远搜不到。实测:用build-index.mjs扫同一个库得到 1028 行,而那份参考实现产出的索引只有 1025 条。
索引仍然是一份公开契约:任何能产出那 7 列的东西都可以替代 build-index.mjs。契约在第二节上方那张表里。
四、验证装好了
安装命令见上一章;重启 DSH 后(插件行只在生成新宿主进程时组合)按顺序验证:
- 工具在不在:问 agent"你有哪些 skill 相关的工具",应当看到
skill_search/skill_load/skill_ref。 - 索引找没找到:让 agent 用
skill_search查一个你库里确实有的名字。返回里带library字段,那是它实际使用的库根——核对这个路径,这是"找错目录"最快的诊断点。 - 加载通不通:让 agent
skill_load其中一个。返回的source应为library(若是resident,说明命中的是常驻区而不是库)。 - 库没被塞进目录:确认新会话的技能目录没有因为这次安装而变长。库在
.skill-src下就不该出现。
library 与 error 两个字段能区分三种失败:索引不存在(报错里会列出找过的路径)、
索引在但不是这个库(library 路径不对)、索引格式坏了(error 非空)。
explain: true 还能看出检索为什么没命中。
手边核对索引本身:
# 表头(应为 6 或 7 列)与行数
Get-Content "D:\work\.skill-src\skill-index.tsv" -TotalCount 1
(Import-Csv "D:\work\.skill-src\skill-index.tsv" -Delimiter "`t").Count
五、日常怎么用
你不需要记住任何技能名。 这是这个插件的设计目的——选择由 agent 做:
- 直接描述任务即可("帮我用 Remotion 做个视频")。工具描述里写明了"非平凡任务开始前先搜一次",agent 会自己检索。
- 想知道库里有什么,可以问:"你的技能库里有没有跟 X 相关的?" agent 会
skill_search把结果给你看。 - 想让它自动触发某个技能(不必每次提醒),那才需要把它装进常驻区:
代价是它开始进入每轮目录——也就是你付钱买"自动触发"。& "D:\work\.skill-src\install-more.ps1" -Name remotion-create
六、库变了怎么办
| 你做了什么 | 要做什么 |
|---|---|
| 新增/删除/重命名了技能目录 | 重跑索引生成,否则检索的是过期元数据 |
改了某个 SKILL.md 的描述 | 同上(描述是检索语料) |
| 只改了技能正文 | 不用重跑——skill_load 每次都实时读文件 |
| 移动了整个库 | 更新链接目标;旧索引里的 repo/relpath 会失效 |
库内容会变,所以把生成命令存成一个脚本(例如 .skill-src\scan-skills.ps1),改完库就跑一次。
过期条目的表现是"搜得到但加载失败"——路径还在索引里、文件已经不在;这时 skill_load 会明确报出
读不到哪个路径,而不是静默失败。
七、备份与隔离
- 库要备份:它是你的能力集合,而且多半来自多个上游仓库。若那些仓库还能重新 clone,最少要备份
skill-index.tsv与你的准入记录。 - 可疑技能先隔离:把目录移出库根(例如
.skill-src\_quarantine\),重跑索引后它就自动消失, 不需要卸载插件。审计命令node tools/audit-library-risk.mjs <库根>会分别统计"代码块内"与 "叙述里"的危险模式——只有前者是模型可能照着执行的。
工具参考
skill_search
关键词转小写,在 name / whenToUse / description / path 上做加权匹配。
| 参数 | 类型 | 说明 |
|---|---|---|
query | string,必填 | 例如 "kubernetes helm"、"remotion video" |
limit | integer | 1–40,默认 12 |
repo | string | 按上游目录名过滤,不分大小写 |
names_only | boolean | 只返回名字与仓库,不带描述(体积约降 60%) |
explain | boolean | 额外返回每条命中的分数构成,用于诊断 |
返回 total、strict、shown、more、fallback,以及每条命中的 name、repo、description、copies、matchCount、stale、whenToUse、files、path、libraryRelative;开了 explain 时另有 score 与 why。
stale: true 表示索引里有这一条但磁盘上已没有 SKILL.md——库改过而索引没重跑。这种条目不会让搜索失败(早期版本会直接抛 ENOENT,一条过期记录拖垮整个检索),而是被标出来,模型也能看到。
skill_load
把一个或多个技能的全文加载进上下文。
| 参数 | 类型 | 说明 |
|---|---|---|
name | string | 单个技能名;也可直接给 SKILL.md 绝对路径 |
names | string | 一次加载多个,逗号或换行分隔 |
repo | string | 上游仓库过滤,作用于本次调用的每个名字 |
返回 requested、loaded、failed,以及 skills[]:每项含 name、source、repo、copies、path、resourceDir、content、referenceFiles、truncated、error。工具卡片把每个技能渲染成 <skill_content> 块并附 base directory,所以 scripts/、references/、assets/ 这类相对路径能正确解析。
为什么是
name+names而不是数组。 早先版本把name声明成oneOf: [string, array]:schema 上好看,实际会坏——数组参数可能以字符串形式到达工具,["gh-cli"]变成字面量'["gh-cli"]',命中 string 分支,被当成一个不存在的技能名。现在三种形态都接受(真数组 / JSON 字符串 / 逗号换行分隔),因为这种健壮性不该依赖传输层怎么序列化。
skill_ref
读技能捆绑的单个文件,或列出它捆绑了什么。
| 参数 | 类型 | 说明 |
|---|---|---|
name | string,必填 | skill_search 返回过的技能名 |
path | string | 相对该技能 base directory 的路径,例如 references/rulesets.md |
list | boolean | 列出全部捆绑文件,而不是读某一个 |
repo | string | 上游仓库过滤,用于同名多份的情况 |
skill_load 返回的 referenceFiles 通常足以判断要不要读某个文件——这个工具让你只读那一个,而不是把整个目录塞进上下文。
技能看板(conversation.view 的「技能」标签页)
插件带一个客户端半,在对话 / 轨迹 / 审批 / 上下文那一栏加一个技能标签页,列出本会话真正加载过哪些技能:
技能调用清单
本会话共 17 次技能调用,涉及 10 个技能。其中 1 次调用一次点名了多个技能,故按技能名分行列出;
1 cordis·插件·开发 (cordis-plugin-development) skill 第 1 轮
2 editing-cordis-compositions skill 第 1 轮
3 remotion·创建 (remotion-create) skill_load 第 1 轮
…
16 验证·前置·完成 (verification-before-completion) skill_load 第 63 轮
17 系统化·调试 (systematic-debugging) skill_load 第 67 轮
18 系统化·调试 (systematic-debugging) skill_ref 第 67 轮
已读到本会话最早一条记录,上面的数字是完整的。
dsh-skill-router v1.12.0 · 第 43 页 · 已读完 · 可翻页 是
零模型 token。 数据全部来自会话账本,而账本由会话作用域插槽交给组件:
// 注册项。`conversation.view` 声明为 scope: "session",渲染器用作用域绑定的 key 调用 inject,
// 再把返回值展开到组件 props 上。(这也是「跟着会话切换」的机制:注入结果按作用域缓存。)
inject: (sessionId, binding) => {
const b = ctx.get('sessions').binding(sessionId ?? binding?.key)
return { source: b.eventSource, session: b.session }
}
| 你可能会以为 | 实际契约 |
|---|---|
eventSource 上能翻页 | ❌ SessionEventSource = ObservableSnapshot<SessionEventWindow>,只有 getSnapshot() 与 subscribe() |
| 那怎么读更早的 | ✅ loadOlder(): Promise<void> 在 session 上(SessionFace extends ISession),官方 trajectory 标签页也是这么调的 |
订阅靠 unsubscribe() 取消 | ❌ 没有这个方法;取消是 subscribe(fn) 的返回值 |
turn / callId 从哪来 | ✅ { type: 'tool/call', seq, time, data: { turn, step, callId, name, arguments } }(实测,不是推断)。身份用信封上的 seq,不是 callId |
读全历史,且只说真话。 账本窗口有上限(实测约 1664–1900 条,见过 3336 → 1664 回落),hasMore 在最新一页上为真,所以第 0 页是历史的近端——几页之前加载的技能在翻到那里之前根本看不见。标签页会一路回填到会话最早一条(实测 43 页),并且:
- 只有读到最早一条才打印
本会话共 N 次技能调用…;之前显示"已加载 N 个技能名(…),更早的记录尚未读完"; - 读不到账本时只说这一件事,一个计数都不打印(服务不在 / 没有绑定 / 没有 eventSource 三种情形各有各的话);
- 停止翻页有三种原因,分开说:没有回应(4 秒截止时间)、有回应但窗口没动、到 200 页上限——"我放弃了后面的历史"和"历史到此为止"是两件事。
行数与调用数为什么会不一样。 这是正确的,不是重复计数:
- 行键是
事件身份 + 规范化技能名,调用数按事件身份分组,所以一次调用点名多个技能会分成多行(真机上就有一次skill_load同时加载了code-review-and-quality与gh-cli); - 事件身份取自事件信封上的
seq(SessionEvent声明seq: SessionSeq是每个事件都有的字段,契约上唯一;而一条tool/call事件就是一次调用)。callId是工具调用与结果的配对 id,契约不保证两条不同调用不会共用它——真按它去重,两条真实调用会静默并成一行、计数悄悄偏低。所以callId只在一个事件没有数字seq时作为回退标记的一部分; - 调用数从最终行派生,不并行累加——同一个事实两个来源就会漂移,而这一条正是真机上先出现"18 行 / 17 次"才被迫改正的;
- 两者不等时表头会自己说明原因,不需要你对着数字发愣。
顺序是会话顺序,不是读到的顺序。 事件以"最新在前"到达,更早的页是前插的,所以按读到的先后排会得到倒序(真机报告过第 31 轮排在第 5 轮前面)。现在按事件自己的 seq 升序,页以什么顺序到达都无所谓。
翻页的判据是"窗口有没有向更早延伸",不是 promise,也不是长度。 真实的 session.loadOlder() 会在几种情况下静默什么都不做(会话还没打开、events 还没到、已有并发请求),返回一个已 resolve 的 promise——所以"promise resolve 了"不等于"读到了一页"。判据是:
窗口里最老那条的 seq 是否变小 ← 主判据:只有真的拿到更早的历史才会发生
或
窗口长度是否变长 ← 次判据:实时追加也可能让它变长
只看长度是错的:账本窗口有上限,所以它可能是滑窗——长度不变而内容整体向更早方向移动。那种页会被误判成"没有进展",两次之后标签页就带着"无进展停止"放弃,把"放弃"说成了"没有"。test/usage-tab.mjs 里有一组容量恒定(40 条、每次前移 20)、把技能藏在旧历史里的滑窗测试钉住这条。
每读到一页就当场并入累积账本。 这一条比判据更关键:累积器曾经只在账本通知时写入,而通知可能很久不来。于是会出现"读取成功但没有留存"——loadOlder() 让一页进入窗口,界面渲染出它,在下次通知之前它随滑窗被挤出去,累积账本从未记到它。现在每页在它还在窗口里时就并入。停止翻页不影响实时尾部——之后出现的调用照样立刻显示。
最后一行说明你跑的是哪一版。 客户端半由 web 服务带 cache-control: immutable 提供、不能被 Node 测试 import、服务端字节又挡在 Desktop 的能力校验后面,所以"浏览器跑的是哪一版"曾是唯一无法回答的问题。现在它印在界面上:dsh-skill-router v1.12.0 · 第 43 页 · 已读完 · 可翻页 是。
中文名只用于显示。 技能名是 skill_load、索引检索和 /skill 命令的匹配键,所以:
- 实际调用的永远是英文原名,中文形不离开渲染层;
- 检索仍走英文原文,
SKILL.md与索引一个字节都没改; - 专名(
azure、vercel、semgrep、figma…)保持原样——本库名字里最高频的 token 正是azure(148)、google(44),把它们译成中文只会更难认。
翻译是术语表 + 专名白名单,不是 872 条整名对照表:短语优先(best-practices → 最佳实践),再退到单词(troubleshooting → 故障排查),虚词(and/from/the)直接丢弃。
这一栏曾经是空的,原因值得留着。 它先后读错过四次数据源:猜的节点形状、请求头里本轮的工具声明(把"给模型看过"当成"被加载过")、
useChat().legacy.nodes(实测同一时刻 210 个节点、0 次工具调用,而账本里有 2778+ 条事件),以及对着source.loadOlder写翻页(方法在session上,于是守卫每次都在第一行返回,四次"修复"全都改在一条从未执行的代码路径上)。四次都不崩溃、UI 都画得出来——所以数据契约必须实测,不能推断。复盘见docs/release-notes-v1.8.0.md。
任务感知的技能发现(HIGH 注入 —— 一次有单一变量的实验)
上面三个工具解决的是"技能很多,怎么让 Agent 找到"。但它们解决不了另一件事:
Agent 会不会想到该去找?
库里的技能对模型不可见,所以用上一个的前提是模型自己先想起要搜索。用户说"帮我做一次 Semgrep 安全审计",如果模型决定直接回答,那 skill_search 根本不会发生——这一层就是为这个缺口准备的。
它挂在 agent/pre-step 上(DSH 的瀑布事件,在请求组装之前运行),在回合第一步用本地、零模型调用的方式给任务排个序:
用户任务(仅第一步)
↓
与 skill_search 同一个 tokenizer、同一个 scoreRow 打分函数(权重只写一份)
↓
但**不继承那个工具的查询策略**:不做严格 AND、不做 all-but-one 回落
↓
top 5,或明确"什么都没有"
↓
tier === HIGH → 一句话摆到模型面前;其余什么都不做
为什么必须共享 scorer、却不共享查询语义。 skill_search 的严格 AND 是为模型写出来的短查询设计的;任务句子是散文,"分析这个 React 项目的性能问题"在严格 AND 下没有任何解释——那会让这一层静默失效。所以差异留在调用方,权重留在函数里。
为什么开注入,以及为什么只开 HIGH
开之前实测到的状态是:273 个回合里,库几乎从未被检索过,而每一条遥测都是 injected: false。所以被检验的假设很窄、很因果:
Agent 不是不会用技能,而是从没人告诉它有哪些技能值得考虑。
测这个假设需要只改一个变量,所以本版只做一件事:tier === HIGH 时把候选摆出来。检索策略(严格 AND、tokenizer、tier 阈值)一行没动——这样行为变化才能归因到"提示",而不是归因到"同时改了两处"。
成本是这件事值得一试的理由:
| 每轮已付出的 | token |
|---|---|
| 常驻技能目录 | ~3,238 |
| 三个工具 schema | ~1,001 |
| 注入 5 个候选(本版新增) | 329–341 字节 ≈ 91–95(实测真库) |
注入的是什么
Maybe relevant skills for this task: semgrep (name, whenToUse); code-review (name).
Load any that fit with skill_load, or ignore this and continue without one.
只有名字与命中的字段,没有描述正文——字节预算就是设计本身。并且明确写着"可以都不用":这一层负责发现,选择仍然在 Agent 手里。
两处已经取证过的实现细节:
- 放进
decision.messages,不是agent.inject()。preStep在派发瀑布之前就调用了inbox.claim(),inject()的东西要等到下一步才被取走——而这一层要在第一步就起作用。decision.messages才是当前这一步进入请求的权威批次。 - 注入的消息带唯一
id。 形状照框架自己的createUserMessage抄:{ role, content: [{type:'text',text}], source: {kind}, id }。id 不是装饰:框架消息总是带一个,两条注入共享 id 会让下游无法区分。
遥测现在记两件事,因为它们回答的是两个问题
{"at":"…","turn":3,"step":1,"tier":"HIGH","reason":"ok","tokenCount":7,"indexRows":1028,
"candidateCount":5,"candidates":[…],"injected":true,"hintBytes":206}
{"at":"…","kind":"turn-calls","turn":3,"tier":"HIGH","injected":true,
"skillSearchCalls":1,"skillLoadCalls":1,"skillRefCalls":0,"residentSkillCalls":0,"otherToolCalls":7}
第一条写在 step === 1,那时谁也不知道这一回合会不会去搜——所以"提示有没有让 Agent 去搜"必须由第二条回答,两条靠 turn 对齐。第二类记录只记计数:没有工具参数、没有技能正文、没有用户原文。
按回合的计数是在下一个回合的第一步结算的,因为这个 harness 里没有可挂的回合结束瀑布(已核实:agent/turn-stopping 在 0.1.7-rc.2 里不存在,挂上去会是"永不执行的埋点")。因中止/崩溃而未结算的回合,其数字是丢失而不是错记——对一次测量来说这是正确的失败方向。
三个成功指标,按因果关系排序:① HIGH 提示后 Agent 是否开始 skill_search(验证触发假设)→ ② 搜到之后是否真的 skill_load(验证候选产生了行为,而不只是被看了一眼)→ ③ 加载的技能与任务是否真的相关(人工抽样)。
一个已经观测到的保守之处(本次不改,登记待数据): 真库实测里 把这个 React 项目的性能问题分析一下 排出了 react-email/vercel-react-best-practices 等明显相关的候选,但 tier 是 NONE(只有一个 token 落地,够不到 HIGH 要求的"≥2 个不同 token"),于是不注入。也就是说:短技术查询可能永远够不到 HIGH。这是不是问题,要等 ① 的数据——如果 HIGH 提示确实有效,那么扩大覆盖面(而不是放松阈值)才是下一步。
两个已知边界,现在不动,因为一次只验证一个变量
- 索引只认 Latin script。 tokenizer 是
[^a-z0-9+#._-],所以中文任务("帮我做一次安全审计")产出 0 个关键词。这不是缺陷需要掩盖,而是索引的性质:这类任务记为reason: "no-searchable-token"(实测占 24.4%)。等注入跑出数据再决定补 aliases 还是双语whenToUse——不是现在上 embedding。 reason区分"没有匹配"和"没有库"(no-library)。否则一个坏掉的索引会看起来像一个安静的、表现良好的路由器。skill_search的严格 AND 也先不动。 四个关键词必须全部出现,实测systematic debugging failing test root cause归零、而debugging命中 21 条——这是真实的第二瓶颈,但与注入同时改就无法归因。等 ① 的数据说话。
工程约束
为什么零导入。 早先版本从 @deepseek-ai/dsh-tools 引入 defineTool。Node 解析裸标识符时先从发起包自己的 node_modules 找,于是包里一个残留的开发用替身遮蔽了真包,作者 DSL(output.schema: { type: 'json' })未经编译就进了注册表,结果整棵插件树加载失败:
unsupported JSON schema: schema.type must be one of object/array/string/number/integer/boolean/null
现在插件不带 node_modules、不带任何依赖,自己在本地把工具定义构造成标准 JSON Schema——无论运行时是否编译都合法。注册表编译器在一处比它的断言更严:object schema 必须显式声明 additionalProperties。test/boot-safety.mjs 把这些规则全部固化成断言,包括"dependencies 里出现任何 @deepseek-ai/* 即构建失败"。
为什么没有 ledger/去重状态。 插件不做自动注入,所以不存在"这个技能本会话已注入过"的状态可维护。是否重复加载由模型自己决定——这是工具驱动相对 pre-step 路由的一处结构性简化。
测试
npm test
二十八个零依赖脚本。机器上能找到真实技能库时就直接对真库跑,否则在系统临时目录生成夹具库,所以裸克隆也能测:
| 脚本 | 覆盖内容 |
|---|---|
boot-safety.mjs | 无遮蔽用的 node_modules、dependencies 里无宿主包、schema 落在注册表强制子集内 |
schema-forms.mjs | 哪种 output.schema 写法能通过注册,以及作者 DSL 会失败 |
shape.mjs | 参数形状与工具描述里的路由措辞 |
verify.mjs | 搜索 + 加载的端到端行为 |
collisions.mjs | 重名解析与 repo 提示 |
batch.mjs | 多技能请求的每一种传输形态 |
robustness.mjs | 部分匹配降级、explain 的分数构成、whenToUse、两种截断边界 |
skill-ref.mjs | 路径包含性(含 ../ 越界尝试)、列目录、文件缺失 |
index-format.mjs | 索引格式契约:6 列与 7 列都可解析、表头按形状识别、真库仍可用 |
index-header.mjs | 表头的五种写法(带引号/不带引号/带 BOM/两者兼有/LF 换行)都不会变成一条名为 name 的技能 |
minimal-host.mjs | 只注入 ctx.fs 时的降级:三个工具仍可用,可选 API 缺席不崩溃 |
link-support.mjs | .skill-src 是目录链接时搜索与加载仍然可用(Windows junction / POSIX symlink);运行器不允许建链接时报告为跳过 |
stale-and-duplicates.mjs | 索引过期(目录已删)不再使 skill_search 抛异常、过期条目被标 stale;重名候选的 repo 列表对模型可见;弱匹配不被当作命中 |
redos-guard.mjs | js/polynomial-redos 的护栏:替代函数与它替换的正则逐例等价(含反斜杠结尾的 Windows 路径,第一版函数只删 /,20 例里错 8 例)、最坏输入为常数级、调用点确实走函数而非又写回正则、host.js 里"量词 + $"正则只能有 1 条(每多一条都要重新论证输入是否有界) |
engine-range.mjs | dsh.engines.dsh 的范围:每条 OR 分支都带预发布标签(node-semver 的规则,缺了就覆盖不到该 tuple 的 rc)、覆盖 0.1.5/0.1.6/0.1.7、排除 0.2;并在本机找到真实 semver 时实测接纳全部 11 个已发布版本、拒绝 0.2.0,且已装的 harness 版本落在范围内 |
discovery-injection.mjs | 发现层的接线(HIGH 注入 + 每回合工具计数):注入只发生在 tier === HIGH 且 step === 1、注入消息形状合法(role/content/source/唯一 id)、两次注入 id 不同、step=2 与 NONE 不注入、原文消息未被改动;遥测如实记 injected 与实测 hintBytes;工具调用在回合结束后按回合计数(三个技能工具各记、原生 skill 单独记、其它工具只汇总),且计数按回合归零、记录里没有工具参数与正文 |
build-index.mjs | 索引生成器:每一行都按插件的路径规则解析到真实存在的 SKILL.md(第一版 relpath 多套了一层 repo/,行数 1028 = 1028 却 0 行可解析——计数检查抓不到这种错)、.git/node_modules 被跳过、BOM/CRLF/块标量/缺 name/缺 description 各形状、制表符与引号的 TSV 转义、whenToUse 只在需要时写第 7 列、CLI 的 --check 三态与 CRLF 不算过期;有真库时逐行复核并与现有索引比键集合 |
doctor.mjs | 库体检的每条断言都构造一种真实故障并要求它被报出来(索引不存在 / 过期 / 缺失 / 无 description / 跨仓库重名),因为一个永远返回"健康"的体检工具也能通过"健康库返回 OK"那种测试;--json 可解析且不含整库明细 |
library-root-contract.mjs | 跨层契约:同一份夹具同时驱动运行时(host.js)与 doctor.mjs,断言两者从同一个嵌套 cwd 得到同一个库根;并覆盖两个分叉点(超过 8 层两者都不该找到;空工作区只有运行时回退入门库) |
starter-library.mjs | 入门技能库:索引与磁盘一致、files 含 resources、空工作区里能搜到并加载入门技能且标 starterLibrary: true、有自己的库时该标记消失、插件没有任何"往常驻目录注册技能"的调用(产品不变量) |
usage-ledger.mjs | 技能账本的纯逻辑(59 条断言),夹具照抄实测事件形状:三种加载工具都算、skill_search 不算、同一条事件跨页只计一次、不同事件共用同一 callId 仍计两次、一次调用带 A+B 两个名字都保留、路径与裸名归并为同一技能、hasMore 三态、窗口挤出后已读到的记录不丢、按 seq 排成会话顺序、行数与调用数的关系在结构上成立(calls ≤ rows,取等当且仅当没有多名调用)、坏输入返回空账本而不抛 |
usage-tab.mjs | 标签页接线(87 条断言),通过浏览器装载它的同一条路径取组件再渲染:注册契约、inject 两个参数、三种"读不到账本"的说明、首屏即读且每页只拉一次、hasMore 永为真时在上限内停住、第 5 页深埋的调用被找到、200 片流式碎片只排一次渲染、窗口挤掉最老一条后它仍在清单里、父组件重渲染不得让翻页卡死、「读取中」必须有截止时间、「行数 ≠ 调用数」必须自我解释、界面只留结论不留开发用诊断 |
client-half.mjs | 按真实加载机制验证客户端半:插桩 window.__ModuleLoader__、像 create() 一样物化 factory、断言 inject 声明、在四种 document 时序下 apply() 都不抛错;并扫描并拒绝已证伪的数据契约回来(legacy.nodes、useChat、把工具声明当用量、对着 source.loadOlder 而不是 session.loadOlder 写翻页) |
package-contract.mjs | 加载器会读的每一样东西:exports/main/dsh.client/dsh.bundle/files、客户端半作为经典脚本可编译且自带 load() 注册、host.js 的导出形状、组合行 |
docs-parity.mjs | 双语文档不漂移:README 对、SECURITY 对、发布说明中文在前 |
workflow-config.mjs | CI 配置本身:permissions 显式且只给 contents: read、action 固定版本、无 tab 缩进 |
release-consistency.mjs | 发版一致性(离线部分):两处版本号一致、每份发布说明的标题以自己版本号开头且双语齐全、文件名规范、流程文档与发布脚本都在。有意不检查"当前版本必须有说明"——那会变成"每次提交都得发一版"的强制来源(见 RELEASING.md 的版本策略) |
no-local-paths.mjs | 代码与配置里没有本机绝对路径;文档里的示例路径有意排除在外 |
用 SKILL_LIBRARY_ROOT=/path/to/workspace 指定要测的技能库;node tools/audit-library-risk.mjs 可对任意库做风险审计。
发版流程与版本策略见 RELEASING.md:版本号只在插件行为变化时才动;tag 与 Release 是两个对象,git push 只推送前者,所以发版的最后一步是 node tools/publish-release.mjs(漏了不会有人收到通知,本仓库曾因此连续 14 个版本只有 tag 没有 Release)。
node tools/audit-client-halves.mjs 不在 npm test 里,这是有意的:它扫的是你本机 profile 里所有插件的客户端半,不具备自包含性——装了别人的坏插件时它应该报出来(那是诊断),但不该让本仓库的测试无故变红。它存在的原因是本插件让 DSH 启动失败过两次,而报错只点名 HMR,真正坏掉的那份在列表中间。
目录结构
host.js 插件本体:apply()、buildSkillRouterTools()、definePortableTool()
client.js 客户端半:在 conversation.view 注册「技能」标签页
cordis.patch.yml 被组合进去的那一行(id: skill-router, name: dsh-skill-router)
SECURITY.md / SECURITY.zh.md 安全政策(英文 / 中文)
test/ 二十八个测试,外加一个仅开发用的 @deepseek-ai/dsh-tools 替身
tools/publish-release.mjs 为版本创建 GitHub Release(发版第 5 步,见 RELEASING.md)
tools/audit-library-risk.mjs 技能库风险审计(政策里的统计由它推导)
tools/audit-client-halves.mjs 本机客户端半的打包契约诊断
docs/ 各版本的发布说明(双语,中文在前)
.github/workflows/ CI:Linux 与 Windows 上、Node 20 / 22 / 24 各跑一遍 npm test
安全
这个插件从不执行代码、从不联网、从不写文件、从不读环境变量——只做索引查询与文件读取。它读到的技能正文是不可信第三方内容,那才是信任边界。
完整政策(威胁模型、路径包含性的已知局限、供应链约束、以及"未经审阅不会加入的功能"清单)见
SECURITY.zh.md | English。漏洞请走本仓库的 GitHub 私密漏洞报告。
许可
MIT
Comments
Loading…
Similar plugins
Skill routing for DeepSeek Harness: embeds skills and each user task with a local model, keeps the clearly-relevant skills with a gap-based selection rule, and auto-injects their full bodies into the
★ 0
NOASSERTION
TypeScript
dsh plugin --profile web add dsh-skill-routerThree-layer skill management for DeepSeek Harness: a library pool, per-workspace enablement whitelists, and per-session picks, with a Web settings panel, a conversation-embedded view, a skill file bro
★ 1
↓ 187/wk
NOASSERTION
JavaScript
dsh plugin --profile web add dsh-skill-managerby cheshireez
DeepSeek Harness(dsh)Web GUI 技能中枢:浏览/搜索完整本地技能目录、启用/禁用、查看正文、排查诊断、新建技能,基于官方 ctx.skills 注册表。 In-GUI skill hub for dsh: browse, search, enable/disable, inspect, diagnose and scaffold local skills from the
★ 26
↓ 2k/wk
MIT
TypeScript
Sep 29, 2026
dsh plugin --profile web add dsh-skill-hubby pandarayc
Progressive disclosure for DSH skill collections: one generated index skill stays in the catalog, every member stays user-invocable but leaves the model catalog. Zero dependencies.
★ 0
MIT
JavaScript
Sep 27, 2026
dsh plugin --profile web add @pandarayc/dsh-skill-tierby peiqi10086
DSH(DeepSeek Harness)Skills 管理 + SkillHub 商城插件:侧边栏面板管理本地 skills(用户级/工作区项目级/内置只读),搜索并一键安装 SkillHub 公开技能,附模型工具 dsh_skillhub_search。
★ 9
MIT
TypeScript
Aug 24, 2026
dsh plugin --profile web add dsh-skills-marketInstalls a skill-router skill that semantically searches a local skill corpus on demand, keeping the corpus out of the per-turn model catalog.
★ 0
dsh plugin --profile web add dsh-awesome-skills