🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

技能按需检索 ZiYuan258/dsh-skill-router

DeepSeek Harnessv0.2.0
ZiYuan25814 天前有提交已提供中文文档扫描:含敏感能力

智能体驱动的技能发现与按需加载。 给 DeepSeek Harness 的 Host 插件:新增…

31.5/100
综合分
2
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/18 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查未通过:仓库 package.json 标记 private,未发布到 npm,需从源码安装。以下命令仍可尝试,但可能失败。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add ZiYuan258/dsh-skill-router
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
dsh 原生插件 · skill
装得上吗
本站已在 dsh 0.2.1-rc.5 下真实安装成功
非静态推断
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 14 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装

仓库 package.json 标记 private,未发布到 npm,需从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✓npm 包dsh-skill-router @ 0.2.0
✓Node 引擎要求 >=20.18.0 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/12 09:36:25

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件读取剪贴板或密钥环境变量
查看扫描证据(共 5 处,展示前 5 条)
  • 读取密钥环境变量
    host.js:2474
    : process.env.DSH_SKILL_ROUTER_DEBUG_TOKENS === '1'
  • 写入本地文件
    discovery.js:48
    mkdirSync(dirname(target), { recursive: true })
  • 写入本地文件
    discovery.js:52
    if (statSync(target).size >= cap) renameSync(target, target + '.1')
  • 读取本地文件
    discovery.js:52
    if (statSync(target).size >= cap) renameSync(target, target + '.1')
  • 写入本地文件
    discovery.js:56
    appendFileSync(target, JSON.stringify(record) + '\n', 'utf8')
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 技能发现与按需加载

智能体驱动的技能发现与按需加载,为 DeepSeek Harness 而生:skill_search / skill_load / skill_ref 让庞大技能库不必常驻目录。不是自动注入路由器——由智能体自己决定加载什么。

README

由 DeepSeek 最新模型翻译生成
dsh-skill-router 智能体驱动的技能发现与按需加载。 给 DeepSeek Harness 的 Host 插件:新增 skill_search / skill_load / skill_ref 三个工具,让智能体在需要时自己去技能库里检索并加载技能。 dsh plugin --profile web add github:ZiYuan258/dsh-skill-router 装之前认准两件事。 一、owner 必须是 ZiYuan258。 这个名字下并存 8 个 GitHub 仓库(我是其中最新的一个)。其中若干走自动注入路线:在模型回答前读用户消息,命中就把技能正文塞进提示词。 二、本插件不会自动加载或注入技能正文,默认也不自动提示候选(v1.16.2 起关停)。 它曾把少量候选技能名(只有名字,没有正文)摆到智能体面前,但实测 45 个注入回合 0 次自发加载(见下),故默认关停;README 底部有一张同名仓库的对照表。 你只有十几个技能?这个插件不适合你。 一次工作通常只用到几个技能,所以技能少的时候,让它们常驻目录反而更省——目录本来就是 DSH 的原生机制。 这个插件解决的是另一个问题:技能多到装不进目录。
展开完整文档(README 剩余 1 节)
dsh-skill-router

智能体驱动的技能发现与按需加载。 给 DeepSeek Harness 的 Host 插件:新增 skill_search / skill_load / skill_ref 三个工具,让智能体在需要时自己去技能库里检索并加载技能。

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

装之前认准两件事。

一、owner 必须是 ZiYuan258。 这个名字下并存 8 个 GitHub 仓库(我是其中最新的一个)。其中若干走自动注入路线:在模型回答前读用户消息,命中就把技能正文塞进提示词。

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

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

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

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

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

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

一条已被证伪的假设。 本插件的注入层曾按「智能体不是不会用技能,而是从没人告诉它有哪些技能值得考虑」来设计。
实测把候选摆到面前之后,自发加载仍然为 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+ 技能的库,等于没有。

于是只有两个选项:要么全塞进目录(每轮都贵),要么全放在库外(模型够不着)。本插件提供第三个:库留在库外,智能体需要时自己检索、自己加载。代价从"每轮固定"变成"用到才付"。

成本(实测,不是估计)

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

| 开销 | 实测 | 性质 |
| 常驻工具 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.0-rc.1 起,逐 tuple 列到 0.2.1,上界 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-router | github: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"sh

Linux / macOS

ln -s /mnt/skills-archive "/home/me/work/.skill-src"

链接下 skill_search 与 skill_load 都正常。已知局限:返回的 path 是链接下的路径,不是真实路径——排查问题时若需要真身,用 dir 或 ls -l 看链接目标。

不要用 DSH 的 customSkillDirs 来指向这个库。那个配置的作用是把技能注册成常驻,会立刻让全部技能进入每轮目录——与这个插件的目标正好相反。

三、生成索引

索引由谁生成都行——只要能产出那几列。参考实现(PowerShell,适用于把多个上游仓库检出一到同一目录): sh
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 还能看出检索为什么没命中。

手边核对索引本身: powershell

表头(应为 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 把结果给你看。
- 想让它自动触发某个技能(不必每次提醒),那才需要把它装进常驻区: powershell & "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 | 手工 / 定期 |

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

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

工具参考

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.18.1 · 第 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 发现根因:智能体用 run_code 作唯一工具接口,skill_search/skill_load 调用嵌在 TypeScript 代码体里,不是独立的 tool/call 事件——前三遍全找不到。v1.17.3 在第一遍里解析 run_code 的 code 参数,用正则提取内嵌的 tools.skill_search({query:…}) 和 tools.skill_load({name:…}),让它们也入列。搜索本身仍不算一次加载调用。

零模型 token。 数据全部来自会话账本,而账本由会话作用域插槽交给组件: js // 注册项。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.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。

任务感知的技能发现(HIGH 注入 —— 结论已记录)

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

智能体会不会想到该去找?

库里的技能对模型不可见,所以用上一个的前提是模型自己先想起要搜索。用户说「帮我做一次 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。于是被检验的假设很窄、很因果:

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

这个假设已被证伪:告诉了,也没用。 做法是只改一个变量 —— 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 字符的真实描述。并且明确写着「可以都不用」:这一层负责发现,选择仍然在智能体手里。
这一栏曾经是 (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 写进遥测

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

cordis.patch.yml 的行上

- insert:
- id: skill-router
name: dsh-skill-router
config:
discovery:
debugTokens: true

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

遥测现在记两件事,因为它们回答的是两个问题 json {"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 把这一步的消息这样落到会话上: js
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 决定的——没有跨回合的状态要记。)

测试 sh
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.mjs | v1.17 词干+同义词+中英映射:debugging→debug、deploy→release、性能→performance 等跨形/跨义/跨语言匹配;精确词仍命中(回归) |
| 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.0/0.2.1、上界 0.3;本仓库零依赖,所以自带一个够用的 satisfies,实测接纳 16 个已发布版本、拒绝 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.mjs | Phase-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.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 替身 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
预览分享卡片长什么样 ↓ZiYuan258/dsh-skill-router
分享 · DeepSeek Harness Hub
ZiYuan258/dsh-skill-router

智能体驱动的技能发现与按需加载。 给 DeepSeek Harness 的 Host 插件:新增…

回到顶部