packages/dsh-skill-explorer
zhu1090093659/dsh-web技能中心:按来源分级浏览已加载的全部 skill,启用/禁用模型调用、创建新技能、删除进可恢复回收站。
DeepSeek Harness 的按需技能工具:注册 skill_search / skill_load / skill_ref 三个工具,让模型先从分层的技能库里检索、再按需加载正文,库里的技能因而不必进入常驻目录、不占每轮上下文;并在对话视图栏加一个「技能」标签页,回读整个会话、列出本次真正加载过哪些技能及其轮次。零依赖,所有技能调用记录只在客户端渲染,不进入模型上下文。
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)。所以:
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 层,没有任何盘符写死。找不到时工具会说明并列出找过哪里。
评分是逐关键词累加,权重顺序体现信息密度:
| 命中字段 | 权重 | 理由 |
|---|---|---|
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
两条硬性约定:
.skill-src。 前导点让它对 DSH 的 skill 扫描器不可见——这正是"库不占目录成本"的机制。若命名为 skills/ 或放进 .dsh/skills/,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 会告诉你(它会比对"磁盘上有"与"索引里有",两个方向都报)。
原来这里是一段要你复制、自己改 $root 的 PowerShell。它能用,但有两个问题:
$root\<repo>\**)、不产出 whenToUse、漏掉没有 frontmatter 的技能——写这段时的参考库里有 3 个这样的技能(微软 monorepo 里),于是它们永远搜不到。实测:用 build-index.mjs 扫同一个库得到 1028 行,而那份参考实现产出的索引只有 1025 条。索引仍然是一份公开契约:任何能产出那 7 列的东西都可以替代 build-index.mjs。契约在第二节上方那张表里。
安装命令见上一章;重启 DSH 后(插件行只在生成新宿主进程时组合)按顺序验证:
skill_search / skill_load / skill_ref。skill_search 查一个你库里确实有的名字。返回里带 library 字段,那是它实际使用的库根——核对这个路径,这是"找错目录"最快的诊断点。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 做:
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 会明确报出
读不到哪个路径,而不是静默失败。
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)。三者由生成脚本一次性写好。
校验两处,判据两条(互相独立;是警告,不是阻止):
.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 发现根因: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 个技能名(…),更早的记录尚未读完";行数与调用数为什么会不一样。 这是正确的,不是重复计数:
事件身份 + 规范化技能名,调用数按事件身份分组,所以一次调用点名多个技能会分成多行(真机上就有一次 skill_load 同时加载了 code-review-and-quality 与 gh-cli);seq(SessionEvent 声明 seq: SessionSeq 是每个事件都有的字段,契约上唯一;而一条 tool/call 事件就是一次调用)。callId 是工具调用与结果的配对 id,契约不保证两条不同调用不会共用它——真按它去重,两条真实调用会静默并成一行、计数悄悄偏低。所以 callId 只在一个事件没有数字 seq 时作为回退标记的一部分;顺序是会话顺序,不是读到的顺序。 事件以"最新在前"到达,更早的页是前插的,所以按读到的先后排会得到倒序(真机报告过第 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。
上面三个工具解决的是"技能很多,怎么让 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 下没有任何解释——那会让这一层静默失效。所以差异留在调用方,权重留在函数里。
开之前实测到的状态是:273 个回合里,库几乎从未被检索过,而每一条遥测都是 injected: false。于是被检验的假设很窄、很因果:
Agent 不是不会用技能,而是从没人告诉它有哪些技能值得考虑。
这个假设已被证伪:告诉了,也没用。 做法是只改一个变量 —— tier === HIGH 时把候选摆出来,检索策略(严格 AND、tokenizer、tier 阈值)一行没动,这样行为变化才能归因到"提示"。结果是:
evaluation 技能;⇒ 指针式注入不改变模型行为。 所以这一层的定位改为「如实展示候选」,而不是「驱动使用」。
成本是这件事值得一试的理由:
| 每轮已付出的 | 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 会让下游无法区分。默认只记数量,不记词本身——一个关键词可能带着项目名、客户名或漏洞编号。需要排查候选质量时才打开:
# 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 会话从头到尾没有提示,干预无法泄漏到它的任何机会里。
代价:两臂现在是不同的会话,能力与上下文可能不同。这是干预持久化之后必须付的代价——"同一个会话内匹配上下文"只有在两臂仍然独立时才值得要,而按回合分臂并不独立。
即使分臂按会话,同一会话里的后续机会也不是独立样本:Agent 搜过一次、学到一个技能,之后的行为已经被它塑形。
所以主要指标只统计每个会话的第一个 eligible opportunity——它是唯一可以证明"先于本实验任何提示"的观测,因为那时还没有任何东西被注入。
插件在每条记录上标 firstEligible;读数脚本把它作为主指标,并单独列出探索性数字(同会话后续机会)和全量汇总,后两者明确标注"不能用来下因果结论"。
① 重启 DSH,记下开始时间 T0
② 固定 skill-index(可用技能数应保持 1025;中途变了这批作废重来)
③ **不要续跑已有会话