Repository Wiki
deepseek-ai/deepseek-harness

文件系统与工作区操作

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。提供者有两个主要阶段:

  1. 发现摘要:list(options) 根据 options.cwd 计算技能根目录,启动或更新观察器,然后为每个根目录发现候选技能摘要。
  2. 按需加载正文:get(candidate, options) 使用候选项中的本地定位信息重新解析技能文件,读取 frontmatter 和正文,并返回完整的 SkillDefinition。

设计上将“目录扫描”和“正文读取”分开:列表查询可以只构造候选项,而真正被选中的技能才读取完整正文;同时 ctx.skills 可以根据候选项的来源和 rank 处理覆盖关系。插件还监听 fs/observed,只对由 mutation tool 产生的主机文件变更主动失效,从而避免无关的文件观察事件反复刷新注册表。

Architecture

Loading diagram...

Source: index.ts Source: index.ts

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 也会在上下文结束时等待该清理过程。

typescript
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 场景。

typescript
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

typescript
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,而不是构造一个不完整技能。

typescript
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

核心时序可以概括为:

Loading diagram...

Source: index.ts

Sources

(2 files)
packages/skill/skill-filesystem/src
packages/skill/skill-filesystem/tests