技能文件从哪几个目录冒出来,又怎样进模型视野
SkillFilesystem 按固定 rank 扫六个技能根目录,按项目优先、全局兜底做覆盖;SkillRegistry 缓存与合并同名技能;tool-skill 每轮预步进把目录清单喂给模型,再按需按需加载正文。技能不是插件,它们是"文件即能力"的一层。
本章以中文撰写
会话刚开头、第一个工具还没被模型选中的时候,发生了一件事:系统提示区里已经躺着一份技能目录清单——几十行、每行一个技能名加一句说明。这份清单不是某个人手写进 system prompt 的,也不是插件静态注册的,而是 tool-skill 在本轮预步进(pre-step)阶段,现场问 SkillRegistry 要出来、再塞进去的。这一章讲这份清单从哪来:技能文件散落在哪几个磁盘目录、按什么优先级合并、为什么”项目目录压过用户目录”是有意设计,以及目录变了之后怎样只在真变了的时候才重发。
六个根目录与固定 rank
技能发现的底层是一个文件系统扫描器 SkillFilesystem。它维护一组”技能根目录”,每个根有一个固定 rank——rank 小的先入候选层,先入者占住同名名额。
本节源码依据(4 处)
| 根目录 | rank | 含义 | 谁能改 |
|---|---|---|---|
项目 .dsh/skills | 100 | 仓库自带、随仓库走的技能 | 提交进 git |
项目 .agents/skills | 200 | 项目级旧约定目录 | 提交进 git |
| 自定义(配置声明) | 300 | 部署方/用户显式加的目录 | settings |
用户 ~/.dsh/skills | 400 | 跨项目的个人技能 | 本机用户 |
用户 ~/.agents/skills | 500 | 用户级旧约定目录 | 本机用户 |
| 运行期注册(内存) | 250 | 插件运行时 register 进来的 | 运行中的插件 |
| 打包技能(bundled) | 600 | 随发行版带的内置技能 | 不可改 |
表里的 rank 数字不是我估的:
PROJECT_DSH_RANK=100到USER_AGENTS_RANK=500是skill-filesystem/src/index.ts里的常量;运行期RUNTIME_RANK=250与打包BUNDLED_SKILL_RANK=600在skill/src/index.ts。
为什么”最近的目录赢”是有意的。 rank 升序意味着:同名技能,项目目录先占住名额,用户目录在后、进不来。这和”用户目录应该能覆盖项目配置”的直觉相反,但技能是给当前仓库量身定做的操作指南——这个仓库的代码约定、构建命令、测试方式,就该由仓库自己的技能说了算;用户全局技能只是兜底。把项目层放在全局层之前,等于声明:进了这个仓库,这个仓库的写法优先于你的个人写法。 这是覆盖优先级的方向性选择,不是排序笔误。
解析 frontmatter:一次合法加载的入口
每个技能根下面是一组 技能名/SKILL.md 文件。parseSkillFile 读它的 YAML frontmatter,抽出 name、description、是否可被模型调用等字段。旧写法里的 invocable、mode 键已经被显式拒绝:
本节源码依据(3 处)
注意 modelInvocable 的默认值是真:只要没写 disableModelInvocation: true,模型就能点名加载它。这把”默认开放、逐个关闭”作为安全姿态——技能作者想收回模型的加载权,得显式关掉,而不是默认不给。
| frontmatter 字段 | 作用 | 默认 |
|---|---|---|
name / description | 进目录清单给模型看的名字与一句说明 | 必填 |
modelInvocable | 模型能否点名加载正文 | 真(未写 disable 即允许) |
userInvocable | 用户能否在对话里显式 @ 它 | 真(未写 false 即允许) |
invocable / mode | 旧写法 | 直接抛错,要求改成新键 |
为什么把 modelInvocable 默认成开。 技能清单的目的是让模型能自助加载操作指南;如果默认关闭,每写一个技能都得显式打开一次,摩擦太大、漏开率高。默认开、显式关的代价是”某个不该被模型碰的技能,作者必须记得写 disable”——这是把默认安全押在作者的注意力上,和密钥系统”默认拦、显式放”的方向相反,因为两者风险面不同:技能被模型读一遍顶多浪费一轮 token,密钥被读一遍就是真泄漏。
这张表里还藏着一个容易被忽略的点:userInvocable 默认真、但和 modelInvocable 是两件事——一个对模型开放、一个对用户 @ 开放。一个技能可以”模型看不见、但用户能显式点名”(比如内部调试技能),反过来也可以”模型能自己加载、但不占用户的快捷入口”。两个开关正交,给了作者四种组合,而不是一个笼统的”开/关”。
SkillRegistry:缓存、合并与同名决胜
SkillRegistry 不直接读盘,它从若干”provider”(文件系统根、运行期注册、打包目录)收集候选,再合并。两个关键设计:
- collect 缓存键包含 scopeChainOf。收集结果按”当前工作区链”做键缓存;工作区一变,键就变,缓存自然失效,跨会话串味被结构上排除。
- provider 失败不炸收集。某一层抛错只作废该层缓存、
warn一行,其它层照常收集;只有能收集到时才写 cacheable 项。
本节源码依据(5 处)
tool-skill:把目录清单喂给模型,正文按需加载
tool-skill 做两件事:注册一个 skill 工具供模型点名加载正文,以及在每轮预步进把完整目录清单注入 system prompt。关键是注入时机——它注册的预步进手势监听先于目录清单监听,保证模型在看到任何工具之前先看到”有哪些技能可用”。
本节源码依据(6 处)
为什么”清单常驻、正文按需”是对的。 如果把每个技能的全文都塞进 system prompt,几十个技能的正文会把上下文吃光;如果什么都不给,模型根本不知道有哪些技能可调用。目录清单(名字 + 一句说明,每条截断到 500 字符)是一份廉价的目录,正文只在模型点名时才加载——这是”目录与正文分离”的标准做法,也是 renderCatalogMessage 要强调”完整清单取代此前所有版本”的原因:模型不该拿着过期清单去调一个已经消失的技能。
两种目录布局与 resourceBase
discoverRoot 对每个根下的一级子条目做两种判断:子条目是目录就读里面的 SKILL.md(bundle 布局,目录本身是 resourceBase),子条目是**.md 文件**就直接当入口(flat 布局,resourceBase 指向根本身)。模型加载正文时会收到一句”相对路径以本目录为基”的提示,让它知道正文里引用的 ./templates/... 从哪算起。
本节源码依据(2 处)
运行期注册:磁盘之外的一层
除了磁盘扫描,插件还能在运行时直接 register 一个内存技能。这层 rank 固定为 250,夹在项目 .agents(200)与自定义(300)之间;同层同名后到者被忽略并 warn。注册返回一个 disposer,插件卸载时自动摘除并失效缓存。
本节源码依据(2 处)
热更:目录变了才重发
技能文件被编辑后,chokidar 监听(depth:1、awaitWriteFinish)触发,isRelevantWatchEvent 过滤掉无关事件,命中技能变化就重收那一层、失效缓存、必要时重发目录清单。
本节源码依据(3 处)
小结
技能不是 Cordis 插件,而是”文件即能力”的一层:六个磁盘根目录按固定 rank 排序合并,项目目录压过用户目录是有意的方向;SkillRegistry 用工作区链做缓存键、逐层容错;tool-skill 每轮预步进喂目录清单、正文按需加载,目录真变了才重发。下一章看另一类完全不同的”工具来源”——第 40 章:外部 MCP server 怎样连进来、它的工具名怎样在全局命名空间里不撞车。