Passer au contenu principal
Z

dsh-skill-router

ziyuan258/dsh-skill-router

Outils de skills à la demande pour DeepSeek Harness : skill_search / skill_load / skill_ref explorent d’abord une bibliothèque par étapes et ne chargent le contenu qu’au besoin, sans garder toute la bibliothèque dans le catalogue de session ni le contexte de chaque tour. L’onglet Skills relit la session et liste les skills réellement chargés et leur tour. Sans dépendance ; les traces sont rendues côté client uniquement et n’entrent jamais dans le contexte du modèle.

Installer

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

README

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 仓库(我是其中最新的一个)。其中若干走自动注入路线:在模型回答前读用户消息,命中就把技能正文塞进提示词。

二、本插件不会自动加载或注入技能正文,默认也不自动提示候选(v1.16.2 起关停)。 它曾把**少量候选技能名(只有名字,没有正文)**摆到 agent 面前,但实测 45 个注入回合 0 次自发加载(见下),故默认关停;README 底部有一张同名仓库的对照表。

你只有十几个技能?这个插件不适合你。 一次工作通常只用到几个技能,所以技能少的时候,让它们常驻目录反而更省——目录本来就是 DSH 的原生机制。 这个插件解决的是另一个问题:技能多到装不进目录。

它是什么,以及它不是什么

三块价值,各自定性 —— 本插件只做这三件事:

能力定位依据
skill_load 直查唯一被证明有效的通道:名字已知或已看到时,直接读正文自然会话里唯一的正例走的就是这条路
skill_search 查询接口明确要找技能时提供查询,不承担"让模型想起来要找"检索域与排序见「工作原理」
注入层(HIGH 候选提示)默认关停(v1.16.2);曾如实展示候选,实测 0 自发转化45 个注入回合 0 自发转化

它不是「技能自动触发」,也不是「教模型该用技能」。

一条已被证伪的假设。 本插件的注入层曾按「Agent 不是不会用技能,而是从没人告诉它有哪些技能值得考虑」来设计。 实测把候选摆到面前之后,自发加载仍然为 0(45 个注入回合、18 个可达单元、三种形态各测一次,见「任务感知的技能发现」)。 ⇒ 指针式注入不改变模型行为;本插件的价值在查询和展示,不在驱动使用。 v1.16.2 起注入层默认关停(设环境变量 DSH_SKILL_ROUTER_INJECT_TIERS=HIGH 可重新打开,仅供继续观测)。

你需要它吗

判断标准只有两条:库有多大,以及每个任务用几个。

你的情况怎么办
技能 < ~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.0-rc.1 起,逐 tuple 列到 0.2.2,上界 0.3):node-semver 只放行"某个比较符的 major.minor.patch 元组完全相同、且该比较符自身也带预发布标签"的预发布版本,所以"覆盖所有 rc"没有捷径、只能逐 tuple 写——test/engine-range.mjs 钉住这条。本插件只用到 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-routergithub:lau4tin1/dsh-skill-router(占着 npm 裸名 dsh-skill-router)
机制工具驱动:模型自己调 skill_search / skill_load / skill_ref自动路由 + 自动注入:回答前用本地模型给任务与技能做向量,按分差挑出相关的,把全文塞进提示词
要解决的问题库里的技能对模型不可见模型该用技能时没用(注意力漏掉)
谁决定用哪个agent:它看完候选自己选,可以一个都不选插件:路由命中即注入
依赖零依赖、零导入各自不同,有的需要本地模型或 embedding

两者立场相反,这是本插件有意的设计,不是缺陷。 见下方「任务感知的技能发现」一节:那一层自动提示少量候选技能名(tier === HIGH 时,一行名字与命中字段),但从不自动加载或注入技能正文——正文只有 skill_load 才进入上下文,而那一步永远由 agent 发起。

同类里还有 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

两条硬性约定:

  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,适用于把多个上游仓库检出一到同一目录):

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。它能用,但有两个问题:

  1. 索引格式是插件的内部数据模型,不该是安装流程的一部分;
  2. 它只扫一层($root\<repo>\**)、不产出 whenToUse、漏掉没有 frontmatter 的技能——写这段时的参考库里有 3 个这样的技能(微软 monorepo 里),于是它们永远搜不到。实测:用 build-index.mjs 扫同一个库得到 1028 行,而那份参考实现产出的索引只有 1025 条。

索引仍然是一份公开契约:任何能产出那 7 列的东西都可以替代 build-index.mjs。契约在第二节上方那张表里。

