文件系统与工作区操作
skill-filesystem 是 DeepSeek Harness 中将本地文件系统技能目录接入 ctx.skills 注册表的提供者(provider)。它按工作区当前目录发现项目级、定制、用户级和 bundled 技能,解析 SKILL.md 或扁平 Markdown 技能文件,并在需要时通过 ctx.fs 读取内容与触发失效通知。
Purpose and Scope
本文档覆盖 @deepseek-ai/dsh-skill-filesystem 的本地技能发现、优先级、内容加载、配置以及文件变化观察机制。重点是“文件系统中的技能如何进入工作区技能注册表并被读取”。
本文不展开 @deepseek-ai/dsh-fs 的通用文件系统抽象、CLI 启动流程、技能执行器或 UI 展示;这些属于相邻能力。若需要了解 CLI 如何组合该插件,可参考 apps/cli/composition.md 中的 skill-filesystem 组件说明。
Overview
该插件实现 SkillProvider,并通过 apply(ctx, config) 注册到 ctx.skills。提供者有两个主要阶段:
- 发现摘要:
list(options)根据options.cwd计算技能根目录,启动或更新观察器,然后为每个根目录发现候选技能摘要。 - 按需加载正文:
get(candidate, options)使用候选项中的本地定位信息重新解析技能文件,读取 frontmatter 和正文,并返回完整的SkillDefinition。
设计上将“目录扫描”和“正文读取”分开:列表查询可以只构造候选项,而真正被选中的技能才读取完整正文;同时 ctx.skills 可以根据候选项的来源和 rank 处理覆盖关系。插件还监听 fs/observed,只对由 mutation tool 产生的主机文件变更主动失效,从而避免无关的文件观察事件反复刷新注册表。
Architecture
apply 是插件入口:它向 ctx.skills 注册 provider,注册 Cordis effect 以便销毁 watcher,并订阅 fs/observed。FileSystemSkillProvider 保存已解析的根目录配置,创建 SkillWatchManager,并把 control.invalidate 交给观察器。根目录本身由 list 计算,因而同一个 provider 能针对不同 cwd 处理不同项目工作区。
插件注册与生命周期
apply 不直接执行目录扫描,而是把 provider 工厂交给 ctx.skills.registerProvider。真正创建 provider 时,传入当前 Context、provider control 和配置。control 的 abort 信号会触发 provider 的 dispose();Cordis effect 也会在上下文结束时等待该清理过程。
1export function apply(ctx: Context, config: Config = {}): void {
2 let provider!: FileSystemSkillProvider
3 ctx.skills.registerProvider((control) => {
4 provider = new FileSystemSkillProvider(ctx, control, config)
5 return provider
6 })
7 ctx.effect(function* () {
8 yield async () => { await provider.dispose() }
9 }, 'skill-filesystem watcher')
10 ctx.on('fs/observed', (target, _observation, actor) => {
11 if (mutationToolName(actor) === undefined) return
12 provider.observeHostMutation(target.displayPath)
13 })
14}Source: index.ts
这里的失效策略是有意收窄的:只有 mutationToolName(actor) 能识别为一方 mutation tool 时才调用 observeHostMutation。因此外部或无关的 fs/observed 事件不会自动改变技能目录的缓存状态。
根目录解析与覆盖优先级
Config 暴露了根目录组合和观察器行为。默认情况下 includeDefaultRoots 为 true,provider 会同时考虑项目根、用户根以及默认 bundled 根;customSkillDirs 被解析为绝对路径,并位于项目根之后、用户根之前。dshHome 默认由 $DSH_HOME 或 ~/.dsh 推导,agentsHome 默认由 $DSH_AGENTS_HOME 或 ~/.agents 推导。
provider 构造函数还特别处理 bundledSkillDir:如果调用者没有显式传入该值,只有在启用默认根时才读取 DSH_BUNDLED_SKILL_DIR。这样,隔离的 provider 不会意外重新发现应用级 bundled skills。
测试验证了同名技能的实际结果:项目 .dsh/skills 可以覆盖 runtime/custom/user 技能;custom 技能可以与其他来源并存;.system 下的隐藏系统项不会进入列表;bundled 技能会以 source: 'bundled' 返回。测试还说明,当项目目录没有 .git 时仍存在 fallback root 场景。
1async function setupLocal(home: string, config: Partial<SkillFileSystem.Config> = {}): Promise<Context> {
2 const ctx = new Context()
3 await ctx.plugin(SkillRegistry)
4 await ctx.plugin(SkillFileSystem, {
5 dshHome: join(home, '.dsh'),
6 agentsHome: join(home, '.agents'),
7 watch: false,
8 ...config,
9 })
10 return ctx
11}Source: skill-filesystem.spec.ts
1const skills = await ctx.skills.list({ cwd: join(project, 'src') })
2expect(skills.map(skill => skill.name)).toEqual([
3 'bundled-only',
4 'custom-only',
5 'same',
6])
7expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
8expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
9expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
10expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
11expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')Source: skill-filesystem.spec.ts
根目录的优先级由稳定 rank 表示:项目 .dsh、项目 .agents、custom、用户 .dsh、用户 .agents 分别使用递增 rank;bundled root 使用 BUNDLED_SKILL_RANK。实际覆盖结果不是简单的“最后扫描者获胜”,而是候选项来源和 rank 交由 ctx.skills 注册表统一裁决。这使 filesystem provider 能与 runtime provider 共存,并让项目技能覆盖 runtime,同时让 runtime 覆盖 custom/user 的场景可被注册表测试验证。
发现与正文加载流程
list(options) 首先调用 roots(options.cwd),因此工作区目录是发现行为的输入,而不是固定的进程当前目录。随后它调用 watchManager.observeRoots(roots):
- 观察器启动成功时,返回完整的
SkillCandidate[]。 - 观察器启动失败时,如果 provider 尚未处于 disposal 状态,仍继续扫描并把结果包装为
{ candidates, complete: false },让上层知道候选结果可能不完整。 - 如果已经进入 disposal,再次出现观察器错误会抛出,以免关闭过程静默吞掉生命周期问题。
扫描每一个 root,并调用 discoverRoot(root, this.ctx, this.name)。候选项包含来源、provider 名称以及可供 get 使用的 locator;正文并不在 list 阶段完全加载。
get(candidate, options) 将 locator 转换为 LocalLocator,调用 parseSkillFile,并把解析后的 frontmatter 字段、正文、来源、provider 和 resource base 组装为完整定义。若文件在列表和加载之间消失,解析返回 undefined,get 也返回 undefined,而不是构造一个不完整技能。
1async list(options: SkillLookupOptions): Promise<SkillCandidate[] | SkillProviderObservation> {
2 const roots = await this.roots(options.cwd)
3 let complete = true
4 try {
5 await this.watchManager.observeRoots(roots)
6 } catch (error) {
7 if (this.disposal !== undefined) throw error
8 complete = false
9 }
10 const candidates: SkillCandidate[] = []
11 for (const root of roots) {
12 for (const skill of await discoverRoot(root, this.ctx, this.name)) {
13 candidates.push(skill)
14 }
15 }
16 return complete ? candidates : { candidates, complete }
17}
18
19async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
20 const locator = candidate.locator as LocalLocator
21 const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
22 if (parsed === undefined) return undefined
23 return {
24 name: parsed.name,
25 description: parsed.description,
26 ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
27 invocation: parsed.invocation,
28 source: candidate.source,
29 provider: this.name,
30 resourceBase: { kind: 'directory', path: locator.directory },
31 path: parsed.path,
32 ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
33 content: parsed.content,
34 }
35}Source: index.ts
核心时序可以概括为:
Source: index.ts