Vai al contenuto principale
Z

dsh-skill-router

ziyuan258/dsh-skill-router

On-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 library out of the resident session catalog and out of every turn's context; adds a Skills tab to the conversation view ring that reads back the whole session and lists which skills were actually loaded, and in which turn. Zero dependencies, and the records render client-side only, never entering the model's context.

Installazione

dsh plugin --profile web add github:ziyuan258/dsh-skill-router

README

dsh-skill-router

English | 中文

给 DeepSeek Harness 的 Host 插件。新增 skill_search / skill_load / skill_ref 三个工具,让 agent 在需要时自己去技能库里检索并加载技能。

你只有十几个技能?这个插件不适合你。 一次工作通常只用到几个技能,所以技能少的时候,让它们常驻目录反而更省——目录本来就是 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 需要时自己检索、自己加载。代价从"每轮固定"变成"用到才付"。

成本(实测,不是估计)

工具驱动不是免费的,它有两笔开销:

开销实测性质
常驻工具 schema3,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 有多个同名仓库

这个名字下至少并存 8 个仓库,装错就是装了另一个插件:

本仓库另一类同名插件(例:MJorgin/dsh-skill-router)
安装命令github:ZiYuan258/dsh-skill-routergithub:akqwpeter-prog/dsh-skill-router(该仓库已改名,命令是过期的)
机制工具驱动:模型自己调 skill_search / skill_load / skill_refpre-step 自动路由:模型回答前读用户消息,命中即注入全文
解决的问题库里的技能对模型不可见模型该用技能时没用(注意力漏掉)
依赖零依赖、零导入各自不同,有的需要 LLM 判定或 embedding

两者不冲突、可叠加——它们在不同层次工作。装之前核对 owner 是 ZiYuan258。

若你的工具报告本仓库"不可访问",先分清是哪种:GitHub 对未认证 API 限流 60 次/小时, 超限返回 403 API rate limit exceeded,而网页与 raw 文件仍然正常——dsh plugin add 走的正是后者。

使用方法

一、把库放到哪里

插件从会话工作目录往上最多 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

两条硬性约定:

  1. 目录名必须是 .skill-src。 前导点让它对 DSH 的 skill 扫描器不可见——这正是"库不占目录成本"的机制。若命名为 skills/ 或放进 .dsh/skills/,DSH 会把里面的技能全部注入每轮上下文,插件就白装了。
  2. 它必须在会话 cwd 的同级或上级。 若你的 DSH 会话开在 <你的工作区>\projects\foo,插件会向上找到 <你的工作区>\.skill-src——这没问题。
  3. 内部结构随意。 插件只要求"某层的目录名等于 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,适用于把多个上游仓库检出一到同一目录):