四、验证装好了

安装命令见上一章;重启 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 <库根> 会分别统计"代码块内"与 "叙述里"的危险模式——只有前者是模型可能照着执行的。

索引与完整性

索引(skill-index.tsv)是检索层的契约,不是缓存:它决定 skill_search 能看到什么。

它有一个身份文件组:skill-index.tsv 本体、skill-index.tsv.sha256(本体的 hash)、skill-index.meta.json(行数 / 仓库数 / 唯一名 / 各规则丢弃数 / sha256)。三者由生成脚本一次性写好。

校验两处,判据两条(互相独立;是警告,不是阻止):

  • ① hash 不一致 ⇒ 索引被外部修改过、没走生成脚本;
  • ② 行数变化超过 10% ⇒ 规模变化必须解释 —— 见 .dsh/SKILLS-POLICY.md 第十七节的「例行重跑例外条款」。
位置做什么怎么触发
检索层(本插件)索引缓存重载时自检;真报警才在 skill_search 返回值里加 indexAlarm,并把提示前置到 note任何一次 skill_search
node .skill-src/index-integrity.mjs字节级精确校验 + 生成器不变式诊断,退出码 0 / 1手工 / 定期

⚠️ 一条使用者会碰到的限制:完整性报警只在新会话或宿主重启后可见。 当前运行中的插件实例不会读到新报警 —— 如果你手工修改了索引,当前会话不会知道,直到下次重载。 插件源码的改动同理:改完不重载,运行中的实例仍然是旧代码。

改了库之后:重跑生成脚本(见「三、生成索引」)。它做提交前校验,且校验失败时四个产物全部放弃、旧文件原样保留 —— 不会留下"本体已换、身份文件没换"的中间态。

工具参考

关键词转小写,在 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.18.2 · 第 43 页 · 已读完 · 可翻页 是

v1.17.1 起:搜索结果也入列;v1.17.2 起:查询兜底;v1.17.3 起:run_code 内嵌调用。 一次成功的 skill_search 以前在这个标签页里不可见——搜索是"找"不是"用",不计入加载计数。v1.17.1 让标签页显示搜索找到了哪些技能(从 tool/result 事件的 hits 解析,标 skill_search 以区别于 skill_load)。但 eventSource 窗口实际只含 tool/call 不含 tool/result(dsh-better-sidebar 的 changes.ops 路由正是为此而建),所以 v1.17.1 在生产里仍看不到。v1.17.3 加了第三遍:对结果不在窗口里的搜索,显示查询本身(前缀 🔍),让"搜过"这件事至少看得见。v1.17.3 发现根因:agent 用 run_code 作唯一工具接口,skill_search/skill_load 调用嵌在 TypeScript 代码体里,不是独立的 tool/call 事件——前三遍全找不到。v1.17.3 在第一遍里解析 run_code 的 code 参数,用正则提取内嵌的 tools.skill_search({query:…}) 和 tools.skill_load({name:…}),让它们也入列。搜索本身仍不算一次加载调用。

零模型 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.18.2 · 第 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 的瀑布事件,在请求组装之前运行),在回合第一步用本地、零模型调用的方式给任务排个序:

用户任务(仅第一步)
   ↓
tokenize(与 skill_search 同一个 tokenizer)
   ↓
STOP_WORDS(语言层面的噪声词)
   ↓
**语料频率过滤**:出现率 > 80% 的词直接丢弃(判别力 < 20%)
   ↓
scoreRow(与 skill_search 同一个打分函数,权重只写一份)
   ↓
**按技能名去重**:同名多份只占一个名额,保留最高分
   ↓
top 5,或明确"什么都没有"
   ↓
tier 在**去重后、且只在有效 token 上**计算
   ↓
tier === HIGH → 一句话摆到模型面前;其余什么都不做

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

为什么开注入,以及为什么只开 HIGH

开之前实测到的状态是:273 个回合里,库几乎从未被检索过,而每一条遥测都是 injected: false。于是被检验的假设很窄、很因果:

Agent 不是不会用技能,而是从没人告诉它有哪些技能值得考虑。

这个假设已被证伪:告诉了,也没用。 做法是只改一个变量 —— tier === HIGH 时把候选摆出来,检索策略(严格 AND、tokenizer、tier 阈值)一行没动,这样行为变化才能归因到"提示"。结果是:

  • 45 个 treatment 回合(真注入了候选)自发加载 = 0;唯一那 1 次是用户指定重放 evaluation 技能;
  • 18 个"可达"单元(检索域里有、候选也对)加载目标 0/18;
  • 三种"把技能送到模型面前"的形态各测过一次 —— 目录已注入、指针注入、正文完整在手 —— 全部不产生自发使用。

