Merge latest master into skill catalog hot refresh
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
|
||||
README.md: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065
|
||||
README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09
|
||||
README.md: 1fe545f15c12a6b540b1feaa7b612ae27c1f4f56
|
||||
README.zh.md: 15911b45c229f41ed2ebca4db22a2050b2d71431
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Local filesystem provider for the `ctx.skills` registry.
|
||||
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
|
||||
## Plugin
|
||||
|
||||
@@ -60,7 +60,7 @@ Indirectly, through `dsh-tool-skill`, which renders this provider's invocable na
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Watcher invalidation can cause the named consumer to append a replacement catalog after the reusable session prefix. Body-only edits leave the catalog digest unchanged.
|
||||
Watcher invalidation can cause the named consumer to append a replacement catalog to the existing request history. Body-only edits leave the catalog digest unchanged.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`ctx.skills` 注册表的本地文件系统提供方。
|
||||
|
||||
该包实现一个 skill 来源。它扫描本地项目、自定义和用户 skill 根,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的加载器工具仍位于 `@deepseek-ai/dsh-tool-skill`。
|
||||
该包实现一个 skill 来源。它扫描本地项目、自定义和用户 skill 根,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的加载器工具仍位于 `@deepseek-ai/dsh-tool-skill`。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -60,7 +60,7 @@ Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 M
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
watcher 触发的失效可促使指定的消费方在可复用会话前缀之后追加替换目录。仅涉及正文的编辑不会改变目录 digest。
|
||||
watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const USER_AGENTS_RANK = 500
|
||||
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200
|
||||
const DEFAULT_WATCH_POLL_INTERVAL_MS = 100
|
||||
const DEFAULT_WATCH_MAX_PROJECTS = 128
|
||||
const BUNDLED_RANK = 600
|
||||
|
||||
export const name = 'skill-local'
|
||||
export const inject = ['skills']
|
||||
@@ -61,6 +62,8 @@ export interface Config {
|
||||
watchMaxProjects?: number
|
||||
/** Whether watched symbolic links follow their target files. */
|
||||
watchFollowSymlinks?: boolean
|
||||
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
|
||||
bundledSkillDir?: string
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = z.object({
|
||||
@@ -73,6 +76,7 @@ export const Config: Schema<Config> = z.object({
|
||||
watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS),
|
||||
watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS),
|
||||
watchFollowSymlinks: z.boolean().default(true),
|
||||
bundledSkillDir: z.string(),
|
||||
})
|
||||
|
||||
interface SkillRoot {
|
||||
@@ -81,6 +85,7 @@ interface SkillRoot {
|
||||
rank: number
|
||||
skipSystem?: boolean
|
||||
projectRoot?: string
|
||||
trustedHost?: boolean
|
||||
}
|
||||
|
||||
interface SkillRootEntry {
|
||||
@@ -132,12 +137,15 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
private readonly watchManager: SkillWatchManager
|
||||
private readonly bundledSkillDir: string | undefined
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config))
|
||||
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
|
||||
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +173,7 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
*/
|
||||
async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as LocalLocator
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
|
||||
if (parsed === undefined) return undefined
|
||||
return {
|
||||
name: parsed.name,
|
||||
@@ -207,6 +215,9 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
...this.bundledSkillDir === undefined
|
||||
? []
|
||||
: [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }],
|
||||
)
|
||||
return roots
|
||||
}
|
||||
@@ -622,7 +633,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
? { path: entry.path, directory: root.path }
|
||||
: undefined
|
||||
if (locator === undefined) continue
|
||||
const parsed = await parseSkillFile(locator.path, ctx)
|
||||
const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)
|
||||
if (parsed === undefined) continue
|
||||
skills.push({
|
||||
name: parsed.name,
|
||||
@@ -643,7 +654,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
|
||||
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
return await listSkillRootEntriesFromNode(root, ctx)
|
||||
}
|
||||
|
||||
@@ -685,8 +696,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom
|
||||
return result
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path, signal)
|
||||
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path, signal, trustedHost)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
@@ -726,10 +737,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
if (fs !== undefined && !trustedHost) {
|
||||
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -170,15 +170,23 @@ describe('LocalSkillProvider', () => {
|
||||
await writeSkill(custom, 'custom-only', 'custom only')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
const bundled = await tempDir('skill-bundled')
|
||||
await writeSkill(bundled, 'bundled-only', 'bundled skill')
|
||||
await writeSkill(bundled, 'same', 'bundled skill')
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled })
|
||||
|
||||
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
|
||||
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
|
||||
['custom-only', 'custom only'],
|
||||
['same', 'project dsh skill'],
|
||||
expect(skills.map(skill => skill.name)).toEqual([
|
||||
'bundled-only',
|
||||
'custom-only',
|
||||
'same',
|
||||
])
|
||||
expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only')
|
||||
expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
|
||||
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
|
||||
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
|
||||
expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
|
||||
expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')
|
||||
|
||||
const noGit = await tempDir('skill-no-git')
|
||||
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
|
||||
@@ -356,6 +364,20 @@ describe('LocalSkillProvider', () => {
|
||||
])
|
||||
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
|
||||
const bundled = await tempDir('skill-backend-bundled')
|
||||
await writeSkill(bundled, 'bundled-host', 'Bundled host skill')
|
||||
const bundledCtx = new Context()
|
||||
await bundledCtx.plugin(TestFileSystem)
|
||||
const bundledFs = bundledCtx.fs as TestFileSystem
|
||||
bundledFs.failResolvePaths.add(bundled)
|
||||
await bundledCtx.plugin(SkillService)
|
||||
await bundledCtx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
bundledSkillDir: bundled,
|
||||
})
|
||||
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
|
||||
})
|
||||
|
||||
it('reports transient root reads as incomplete without caching an empty catalog', async () => {
|
||||
@@ -695,17 +717,22 @@ describe('LocalSkillProvider', () => {
|
||||
it('uses default home root resolution without exposing builtin skills', async () => {
|
||||
const previousDshHome = process.env.DSH_HOME
|
||||
const previousAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR
|
||||
const envHome = await tempDir('skill-env-home')
|
||||
try {
|
||||
process.env.DSH_HOME = join(envHome, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
|
||||
const bundled = join(envHome, 'bundled-skills')
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = bundled
|
||||
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
|
||||
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { watch: false })
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
|
||||
const empty = new Context()
|
||||
await empty.plugin(SkillService)
|
||||
@@ -725,6 +752,11 @@ describe('LocalSkillProvider', () => {
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = previousAgentsHome
|
||||
}
|
||||
if (previousBundledSkillDir === undefined) {
|
||||
delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
} else {
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user