$rows = Get-ChildItem $root -Directory | ForEach-Object {
  $repo = $_
  Get-ChildItem $repo.FullName -Recurse -File -Filter 'SKILL.md' | ForEach-Object {
    $text = Get-Content $_.FullName -Raw
    $name = ''; $desc = ''
    if ($text -match '(?s)^\uFEFF?---\s*\r?\n(.*?)\r?\n---') {
      $fm = $Matches[1]
      if ($fm -match '(?m)^name:\s*(.+?)\s*$') { $name = $Matches[1].Trim() }
      if ($fm -match '(?ms)^description:\s*(.+?)(?=\r?\n[a-zA-Z_-]+:\s|\z)') {
        $desc = ($Matches[1] -replace '\s+', ' ').Trim() -replace '^[>|][+-]?\s*', ''
      }
    }
    [pscustomobject]@{
      repo = $repo.Name
      relpath = $_.Directory.FullName.Substring($repo.FullName.Length).TrimStart('\')
      name = $name; description = $desc
      files = 1; KB = [math]::Round($_.Length / 1KB)
    }
  }
}
$rows | Export-Csv -Path (Join-Path $root 'skill-index.tsv') -Delimiter "`t" -NoTypeInformation -Encoding UTF8

请用真正的 CSV 写入器(Export-Csv、csv.writer 之类)。手工用制表符拼列会在描述含制表符、引号或换行时坏掉——上面这份参考实现当初就踩过这个坑。库内容变了要重跑(见第六节的"库变了怎么办")。

四、验证装好了

安装命令见上一章;重启 DSH 后(插件行只在生成新宿主进程时组合)按顺序验证:

  1. 工具在不在:问 agent"你有哪些 skill 相关的工具",应当看到 skill_search / skill_load / skill_ref。
  2. 索引找没找到:让 agent 用 skill_search 查一个你库里确实有的名字。返回里带 library 字段,那是它实际使用的库根——核对这个路径,这是"找错目录"最快的诊断点。
  3. 加载通不通:让 agent skill_load 其中一个。返回的 source 应为 library(若是 resident,说明命中的是常驻区而不是库)。
  4. 库没被塞进目录:确认新会话的技能目录没有因为这次安装而变长。库在 .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 <库根> 会分别统计"代码块内"与 "叙述里"的危险模式——只有前者是模型可能照着执行的。

工具参考

关键词转小写,在 name / whenToUse / description / path 上做加权匹配。

参数类型说明
querystring,必填例如 "kubernetes helm"、"remotion video"
limitinteger1–40,默认 12
repostring按上游目录名过滤,不分大小写
names_onlyboolean只返回名字与仓库,不带描述(体积约降 60%)
explainboolean额外返回每条命中的分数构成,用于诊断

返回 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

把一个或多个技能的全文加载进上下文。

参数类型说明
namestring单个技能名;也可直接给 SKILL.md 绝对路径
namesstring一次加载多个,逗号或换行分隔
repostring上游仓库过滤,作用于本次调用的每个名字

返回 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

读技能捆绑的单个文件,或列出它捆绑了什么。

参数类型说明
namestring,必填skill_search 返回过的技能名
pathstring相对该技能 base directory 的路径,例如 references/rulesets.md
listboolean列出全部捆绑文件,而不是读某一个
repostring上游仓库过滤,用于同名多份的情况

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.10.1 · 第 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.10.1 · 第 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。

任务感知的技能发现(当前是干跑:只测量,不注入)

上面三个工具解决的是"技能很多,怎么让 Agent 找到"。但它们解决不了另一件事:

Agent 会不会想到该去找?

库里的技能对模型不可见,所以用上一个的前提是模型自己先想起要搜索。用户说"帮我做一次 Semgrep 安全审计",如果模型决定直接回答,那 skill_search 根本不会发生——这一层就是为这个缺口准备的。

它挂在 agent/pre-step 上(DSH 的瀑布事件,在请求组装之前运行),在回合第一步用本地、零模型调用的方式给任务排个序:

用户任务(仅第一步)
   ↓
与 skill_search 同一个 tokenizer、同一个 scoreRow 打分函数(权重只写一份)
   ↓
但**不继承那个工具的查询策略**:不做严格 AND、不做 all-but-one 回落
   ↓
top 5,或明确"什么都没有"

为什么必须共享 scorer、却不共享查询语义。 skill_search 的严格 AND 是为模型写出来的短查询设计的;任务句子是散文,"分析这个 React 项目的性能问题"在严格 AND 下没有任何解释——那会让这一层静默失效。所以差异留在调用方,权重留在函数里。

当前版本不注入任何东西。 它只往 ~/.dsh/skill-router/discovery.jsonl 追加一行遥测,然后原样返回决策。测的是你真正需要数据才能回答的三个问题:候选经常有意义吗、误报多不多、多少任务什么都找不到。

{"at":"…","turn":3,"step":1,"tier":"HIGH","reason":"ok","tokenCount":7,"indexRows":2,"elapsedMs":11,
 "candidateCount":1,"candidates":[{"name":"semgrep","score":210,"matched":3,"nameHits":1,"fields":["name","whenToUse"]}],"injected":false}

记录里没有用户原文——只有命中的 token 数量、候选名与分数。一个观察功能不该顺手制造新的会话内容存储。日志有字节上限并轮转,所以放着跑几周也不会无限增长。

tier 是这个阶段的核心读数:HIGH(命中 name 且 ≥2 个不同 token 落地)、MEDIUM(只有 description 命中、或单个弱关键词)、NONE(没有足够区分度,或与第二名咬得太近)。几天数据之后才谈"阈值该定在哪",而不是凭感觉定。

两个已知边界,现在不修,因为干跑的意义就是先量它们:

  • 索引只认 Latin script。 tokenizer 是 [^a-z0-9+#._-],所以中文任务("帮我做一次安全审计")产出 0 个关键词。这不是缺陷需要掩盖,而是索引的性质:这类任务记为 reason: "no-searchable-token",干跑会告诉你它占多少比例。如果比例很高,下一步再考虑 aliases 或双语的 whenToUse——不是现在上 embedding。
  • reason 区分"没有匹配"和"没有库"(no-library)。否则一个坏掉的索引会看起来像一个安静的、表现良好的路由器。

正式启用时(注入)会改一处,而且这一处已经取证过:hint 要放进 decision.messages,不是 agent.inject()。因为 preStep 在派发瀑布之前就调用了 inbox.claim(),inject() 的东西要等到下一步才被取走——而这一层要在第一步就起作用;decision.messages 才是当前这一步真正进入请求的权威批次(并且会落成会话里的 user/message)。

工程约束

为什么零导入。 早先版本从 @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 列表对模型可见;弱匹配不被当作命中
engine-range.mjsdsh.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-dry-run.mjs发现层的干跑,真的调用 apply(ctx) 并像 agent-loop 一样触发 agent/pre-step:注册三个工具与监听、不改变决策、写出遥测、只在第一步记录、中文任务记 no-searchable-token、reject 原样返回,以及遥测里没有用户原文
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.mjsCI 配置本身: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

Plugin correlati