⇒ 指针式注入不改变模型行为。 所以这一层的定位改为「如实展示候选」,而不是「驱动使用」。

成本是这件事值得一试的理由:

每轮已付出的token
常驻技能目录~3,238
三个工具 schema~1,001
注入 5 个候选(本版新增)532–549 字节 ≈ 148–152(实测真库)

注入的是什么

Maybe relevant skills for this task: semgrep — Runs a Semgrep security scan over a codebase: detects langu…; code-review-and-quality — Conducts multi-axis code review. Use before merging any cha….
Load any that fit with skill_load, or ignore this and continue without one.

名字 + 截到 60 字符的真实描述。并且明确写着"可以都不用":这一层负责发现,选择仍然在 Agent 手里。

这一栏曾经是 (name, description, path),而那不是描述,是命中的字段名。

实测 467 个候选里 304 个的三个字段全部命中,于是提示行几乎每一行都渲染成同一个括号—— 一个写在括号里的字面量。模型拿到五个陌生技能名,没有任何用途说明,判断相关性的依据为零; 观察到的行为是原样继续 read/edit/pwsh,遥测侧 14 次注入对应 0 次库加载。

代码本意是"显示命中的字段内容",字面实现成了"显示字段名"。所以这不是加了功能,是修了一个 渲染错误:提示从零信息量回到有信息量。代价是每个注入回合多 ~57 字节(329–341 → 532–549),而这个代价换掉的是"每一轮都在付、且拿不到任何回报"的注入。

两处已经取证过的实现细节:

  • 放进 decision.messages,不是 agent.inject()。 preStep 在派发瀑布之前就调用了 inbox.claim(),inject() 的东西要等到下一步才被取走——而这一层要在第一步就起作用。decision.messages 才是当前这一步进入请求的权威批次。
  • 注入的消息带唯一 id。 形状照框架自己的 createUserMessage 抄:{ role, content: [{type:'text',text}], source: {kind}, id }。id 不是装饰:框架消息总是带一个,两条注入共享 id 会让下游无法区分。

调试开关:把任务的 token 写进遥测

默认只记数量,不记词本身——一个关键词可能带着项目名、客户名或漏洞编号。需要排查候选质量时才打开:

# cordis.patch.yml 的行上
- insert:
    - id: skill-router
      name: dsh-skill-router
      config:
        discovery:
          debugTokens: true

或环境变量 DSH_SKILL_ROUTER_DEBUG_TOKENS=1(两条路都留着,因为"cordis 是否把 config 交给挂载的行"这件事本仓库还没验过)。打开后记录里会多一个 tokensUsed 字段。

遥测现在记两件事,因为它们回答的是两个问题

{"at":"…","turn":3,"step":1,"tier":"HIGH","reason":"ok","tokenCount":7,"indexRows":1025,
 "candidateCount":5,"candidates":[…],"arm":"treatment","injected":true,"hintBytes":330}

{"at":"…","kind":"turn-calls","turn":3,"tier":"HIGH","arm":"treatment","injected":true,
 "skillSearchCalls":1,"skillLoadCalls":1,"skillRefCalls":0,"residentSkillCalls":0,"otherToolCalls":7}

第一条写在 step === 1,那时谁也不知道这一回合会不会去搜——所以"提示有没有让 Agent 去搜"必须由第二条回答,两条靠 (sessionKey, turn) 对齐(不是 turn:实测有 14 个 turn 号跨会话重复)。第二类记录只记计数:没有工具参数、没有技能正文、没有用户原文。

按回合的计数是在下一个回合的第一步结算的,因为这个 harness 里没有可挂的回合结束瀑布(已核实:agent/turn-stopping 在 0.1.7-rc.2 里不存在,挂上去会是"永不执行的埋点")。因中止/崩溃而未结算的回合,其数字是丢失而不是错记——对一次测量来说这是正确的失败方向。

随机分臂:为什么"未注入"不是对照,以及为什么分臂单位是会话

曾经把"未注入"的回合当成对照组。那不是对照。 注入与否由 tier 决定,所以两组在构造上就是不同的任务总体:

注入组: 做 Semgrep 安全审计     → 候选明确 → injected=true
"对照": 今天天气怎么样          → 没有候选 → injected=false

两组的搜索率之差说明不了提示的作用——任何差异都可以归因于任务本身不同。

