Add skill discovery and loading
This commit is contained in:
425
packages/core/skill/src/index.ts
Normal file
425
packages/core/skill/src/index.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Agent skill discovery and prompt listing.
|
||||
*
|
||||
* Skills are progressive-disclosure instructions: the model sees only a short
|
||||
* listing in the system prompt, then calls the `skill` tool to load the full
|
||||
* `SKILL.md` body when a task matches.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill
|
||||
*/
|
||||
|
||||
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const MAX_PROMPT_FIELD_LENGTH = 500
|
||||
|
||||
export function isSkillName(name: string): boolean {
|
||||
return SKILL_NAME.test(name)
|
||||
}
|
||||
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system'
|
||||
|
||||
export interface SkillSummary {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
directory: string
|
||||
source: SkillSource
|
||||
}
|
||||
|
||||
export interface SkillDefinition extends SkillSummary {
|
||||
content: string
|
||||
path?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
|
||||
disableModelInvocation?: boolean
|
||||
}
|
||||
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Shared agent config root. Defaults to `~/.agents`. */
|
||||
agentsHome?: string
|
||||
/** Extra skill roots, scanned after user roots and before system skills. */
|
||||
extraRoots?: string[]
|
||||
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
|
||||
installSystemSkills?: boolean
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
skills: SkillService
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillRoot {
|
||||
path: string
|
||||
source: SkillSource
|
||||
skipSystem?: boolean
|
||||
}
|
||||
|
||||
const SYSTEM_SKILLS: SkillDefinition[] = [
|
||||
{
|
||||
name: 'dsh-plugin-creator',
|
||||
description: 'Create or update DeepSeek Harness Cordis plugins and packages.',
|
||||
directory: 'system://dsh-plugin-creator',
|
||||
source: 'system',
|
||||
content: [
|
||||
'Use this skill to create DeepSeek Harness plugins that fit the repository architecture.',
|
||||
'',
|
||||
'Prefer Cordis services, plugin packages, effect-scoped registrations, and existing extension seams over loop changes.',
|
||||
'When adding a swappable capability, design the interface/implementation/consumer split first.',
|
||||
'Every registry or registration path needs disposal/HMR coverage.',
|
||||
'Update package docs, architecture docs, package graph references, and generated catalogs when public surfaces change.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'dsh-skill-creator',
|
||||
description: 'Create or update DeepSeek Harness SKILL.md instructions.',
|
||||
whenToUse: 'Use when writing reusable agent instructions for DeepSeek Harness or adding system/project/user skills.',
|
||||
directory: 'system://dsh-skill-creator',
|
||||
source: 'system',
|
||||
content: [
|
||||
'Use this skill to write focused DeepSeek Harness skills.',
|
||||
'',
|
||||
'A skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.',
|
||||
'Frontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.',
|
||||
'Use optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.',
|
||||
'Keep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.',
|
||||
].join('\n'),
|
||||
},
|
||||
]
|
||||
|
||||
export class SkillService extends Service {
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly extraRoots: string[]
|
||||
private readonly installSystemSkills: boolean
|
||||
private readonly runtime = new Map<string, SkillDefinition>()
|
||||
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
|
||||
private runtimeRevision = 0
|
||||
private systemReady: Promise<void> | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'skills')
|
||||
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
||||
this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents'))
|
||||
this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
|
||||
this.installSystemSkills = config.installSystemSkills ?? true
|
||||
if (this.installSystemSkills) {
|
||||
const systemRoot = join(this.dshHome, 'skills/.system')
|
||||
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`failed to install bundled system skills under ${systemRoot}: ${errorMessage(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
ctx.on('agent/request', async (agent, _turn, _step, _request, next) => {
|
||||
const listing = await this.renderModelListing({ cwd: agent.session.header.cwd })
|
||||
const result = await next()
|
||||
if (listing.length > 0) appendSystem(result, listing)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
register(skill: SkillRegistration): () => void {
|
||||
const normalized = normalizeSkill(skill)
|
||||
const dispose = this.ctx.effect(function* (this: SkillService) {
|
||||
this.runtime.set(normalized.name, normalized)
|
||||
this.invalidateCache()
|
||||
yield () => {
|
||||
this.runtime.delete(normalized.name)
|
||||
this.invalidateCache()
|
||||
}
|
||||
}.bind(this), 'skills.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSummary)
|
||||
}
|
||||
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
if (!isSkillName(name)) return undefined
|
||||
return (await this.collect(options)).find(skill => skill.name === name)
|
||||
}
|
||||
|
||||
async renderModelListing(options: SkillLookupOptions = {}): Promise<string> {
|
||||
const skills = await this.list(options)
|
||||
if (skills.length === 0) return ''
|
||||
const entries = skills.map((skill) => {
|
||||
const lines = [
|
||||
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
|
||||
`description: ${promptLine(skill.description)}`,
|
||||
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
|
||||
'</skill>',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}).join('\n')
|
||||
return [
|
||||
'## Skills',
|
||||
'Available skills are listed below. Load a skill with the `skill` tool before following its instructions; do not infer or follow instructions from a skill body that has not been loaded.',
|
||||
'<available_skills>',
|
||||
entries,
|
||||
'</available_skills>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<SkillDefinition[]> {
|
||||
await this.ensureSystemSkills()
|
||||
const roots = await this.roots(options.cwd)
|
||||
const key = collectCacheKey(roots, this.runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const collected = this.collectFresh(roots)
|
||||
this.collectCache.set(key, collected)
|
||||
return collected
|
||||
}
|
||||
|
||||
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
|
||||
const seen = new Set<string>()
|
||||
const result: SkillDefinition[] = []
|
||||
|
||||
const add = (skill: SkillDefinition): void => {
|
||||
if (seen.has(skill.name)) {
|
||||
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.directory} ignored because a higher-priority skill already exists`)
|
||||
return
|
||||
}
|
||||
seen.add(skill.name)
|
||||
result.push(skill)
|
||||
}
|
||||
|
||||
for (const root of roots.project) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
|
||||
}
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) add(skill)
|
||||
for (const root of roots.shared) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
|
||||
const project: SkillRoot[] = []
|
||||
if (cwd !== undefined) {
|
||||
const projectRoot = await findProjectRoot(resolve(cwd))
|
||||
project.push(
|
||||
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
|
||||
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
|
||||
)
|
||||
}
|
||||
const shared: SkillRoot[] = [
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents' },
|
||||
...this.extraRoots.map(path => ({ path, source: 'extra' as const })),
|
||||
{ path: join(this.dshHome, 'skills/.system'), source: 'system' },
|
||||
]
|
||||
return { project, shared }
|
||||
}
|
||||
|
||||
private ensureSystemSkills(): Promise<void> {
|
||||
return this.systemReady ?? Promise.resolve()
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.runtimeRevision += 1
|
||||
this.collectCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
|
||||
await mkdir(systemRoot, { recursive: true })
|
||||
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
|
||||
const dir = join(systemRoot, skill.name)
|
||||
const file = join(dir, 'SKILL.md')
|
||||
try {
|
||||
await access(file)
|
||||
return
|
||||
} catch {
|
||||
// Expected first-run path: the bundled system skill has not been installed.
|
||||
}
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(file, renderSkillFile(skill))
|
||||
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
|
||||
}))
|
||||
}
|
||||
|
||||
function renderSkillFile(skill: SkillDefinition): string {
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
...skill.whenToUse ? [`whenToUse: ${skill.whenToUse}`] : [],
|
||||
'---',
|
||||
'',
|
||||
]
|
||||
return `${frontmatter.join('\n')}${skill.content}\n`
|
||||
}
|
||||
|
||||
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinition[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const skills: SkillDefinition[] = []
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (root.skipSystem && entry.name === '.system') continue
|
||||
const fullPath = join(root.path, entry.name)
|
||||
const parsed = entry.isDirectory()
|
||||
? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
|
||||
: entry.isFile() && entry.name.endsWith('.md')
|
||||
? await parseSkillFile(fullPath, root.path, root.source, ctx)
|
||||
: undefined
|
||||
if (parsed) skills.push(parsed)
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseFrontmatter(raw)
|
||||
if (!parsed) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
|
||||
return undefined
|
||||
}
|
||||
const name = stringField(parsed.data, 'name')
|
||||
const description = stringField(parsed.data, 'description')
|
||||
if (name === undefined || description === undefined) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
|
||||
return undefined
|
||||
}
|
||||
if (!isSkillName(name)) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...optionalMetadata(parsed.data),
|
||||
directory,
|
||||
path,
|
||||
source,
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||
if (!raw.startsWith('---\n')) return undefined
|
||||
const end = raw.indexOf('\n---', 4)
|
||||
if (end < 0) return undefined
|
||||
const yaml = raw.slice(4, end)
|
||||
const bodyStart = raw.indexOf('\n', end + 4)
|
||||
const parsed = parseYaml(yaml) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
||||
return { data: parsed as Record<string, unknown>, body: bodyStart < 0 ? '' : raw.slice(bodyStart + 1) }
|
||||
}
|
||||
|
||||
async function findProjectRoot(cwd: string): Promise<string> {
|
||||
let current = cwd
|
||||
while (true) {
|
||||
try {
|
||||
await access(join(current, '.git'))
|
||||
return current
|
||||
} catch {
|
||||
// Continue walking upward until a git root is found.
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return cwd
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
|
||||
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
|
||||
return { ...skill, source: skill.source }
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, directory, source } = skill
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
directory,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
function compareSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return left.name.localeCompare(right.name)
|
||||
}
|
||||
|
||||
function promptLine(value: string): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= MAX_PROMPT_FIELD_LENGTH) return normalized
|
||||
return `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
|
||||
}
|
||||
|
||||
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
|
||||
const value = data[key]
|
||||
return typeof value === 'boolean' ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
|
||||
const value = data.metadata
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return { metadata: value as Record<string, unknown> }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string {
|
||||
return JSON.stringify({ runtimeRevision, roots })
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function appendSystem(request: GenerateOptions, text: string): void {
|
||||
request.system = [request.system ?? '', text].filter(part => part.length > 0).join('\n\n')
|
||||
}
|
||||
|
||||
export default SkillService
|
||||
Reference in New Issue
Block a user