真正要回答的问题窄得多,只有随机分臂能答:

在系统已经认为候选质量很高的同一类任务里,仅仅把候选摆出来,会不会改变 Agent 的行为?

而且干预是持久的,这一点决定了分臂的单位。dsh-agent-loop 把这一步的消息这样落到会话上:

this.session.append('user/message', message, { surfaceOp: 'append' })

surfaceOp: 'append' 意味着提示进入会话 surface,之后每一步的历史都由它派生(实测可见:注入的提示在会话投影里是一个 skill-router 的 surface 节点,约 90 token)。

所以按回合分臂是错的,而且错得单向:

turn 10  treatment  →  提示进入会话 surface
turn 11  control    →  没有新提示,但 turn 10 的提示仍在上下文里

treatment 会污染它之后的所有 control,反之不成立——汇总起来会朝一个已知方向偏,而数字看起来完全正常。

因此分臂单位是会话:

session
   ↓
sha256(sessionKey) → 约 50/50
   ↙                    ↘
control               treatment
该会话所有 HIGH         该会话所有 HIGH
都不注入                都注入

control 会话从头到尾没有提示,干预无法泄漏到它的任何机会里。

代价:两臂现在是不同的会话,能力与上下文可能不同。这是干预持久化之后必须付的代价——"同一个会话内匹配上下文"只有在两臂仍然独立时才值得要,而按回合分臂并不独立。

测量单位:每会话的第一个 HIGH

即使分臂按会话,同一会话里的后续机会也不是独立样本:Agent 搜过一次、学到一个技能,之后的行为已经被它塑形。

所以主要指标只统计每个会话的第一个 eligible opportunity——它是唯一可以证明"先于本实验任何提示"的观测,因为那时还没有任何东西被注入。

插件在每条记录上标 firstEligible;读数脚本把它作为主指标,并单独列出探索性数字(同会话后续机会)和全量汇总,后两者明确标注"不能用来下因果结论"。

实验协议

① 重启 DSH,记下开始时间 T0
② 固定 skill-index(可用技能数应保持 1025;中途变了这批作废重来)
③ **不要续跑已有会话**——但要排除的是**残留**,不是年龄(见下)
④ 收集 ≥50 个**会话**,每个恰好一个合格的首观测(约各半)
⑤ node tools/discovery-report.mjs --since <T0>

注意第④条的门槛单位是会话,不是回合:需要 ≥50 个会话恰好各有一个可配对的首个 HIGH opportunity。

--since 是必需的:旧记录没有 sessionKey,读数脚本会把它们报成"配对不可靠",但只有分析窗口才能把它们真正排除在分母外。

第③条到底在排除什么:残留,不是年龄

firstEligibleSeen 是插件进程内存,重启 DSH 即清空。而 DSH 的 session 是持久、可 resume 的。两者相加会出现:

实验开始前   session A 已经被注入过 skill-router 提示
     ↓
重启 DSH     firstEligibleSeen 清空
     ↓
继续用 session A
     ↓
它的下一个 HIGH  →  firstEligible = true   ← 但 A 已有干预历史

污染没有消失,只是从跨回合变成了跨进程。

要让一个观测无效,需要的是它被暴露过,而"会话出生在 T0 之前"只是这件事的代理——两个方向都会错:

实际情况出生时间判据的表现
假阳性T0 前创建、但从未被注入过:历史里一条提示都没有,观测点是干净的被错误地排除
假阴性T0 后创建、但同一进程内已被注入过判据看不到

所以准入直接判污染本身:会话在 T0 前不得有过 injected: true 的记录。

主指标 = 唯一 sessionKey
       ∩ firstEligible === true
       ∩ paired
       ∩ **T0 前没有投递过提示**      ← 判据的本体
       ∩ sessionCreatedAt 可确认
       ∩ armViolations === 0
       ∩ duplicateFirsts === 0
       ∩ indexRows 单一

这个集合必须由读数脚本在按 T0 裁掉旧记录之前算出来——裁完就再也问不出"这个会话 T0 前被注入过吗"。

报告会把准入的观测分成两类印出来(T0 之后新建 / T0 前创建但从未注入),所以你能看到干净的旧会话贡献了多少,而不是被笼统的一句"T0 之后新建"盖过去。

创建时间无法确认的会话仍然一律排除:一个时间读不出来的会话既可能是新建的,也可能是重启后 resume 的,凭一个猜出来的时间放行等于没有闸。被排除的数量分两类列出(有 T0 前残留 / 创建时间未知),但不从探索性与全量里藏掉。

没给 --since 时闸门是关的,报告会明确报警。

三个成功指标,按因果关系排序:① HIGH 提示后 Agent 是否开始 skill_search(验证触发假设)→ ② 搜到之后是否真的 skill_load(验证候选产生了行为,而不只是被看了一眼)→ ③ 加载的技能与任务是否真的相关(人工抽样)。

50 个会话是"决定下一步"的门槛,不是"项目已被证明"的终点

比例单看会让人忘记 n 有多小。8/25 与 20/25 都算得出一个 Δ,但证据强度差一个量级。所以读数脚本同时给出:

treatment   n=25  搜了 20 / 没搜 5 → 80.0%  95% 区间 [60.9%, 91.1%]
control     n=25  搜了  4 / 没搜 21 → 16.0%  95% 区间 [6.4%, 34.7%]
Δ(差值)                    +64.0 个百分点
区间是否重叠                 不重叠 → 差异方向可信(仍不是效应量估计)

三个刻意的设计:

  • 区间用 Wilson 而不是朴素正态近似 p ± 1.96·sqrt(p(1-p)/n)。后者在实验真正会遇到的取值上会坏掉:25 个会话里 0 次搜索时它给出 [0, 0]——把"没观测到"说成"不可能发生";1 次搜索会给出下界为负的区间。Wilson 在这些位置仍然可解释,实现只多两行。
  • "没搜"/"没加载"是显式字段,读者不必自己减。
  • 指标②给两个分母,因为它们回答不同的问题:条件率(分母=该臂搜过的会话)回答"搜了会不会真的用";全漏斗(分母=全部首观测)回答"一个机会最终有多大比例真的加载了"。只给后者会把"根本没搜"混进来,读起来像"搜了却不用"。

它不是统计模型——只是把"n 这么小时这个比例有多不确定"变成一个数字。

两组区间不重叠不是显著性检验。 那是描述,不是结论。读数脚本因此打印原始 2×2 表,并且在默认输出里不算 p 值——检验属于分析阶段,产品不该把某个检验绑进自己的输出,读者也不该在每次读数里看到一个会被误当成结论的 p 值:

④ 原始 2×2 表
                      search    no search   total
  treatment           20        5           25
  control             4         21          25
  (一行可复制: a=20 b=5 c=4 d=21)

需要时用 --fisher 显式请求(Fisher exact 两尾,零依赖实现,用教科书例子校验过:3/3 vs 0/3 → p=0.1)。样本小时它比正态近似稳妥。一次读数里的多重比较不做校正,p 只回答"这批数据像不像同一分布",不是效应量。

结论已记录(2026-10)

注入这一层的结论是阴性且可解释的:把候选摆到眼前,不足以触发搜索或加载。

因此它保留为「如实展示候选」,不再作为实验推进,也不再据此调整检索策略。要重新评估它,条件是出现新证据(例如某个技能在自然会话里被主动搜索并真的被加载)。上面那张 2×2 的分析工具仍然随仓库发布 —— 它有数据就能跑,只是当前这组数据已经给了答案。

与之相邻的、与"注入是否有用"无关的代码缺陷另有清单,见下一节。

已知边界(现状)

  • 索引只认 Latin script。 tokenizer 是 [^a-z0-9+#._-],所以中文任务("帮我做一次安全审计")产出 0 个关键词。这不是缺陷需要掩盖,而是索引的性质:这类任务记为 reason: "no-searchable-token"(实测占 24.4%)。这一条现在有了明确方向:可发现性靠目录块里的中文触发词,不是靠检索层的匹配 —— 因为目录块是纯文本、模型直接读,而检索层看不见中文。
  • reason 区分"没有匹配"和"没有库"(no-library)。否则一个坏掉的索引会看起来像一个安静的、表现良好的路由器。
  • skill_search 的严格 AND 是第二个瓶颈。 四个关键词必须全部出现,实测 systematic debugging failing test root cause 归零、而 debugging 命中 21 条。它与上面那条一样,属于查询能力的修 bug,不预期提升"模型自发使用"的转化率。

工程约束

为什么零导入。 早先版本从 @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 路由的一处结构性简化。

(发现层的注入是这条规则的一个有界例外,而且它同样不需要 ledger:提示只在 step === 1 出现一次,其内容只用名字与命中字段,并且注入与否是当回合由 tier 决定的——没有跨回合的状态要记。)

测试

npm test

三十三个零依赖脚本(test/ 下三十一个 + experimental/phase2/ 下两个归档实验)。机器上能找到真实技能库时就直接对真库跑,否则在系统临时目录生成夹具库,所以裸克隆也能测:

脚本覆盖内容
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 列表对模型可见;弱匹配不被当作命中
matching-expansion.mjsv1.17 词干+同义词+中英映射:debugging→debug、deploy→release、性能→performance 等跨形/跨义/跨语言匹配;精确词仍命中(回归)
redos-guard.mjsjs/polynomial-redos 的护栏:替代函数与它替换的正则逐例等价(含反斜杠结尾的 Windows 路径,第一版函数只删 /,20 例里错 8 例)、最坏输入为常数级、调用点确实走函数而非又写回正则、host.js 里"量词 + $"正则只能有 1 条(每多一条都要重新论证输入是否有界)
engine-range.mjsdsh.engines.dsh 的范围:每条 OR 分支都带预发布标签(node-semver 的规则,缺了就覆盖不到该 tuple 的 rc)、覆盖 0.1.5/0.1.6/0.1.7/0.2.0/0.2.1/0.2.2、上界 0.3;本仓库零依赖,所以自带一个够用的 satisfies,实测接纳 17 个已发布版本、拒绝 3 个越界版本,并问 dsh --version 确认已装的 harness 落在范围内(CI 无 dsh 时跳过)
discovery-report.mjs实验读数脚本(node tools/discovery-report.mjs):按随机分臂汇总实验(treatment vs control,而不是"注入 vs 未注入"——后者是不同任务总体),主指标只算每会话第一个 HIGH(唯一可证明先于任何提示的观测),并,并且把"未知"与"0 次"严格分开——按回合的计数天生右截断(回合结束才在下一回合落盘),未配对的回合必须报成 unknown 而不是 search=0,否则上升的搜索率会被算成没变化;配对键是 (sessionKey, turn) 而不是 turn(实测 14 个 turn 号跨会话重复),且按回合真正去重(多条发现记录取最后一条、多条计数记录逐项求和,否则同一回合会在分子分母里各算两次);无会话标签的旧记录单独计数;坏行不吞掉;indexRows 出现多个取值时报警(语料换过);token 文本默认不打印
phase2-control.test.mjsPhase-2 四组的纯控制逻辑(归档至 experimental/phase2/):组别解析(未设变量保留旧的随机行为、非法值 fail-closed 而不是回退到随机组)、人工指定技能列表(负对照的空数组合法、去重不换序、坏 JSON / 非字符串项 / 不安全技能名被拒、超限报错而不静默截断)、<skill_content source="library"> 正文信封(拒绝非库来源、不安全名、空正文、被截断的正文)
discovery-ranking.mjs候选生成器的质量护栏:语料频率过滤(全库都有的词不得参与评分、corpusFrequency 如实报道比例、阈值边界 80% 保留 / >80% 丢弃、全部无判别力时给 no-discriminating-token)、按技能名去重(同名只占一个名额、保留最高分)、tier 在去重后的有效 token 上计算(100% 词不能伪造 HIGH)、真正的强候选仍是 HIGH;有真库时抽样验证 skill/skills 确为 100% 词、且安全审计任务把 semgrep 排第一、代码审查任务把 code-review 类排前、候选名不重复
discovery-injection.mjs发现层的接线(HIGH 注入 + 每回合工具计数):注入只发生在 tier === HIGH 且 step === 1、注入消息形状合法(role/content/source/唯一 id)、两次注入 id 不同、step=2 与 NONE 不注入、原文消息未被改动;遥测如实记 injected 与实测 hintBytes;工具调用在回合结束后按回合计数(三个技能工具各记、原生 skill 单独记、其它工具只汇总),且计数按回合归零、记录里没有工具参数与正文
phase2-conditions.test.mjs四组条件的宿主接线(归档至 experimental/phase2/):用真库夹具驱动真实的 agent/pre-step 处理器,逐组断言实际效果——A 一个字节都不追加、B 只追加候选提示且永不含 <skill_content> 正文、C 按任务文本路由并注入排名第一的库技能正文(夹具故意把 DSH_PHASE2_EXPECTED_SKILLS 设成另一个技能,所以 C 不可能是靠读那个标签通过的)、D 注入人工指定的正文而其显式空数组负对照不追加任何东西;遥测记机制与技能名、从不记正文
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.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 替身
experimental/phase2/           归档的 phase-2 实验测试(两个)+ README
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

Plugins associés