feat(skill): move catalogs into session prefixes

This commit is contained in:
Yichen Jiang
2026-07-10 14:19:06 +08:00
parent b9bf67d0a7
commit 6292d52236
54 changed files with 1019 additions and 691 deletions

View File

@@ -1,19 +1,16 @@
# core/ — product API spine
The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against.
The session log, system-prompt assembly, tool registry, agent vocabulary, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against.
| Package | Role | ctx key |
|---|---|---|
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
| `skill/` | Agent skill provider registry + request-time skill listing | `ctx.skills` |
| `skill-local/` | Local filesystem skill provider | (registers on `ctx.skills`) |
| `tool-skill/` | Model-facing `skill` loader tool | (registers on `ctx.tools`) |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
`agent-core` is the composition counterpart: one bundle plugin that loads the default spine (`timer` + `llm` + sessions + system-prompt + tools + skill registry + local skill provider + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared core while leaving executors, LLM adapters, non-local skill providers, and UI front doors outside the bundle.
`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle.

View File

@@ -14,12 +14,12 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-session event-sourced session log + store
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
@deepseek-ai/dsh-skill skill provider registry + prompt listing
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
@deepseek-ai/dsh-tool-skill the model-facing skill loader schema
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
(dsh-system-prompt gets the forwarded `persona`)
```
@@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core'
// so validation and defaulting can never drift from the owners.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -62,12 +62,14 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
export const name = 'agent-core'
/** Skill bundle config forwarded to the registry and the local provider. */
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Registry-level prompt/cache settings. */
/** Registry-level discovery cache settings. */
registry?: SkillRegistryConfig
/** Local filesystem skill provider settings. */
local?: SkillLocal.Config
/** Model-facing skill catalog and tool settings. */
tool?: toolSkill.Config
}
/**
@@ -75,7 +77,7 @@ export interface SkillConfig {
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), and `skills` to the skill registry/local provider. Every field is
* order), and `skills` to the skill registry/local provider/tool consumer. Every field is
* optional INPUT here because each owner's schema supplies the default (`[]` /
* `''` / absent — lexicographic / the DSH skill roots); the schema is the
* INTERSECTION of the owners' own schemas, so validation and defaulting can
@@ -88,7 +90,7 @@ export interface Config {
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
/** Skill registry and local provider config. */
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
}
@@ -96,6 +98,7 @@ export interface Config {
export const SkillConfigSchema: z<SkillConfig> = z.object({
registry: SkillService.Config,
local: SkillLocal.Config,
tool: toolSkill.Config,
})
/** Intersect the owners' schemas so validation + defaulting stay identical. */
@@ -134,6 +137,6 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(toolSkill)
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}

View File

@@ -7,6 +7,15 @@ import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const empty: Message[] = []
return await ctx.waterfall(
'agent/session-prefix', { session: { header: { cwd } } } as never,
empty, new AbortController().signal, () => Promise.resolve(empty),
)
}
/**
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
@@ -120,7 +129,7 @@ describe('dsh-agent-core bundle', () => {
await ctx.fiber.dispose()
})
it('forwards skill config to the registry and local provider', async () => {
it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-'))
@@ -129,16 +138,17 @@ describe('dsh-agent-core bundle', () => {
const ctx = await mount({
agents: [],
skills: {
registry: { promptFieldMaxLength: 6 },
registry: { collectCacheMaxEntries: 4 },
local: {
dshHome: join(home, '.dsh'),
agentsHome: join(agentsHome, '.agents'),
customSkillDirs: [custom],
},
tool: { catalogDescriptionMaxLength: 6 },
},
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['custom-skill'])
expect(await ctx.skills.renderModelListing()).toContain('description: Cus...')
expect(JSON.stringify(await composePrefix(ctx, '/tmp'))).toContain('- `custom-skill`: Cus...')
await ctx.fiber.dispose()
})

View File

@@ -33,13 +33,13 @@
"path": "../../core/tools"
},
{
"path": "../../core/skill"
"path": "../../skill/skill"
},
{
"path": "../../core/skill-local"
"path": "../../skill/skill-local"
},
{
"path": "../../core/tool-skill"
"path": "../../skill/tool-skill"
},
{
"path": "../../core/agent"

View File

@@ -1,37 +0,0 @@
# @deepseek-ai/dsh-skill-local
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, prompt listing, and model-facing loader tool remain in `@deepseek-ai/dsh-skill` and `@deepseek-ai/dsh-tool-skill`.
## Plugin
Requires `ctx.skills` (`inject: ['skills']`).
### Config
| Field | Default | Meaning |
|---|---|---|
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
## Discovery
Default roots are resolved in this provider's rank order:
| Rank | Source | Path |
|---|---|---|
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
| 300 | `custom` | `Config.customSkillDirs` |
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. DeepSeek Harness no longer ships built-in system skills from this provider; additional built-ins can be supplied later by another provider.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Without a filesystem service, the provider falls back to Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
## Skill Format
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.

View File

@@ -1,38 +0,0 @@
{
"name": "@deepseek-ai/dsh-skill-local",
"description": "Local filesystem skill provider for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,413 +0,0 @@
/**
* Local filesystem skill provider.
*
* This package is one implementation of the `ctx.skills` provider registry. It
* discovers directory-bundle and flat Markdown skills from project, custom, and
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
* filesystem service is present.
*
* @module @deepseek-ai/dsh-skill-local
*/
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import type { Context } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
import {
isSkillName,
type SkillCandidate,
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
type SkillSource,
} from '@deepseek-ai/dsh-skill'
const PROJECT_DSH_RANK = 100
const PROJECT_AGENTS_RANK = 200
const CUSTOM_RANK = 300
const USER_DSH_RANK = 400
const USER_AGENTS_RANK = 500
export const name = 'skill-local'
export const inject = ['skills']
/** Local filesystem skill provider configuration. */
export interface Config {
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
agentsHome?: string
/** Additional skill roots scanned after project roots and before user roots. */
customSkillDirs?: string[]
}
export const Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
customSkillDirs: z.array(z.string()).default([]),
})
interface SkillRoot {
path: string
source: SkillSource
rank: number
skipSystem?: boolean
}
interface SkillRootEntry {
name: string
type: 'directory' | 'file' | 'other'
path: string
}
interface ParsedSkill {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
metadata?: Record<string, unknown>
content: string
}
interface LocalLocator {
path: string
directory: string
}
/** Register the local filesystem skill provider on `ctx.skills`. */
export function apply(ctx: Context, config: Config = {}): void {
const provider = new LocalSkillProvider(ctx, config)
ctx.skills.registerProvider(provider)
}
/** Provider that maps local project/user skill roots into `ctx.skills`. */
export class LocalSkillProvider implements SkillProvider {
readonly name = 'local'
private readonly dshHome: string
private readonly agentsHome: string
private readonly customSkillDirs: string[]
constructor(private readonly ctx: Context, config: Config = {}) {
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
}
/**
* Discover local skill summaries for a cwd-sensitive workspace.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @returns local provider candidates with stable root ranks.
*/
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
const roots = await this.roots(options.cwd)
const candidates: SkillCandidate[] = []
for (const root of roots) {
for (const skill of await discoverRoot(root, this.ctx)) {
candidates.push(skill)
}
}
return candidates
}
/**
* Load a complete local skill body from the candidate's file locator.
* @param candidate - the winning candidate returned by this provider.
* @returns the full local skill, or `undefined` if the file disappeared.
*/
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as LocalLocator
const parsed = await parseSkillFile(locator.path, this.ctx)
if (parsed === undefined) return undefined
return {
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
source: candidate.source,
provider: this.name,
resourceBase: { kind: 'directory', path: locator.directory },
path: locator.path,
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
content: parsed.content,
}
}
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
const roots: SkillRoot[] = []
if (cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
roots.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK },
)
}
roots.push(
...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 },
)
return roots
}
}
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
const skills: SkillCandidate[] = []
const entries = await listSkillRootEntries(root, ctx)
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (root.skipSystem && entry.name === '.system') continue
const locator = entry.type === 'directory'
? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
: entry.type === 'file' && entry.name.endsWith('.md')
? { path: entry.path, directory: root.path }
: undefined
if (locator === undefined) continue
const parsed = await parseSkillFile(locator.path, ctx)
if (parsed === undefined) continue
skills.push({
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
provider: 'local',
source: root.source,
rank: root.rank,
locator,
resourceBase: { kind: 'directory', path: locator.directory },
path: locator.path,
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
})
}
return skills
}
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
return await listSkillRootEntriesFromNode(root, ctx)
}
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
// Skill roots are optional; an absent or unlistable root contributes no skills.
const entries = await fsListDir(fs, root.path).catch(() => undefined)
return entries === undefined ? [] : entries.map(entryFromFs)
}
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
const target = await fs.resolve(path)
return await fs.listDir(target)
}
function entryFromFs(entry: FsDirEntry): SkillRootEntry {
return { name: entry.name, type: entry.type, path: entry.target.displayPath }
}
async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
let entries
try {
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
} catch {
// Missing or unreadable local skill roots are expected in most deployments.
return []
}
const result: SkillRootEntry[] = []
for (const entry of entries) {
const path = join(root.path, entry.name)
const type = await nodeEntryKind(path, entry, ctx)
result.push({ name: entry.name, type: type ?? 'other', path })
}
return result
}
async function parseSkillFile(path: string, ctx: Context): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path)
if (raw === undefined) {
return undefined
}
let parsed
try {
parsed = parseFrontmatter(raw)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
return undefined
}
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),
content: parsed.body.trim(),
}
}
function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function readSkillText(ctx: Context, path: string): Promise<string | undefined> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
return await readSkillTextFromFileSystem(ctx, fs, path)
}
try {
return await readFile(path, 'utf8')
} catch {
return undefined
}
}
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
// A missing or temporarily inaccessible skill file is not fatal to discovery.
const target = await fs.resolve(path).catch(() => undefined)
if (target === undefined) return undefined
const info = await fs.stat(target).catch((error: unknown) => {
ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
return undefined
})
if (info === undefined || info.type !== 'file') return undefined
try {
return await fs.readText(target)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
return undefined
}
}
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
}
async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
if (entry.isDirectory()) return 'directory'
if (entry.isFile()) return 'file'
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
if (!entry.isSymbolicLink()) return undefined
try {
const info = await stat(fullPath)
if (info.isDirectory()) return 'directory'
if (info.isFile()) return 'file'
return undefined
} catch (error) {
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
return undefined
}
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
const firstLineEnd = raw.indexOf('\n')
if (firstLineEnd < 0) return undefined
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
if (firstLine !== '---') return undefined
const start = firstLineEnd + 1
const closing = findClosingFrontmatter(raw, start)
if (closing === undefined) return undefined
const yaml = raw.slice(start, closing.start)
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: raw.slice(closing.bodyStart) }
}
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
let lineStart = start
while (lineStart <= raw.length) {
const nextNewline = raw.indexOf('\n', lineStart)
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
if (line === '---') {
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
}
if (nextNewline < 0) return undefined
lineStart = nextNewline + 1
}
}
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
let current = cwd
while (true) {
if (await pathExists(join(current, '.git'), fs)) {
return current
}
const parent = dirname(current)
if (parent === current) return cwd
current = parent
}
}
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
if (fs !== undefined) {
return await pathExistsInFileSystem(path, fs)
}
return await pathExistsInNode(path)
}
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
let target
try {
target = await fs.resolve(path)
} catch {
// A backend may reject or hide this candidate; continue walking upward.
return false
}
try {
return await fs.stat(target) !== undefined
} catch {
// Transient stat failures make only this git-root candidate unusable.
return false
}
}
async function pathExistsInNode(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
// Missing host paths are expected while walking toward the filesystem root.
return false
}
}
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 errorMessage(error: unknown): string {
return String(error)
}

View File

@@ -1,352 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillLocal from '../src/index.ts'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
await mkdir(root, { recursive: true })
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
failStatPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
try {
const fs = await import('node:fs/promises')
const info = await fs.stat(target.displayPath)
return {
version: FsVersion(String(info.mtimeMs)),
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
size: info.size,
}
} catch {
return undefined
}
}
override async readText(target: FsTarget): Promise<string> {
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
return text
}
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
throw new Error('not needed in skill tests')
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
this.listDirCalls += 1
const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
const result: FsDirEntry[] = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const childPath = join(target.displayPath, entry.name)
let type: FsInfo['type'] = 'other'
let size: number | undefined
try {
const info = await stat(childPath)
type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
size = info.isFile() ? info.size : undefined
} catch {
type = 'other'
}
result.push({
name: entry.name,
type,
target: { targetKey: childPath as never, displayPath: childPath },
version: FsVersion('test'),
...(size !== undefined ? { size } : {}),
})
}
return result
}
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
await mkdir(dirname(target.displayPath), { recursive: true })
await writeFile(target.displayPath, content)
return { operation: 'create', version: FsVersion('test'), before: null, after: content }
}
override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
throw new Error('not needed in skill tests')
}
}
async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
...config,
})
return ctx
}
describe('dsh-skill-local plugin exports', () => {
it('declares stable plugin metadata', () => {
expect(SkillLocal.name).toBe('skill-local')
expect(SkillLocal.inject).toEqual(['skills'])
})
})
describe('LocalSkillProvider', () => {
it('discovers project, custom, user, and agents skill roots in priority order', async () => {
const home = await tempDir('skill-home')
const project = await tempDir('skill-project')
const custom = await tempDir('skill-custom')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
await writeSkill(custom, 'same', 'custom skill')
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
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 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.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
const noGit = await tempDir('skill-no-git')
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
})
it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
const home = await tempDir('skill-runtime-priority')
const project = await tempDir('skill-runtime-project')
const custom = await tempDir('skill-runtime-custom')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
await writeSkill(custom, 'runtime-name', 'Custom loses')
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
ctx.skills.register({
name: 'project-name',
description: 'Runtime loses to project',
content: 'Runtime body.',
source: 'runtime',
})
ctx.skills.register({
name: 'runtime-name',
description: 'Runtime wins',
content: 'Runtime body.',
source: 'runtime',
})
expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
})
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
const home = await tempDir('skill-flat')
const root = join(home, '.dsh/skills')
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
await writeFile(join(root, 'rich-skill.md'), [
'---',
'name: rich-skill',
'description: rich description',
'whenToUse: For richer local parsing',
'disableModelInvocation: false',
'metadata:',
' owner: tests',
'---',
'',
'Rich body.',
].join('\n'))
await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
await writeFile(join(root, 'notes.txt'), 'ignored')
await mkdir(join(root, 'not-a-skill'), { recursive: true })
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
const ctx = await setupLocal(home)
const listedBeforeDelete = await ctx.skills.list()
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
if (flatSummary === undefined) throw new Error('expected flat-skill')
await writeFile(join(root, 'flat-skill.md'), '')
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
disableModelInvocation: false,
metadata: { owner: 'tests' },
})
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
const home = await tempDir('skill-frontmatter-crlf')
const root = join(home, '.dsh/skills')
await mkdir(root, { recursive: true })
await writeFile(join(root, 'crlf-skill.md'), [
'---',
'name: crlf-skill',
'description: CRLF skill',
'metadata:',
' marker: "----"',
'---',
'',
'CRLF body.',
].join('\r\n'))
await writeFile(join(root, 'block-skill.md'), [
'---',
'name: block-skill',
'description: |',
' Includes a ---- marker that is not a delimiter.',
'---',
'',
'Block body.',
].join('\n'))
const ctx = await setupLocal(home)
expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
})
it('skips invalid YAML skill files without hiding valid siblings', async () => {
const home = await tempDir('skill-invalid-yaml')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'good-skill', 'Good skill')
await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
})
it('discovers symlinked skill directories and flat files', async () => {
const home = await tempDir('skill-symlink-home')
const external = await tempDir('skill-symlink-external')
await writeSkill(external, 'linked-dir', 'Linked directory')
await writeFlatSkill(external, 'linked-flat', 'Linked flat')
await mkdir(join(home, '.dsh/skills'), { recursive: true })
await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
})
it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
const home = await tempDir('skill-read-fs')
const project = await tempDir('skill-project-root-backend')
const nestedCwd = join(project, 'packages/app')
const root = join(home, '.dsh/skills')
await mkdir(nestedCwd, { recursive: true })
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
await mkdir(join(root, 'empty-dir'), { recursive: true })
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
Buffer.from([0xff]),
Buffer.from('\n'),
]))
await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
fs.failStatPaths.add(join(root, 'stat-fail.md'))
fs.failResolvePaths.add(join(nestedCwd, '.git'))
fs.failStatPaths.add(join(project, 'packages/.git'))
fs.statOverrides.set(join(project, '.git'), {
version: FsVersion('virtual-git'),
type: 'directory',
size: 0,
})
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
['text-skill', 'user-dsh'],
])
expect(fs.listDirCalls).toBeGreaterThan(0)
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
})
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 envHome = await tempDir('skill-env-home')
try {
process.env.DSH_HOME = join(envHome, '.dsh')
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
const empty = new Context()
await empty.plugin(SkillService)
SkillLocal.apply(empty, {})
expect(await empty.skills.list()).toEqual([])
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME
} else {
process.env.DSH_HOME = previousDshHome
}
if (previousAgentsHome === undefined) {
delete process.env.DSH_AGENTS_HOME
} else {
process.env.DSH_AGENTS_HOME = previousAgentsHome
}
}
})
})

View File

@@ -1,15 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../skill" }
]
}

View File

@@ -1,38 +0,0 @@
# @deepseek-ai/dsh-skill
Agent skill provider registry and model-facing skill guidance.
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
## Service: `SkillService` (ctx key: `skills`)
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace, merged across providers.
- `ctx.skills.get(name, { cwd? })` Returns the full winning skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
- `ctx.skills.renderModelListing({ cwd? })` Renders the request-time `## Skills` catalog.
### Config
| Field | Default | Meaning |
|---|---|---|
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing; must be at least `3` because truncated fields reserve `...`. |
| `collectCacheMaxEntries` | `128` | Maximum cwd/provider discovery promises kept in memory. |
## Provider Contract
A provider returns `SkillCandidate[]` from `list(options)` and later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a future HTTP provider can store a URL, id, or version token.
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final model-visible summary list is sorted by skill `name` for deterministic prompt text and provider prefix-cache friendliness.
## Runtime Skills
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Prompt Integration
The service listens on `system-prompt/assemble` and appends a short `## Skills` section to the calling agent's assembled system prompt. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or absolute local paths. `description` and `whenToUse` are whitespace-normalized, capped, XML-escaped, and have `{{` / `}}` delimiters split so provider text cannot trip prompt-variable interpolation. Models load full instructions through the `skill` tool.
The prompt-injection surface is intentionally separate from provider loading: changing where skills come from means adding or swapping providers, not changing prompt assembly or the `skill` tool.

View File

@@ -1,37 +0,0 @@
{
"name": "@deepseek-ai/dsh-skill",
"description": "Agent skill provider registry and prompt listing for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,483 +0,0 @@
/**
* Agent skill registry and request-time catalog rendering.
*
* This package is the interface third of the skill capability seam. Concrete
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
* from; this service only merges provider catalogs, resolves the winning skill
* for a name, and exposes the model-facing catalog/tool consumers use.
*
* @module @deepseek-ai/dsh-skill
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-agent'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_PROMPT_FIELD_LENGTH = 500
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
const SKILL_PROMPT_SECTION_ORDER = 1000
/**
* Return whether a string is a valid kebab-case skill name.
* @param name - candidate skill name to validate.
* @returns whether the name matches the public skill-name grammar.
*/
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =
| { kind: 'directory'; path: string }
| { kind: 'url'; url: string }
| { kind: 'opaque'; description: string }
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
export interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
name: string
/** Short routing description shown to the model. */
description: string
/** Optional extra routing guidance shown to the model. */
whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
disableModelInvocation?: boolean
/** Discovery source that produced this winning skill. */
source: SkillSource
/** Provider that owns this skill body. */
provider: string
/** Provider-specific base for relative resources. */
resourceBase?: SkillResourceBase
}
/** Provider catalog entry used by the registry to merge and later load skills. */
export interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
locator: unknown
/** Absolute file path when the provider has one. */
path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
metadata?: Record<string, unknown>
}
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
export interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after any provider-specific metadata removal. */
content: string
/** Absolute file path when the skill came from disk. */
path?: string
/** Parsed optional metadata object from frontmatter. */
metadata?: Record<string, unknown>
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
/** Workspace selector used for cwd-sensitive provider discovery. */
export interface SkillLookupOptions {
cwd?: string | undefined
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
name: string
/**
* List available skill candidates for the current lookup context.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns provider candidates with precedence ranks and opaque locators.
*/
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
}
/** Skill registry configuration. */
export interface Config {
/** Maximum rendered description/whenToUse length in the prompt listing; minimum 3. */
promptFieldMaxLength?: number
/** Maximum number of cwd/provider discovery promises kept in the in-memory cache. */
collectCacheMaxEntries?: number
}
declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider became resolvable in the `ctx.skills` registry.
* Consumers can observe this instead of depending on Cordis plugin load
* order, which is concurrent for sibling plugins.
* @param provider - the provider that just registered.
* @mode emit
*/
'skill/provider-added'(provider: SkillProvider): void
/**
* A skill provider left the registry because its plugin fiber was disposed.
* @param name - the registry name that no longer resolves.
* @mode emit
*/
'skill/provider-removed'(name: string): void
}
}
interface IndexedCandidate {
candidate: SkillCandidate
provider: SkillProvider
providerOrder: number
localOrder: number
}
interface CollectResult {
entries: IndexedCandidate[]
cacheable: boolean
}
/**
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted model-visible summaries, loads
* full skill bodies on demand, and renders the request-time catalog fragment.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
})
private readonly promptFieldMaxLength: number
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, Promise<IndexedCandidate[]>>()
private providerRevision = 0
private nextProviderOrder = 0
private runtimeRevision = 0
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength, 3)
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
const agent = context.agent
if (agent === undefined) return result
const listing = await this.renderModelListing({ cwd: agent.session.header.cwd })
if (listing.length > 0) {
result.sections.push({
name: 'skills:available',
order: SKILL_PROMPT_SECTION_ORDER,
text: listing,
})
}
return result
})
}
/**
* Register a skill provider. Throws if another provider already owns the same
* provider name, including the reserved runtime provider name. Effect-scoped
* and HMR-safe: disposing the caller's fiber unregisters the provider and
* invalidates cached catalogs.
* @param provider - the provider to register by `provider.name`.
* @returns a disposer that unregisters this provider.
*/
registerProvider(provider: SkillProvider): () => void {
const dispose = this.ctx.effect(function* (this: SkillService) {
if (provider.name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(provider.name)) {
throw new Error(`a skill provider named "${provider.name}" is already registered`)
}
this.providers.set(provider.name, { provider, order: this.nextProviderOrder })
this.nextProviderOrder += 1
this.invalidateCache()
yield () => {
this.providers.delete(provider.name)
this.invalidateCache()
this.ctx.emit('skill/provider-removed', provider.name)
}
this.ctx.emit('skill/provider-added', provider)
}.bind(this), 'skills.registerProvider()')
return () => void dispose()
}
/**
* Register a runtime skill contribution. Runtime registrations are treated as
* embedded provider entries with project-over-user priority. Same-name runtime
* registrations are first-wins: a duplicate logs a warning and gets a no-op
* disposer so it cannot remove the active contribution.
* @param skill - the complete skill definition to expose for discovery.
* @returns a disposer that removes this runtime contribution and invalidates caches.
*/
register(skill: SkillRegistration): () => void {
const normalized = normalizeRuntimeSkill(skill)
const existing = this.runtime.get(normalized.name)
if (existing !== undefined) {
this.ctx.logger.warn(`runtime skill "${normalized.name}" ignored because it is already registered`)
return () => {}
}
const dispose = this.ctx.effect(function* (this: SkillService) {
this.runtime.set(normalized.name, normalized)
this.runtimeRevision += 1
this.invalidateCache()
yield () => {
this.runtime.delete(normalized.name)
this.runtimeRevision += 1
this.invalidateCache()
}
}.bind(this), 'skills.register()')
return () => void dispose()
}
/**
* List model-invocable skill summaries for a workspace.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @returns sorted summaries, excluding skills disabled for model invocation.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSummary)
}
/**
* Load one full skill definition by name.
* @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns the full skill, including body content, or `undefined`.
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
return await match.provider.get(match.candidate, options)
}
/**
* Render the request-time `## Skills` prompt fragment.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns an empty string when no model-invocable skills are available.
*/
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, this.promptFieldMaxLength)}`,
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
'</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<IndexedCandidate[]> {
const key = collectCacheKey(options, this.providerRevision, this.runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return cached
const collected = this.collectFresh(options)
const cachedPromise = collected.then((result) => {
if (!result.cacheable) this.collectCache.delete(key)
return result.entries
}).catch((error: unknown) => {
this.collectCache.delete(key)
throw error
})
this.collectCache.set(key, cachedPromise)
if (this.collectCache.size > this.collectCacheMaxEntries) {
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
this.collectCache.delete(oldest.value)
}
return cachedPromise
}
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
const collected = await this.listAllCandidates(options)
collected.entries.sort(compareIndexedCandidates)
const seen = new Set<string>()
const result: IndexedCandidate[] = []
for (const entry of collected.entries) {
const skill = entry.candidate
if (seen.has(skill.name)) {
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
continue
}
seen.add(skill.name)
result.push(entry)
}
return { entries: result, cacheable: collected.cacheable }
}
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
const candidates: IndexedCandidate[] = []
let cacheable = true
let runtimeOrder = 0
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) {
candidates.push({
candidate: runtimeCandidate(skill),
provider: RUNTIME_SKILL_PROVIDER,
providerOrder: -1,
localOrder: runtimeOrder,
})
runtimeOrder += 1
}
for (const { provider, order } of this.providers.values()) {
let localOrder = 0
const listed = await provider.list(options).catch((error: unknown) => {
cacheable = false
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
return undefined
})
if (listed === undefined) continue
for (const candidate of listed) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
}
}
return { entries: candidates, cacheable }
}
private invalidateCache(): void {
this.providerRevision += 1
this.collectCache.clear()
}
}
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
name: RUNTIME_PROVIDER,
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
list() {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillDefinition
return Promise.resolve({ ...skill })
},
}
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
return {
...toSummary(skill),
rank: RUNTIME_RANK,
locator: skill,
...skill.path !== undefined ? { path: skill.path } : {},
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
}
}
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (!SKILL_NAME.test(candidate.name)) {
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
}
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
if (!Number.isFinite(candidate.rank)) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
}
if (candidate.provider !== providerName) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
}
}
function normalizeRuntimeSkill(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,
provider: skill.provider ?? RUNTIME_PROVIDER,
source: skill.source,
}
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
}
}
function compareSummary(left: SkillSummary, right: SkillSummary): number {
return left.name.localeCompare(right.name)
}
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
return left.candidate.rank - right.candidate.rank
|| left.providerOrder - right.providerOrder
|| left.localOrder - right.localOrder
}
function promptLine(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= maxLength
? normalized
: `${normalized.slice(0, maxLength - 3)}...`
return escapeText(breakPromptTemplateDelimiters(truncated))
}
function breakPromptTemplateDelimiters(value: string): string {
return value.replaceAll('{{', '{ {').replaceAll('}}', '} }')
}
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
if (!Number.isInteger(value) || value < minimum) {
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
}
}
function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
}
function errorMessage(error: unknown): string {
return String(error)
}
export default SkillService

View File

@@ -1,260 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
function agentForCwd(cwd: string): never {
return { session: { header: { cwd } } } as never
}
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
name,
description,
provider: 'memory',
source: 'memory',
rank,
locator: { content: body },
}
}
class MemoryProvider implements SkillProvider {
readonly name = 'memory'
listCalls = 0
constructor(private candidates: SkillCandidate[]) {}
async list(_options: SkillLookupOptions): Promise<SkillCandidate[]> {
this.listCalls += 1
return this.candidates
}
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as { content: string }
return { ...candidate, content: locator.content }
}
replace(candidates: SkillCandidate[]): void {
this.candidates = candidates
}
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([
memorySkill('z-skill', 'Z skill', 20),
memorySkill('a-skill', 'A skill', 10),
memorySkill('shadowed', 'Lower priority', 20),
])
const overrideProvider: SkillProvider = {
name: 'override',
async list() {
return [{
name: 'shadowed',
description: 'Higher priority',
provider: 'override',
source: 'override',
rank: 5,
locator: { content: 'Override body.' },
}]
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
const disposeMemory = ctx.skills.registerProvider(provider)
ctx.skills.registerProvider(overrideProvider)
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
['a-skill', 'A skill', 'memory'],
['shadowed', 'Higher priority', 'override'],
['z-skill', 'Z skill', 'memory'],
])
expect((await ctx.skills.get('shadowed'))?.content).toBe('Override body.')
const sameRankProvider: SkillProvider = {
name: 'same-rank',
async list() {
return [{
name: 'same-rank-skill',
description: 'Same rank',
provider: 'same-rank',
source: 'same-rank',
rank: 10,
locator: { content: 'Same rank body.' },
}]
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
ctx.skills.registerProvider(sameRankProvider)
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
await expect(ctx.plugin({
name: 'duplicate-memory',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(new MemoryProvider([]))
},
})).rejects.toThrow('already registered')
expect(() => ctx.skills.registerProvider({
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
})).toThrow('reserved')
disposeMemory()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('validates provider candidates and invalid registry caps', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
name: 'bad',
async list() {
return [memorySkill('Bad_Name', 'bad', 1)]
},
async get() {
return undefined
},
})
await expect(ctx.skills.list()).rejects.toThrow('invalid skill name')
const invalidCandidates = [
{ ...memorySkill('empty-description', '', 1), provider: 'empty-description' },
{ ...memorySkill('bad-rank', 'Bad rank', Number.NaN), provider: 'bad-rank' },
{ ...memorySkill('wrong-provider', 'Wrong provider', 1), provider: 'different' },
]
for (const candidate of invalidCandidates) {
const invalid = new Context()
await invalid.plugin(SkillService)
invalid.skills.registerProvider({
name: candidate.name,
async list() {
return [candidate]
},
async get() {
return undefined
},
})
await expect(invalid.skills.list()).rejects.toThrow('skill provider')
}
await expect(new Context().plugin(SkillService, { promptFieldMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
await expect(new Context().plugin(SkillService, { collectCacheMaxEntries: 1.5 })).rejects.toThrow('collectCacheMaxEntries')
})
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
const ctx = new Context()
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
ctx.skills.registerProvider(provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
provider.replace([memorySkill('second-skill', 'Second', 10)])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
const disposeRuntime = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime',
source: 'runtime',
resourceBase: { kind: 'opaque', description: 'runtime memory' },
path: 'memory://runtime-skill',
metadata: { owner: 'tests' },
content: 'Runtime body.',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill', 'second-skill'])
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
content: 'Runtime body.',
path: 'memory://runtime-skill',
metadata: { owner: 'tests' },
})
disposeRuntime()
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
let fail = true
let flakyCalls = 0
ctx.skills.registerProvider({
name: 'flaky',
async list() {
flakyCalls += 1
if (fail) throw new Error('transient discovery failure')
return [{ ...memorySkill('flaky-skill', 'Flaky', 10), provider: 'flaky' }]
},
async get() {
return undefined
},
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(1)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(2)
fail = false
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
expect(flakyCalls).toBe(3)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
expect(flakyCalls).toBe(3)
})
it('renders stable prompt guidance and omits it when no skills exist', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { persona: 'base' })
await ctx.plugin(SkillService, { promptFieldMaxLength: 6 })
ctx.skills.registerProvider(new MemoryProvider([
{
...memorySkill('escaped-skill', 'Use </available_skills><oops> safely', 10),
whenToUse: 'Handle <tag> & marker',
},
]))
const listing = await ctx.skills.renderModelListing()
expect(listing).toContain('description: Use...')
expect(listing).toContain('whenToUse: Han...')
expect(listing).not.toContain('</available_skills><oops>')
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).toContain('## Skills')
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('## Skills')
const empty = new Context()
await empty.plugin(SystemPrompt, { persona: 'base' })
await empty.plugin(SkillService)
expect(await empty.skills.renderModelListing()).toBe('')
expect(renderPrompt(await empty.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).not.toContain('## Skills')
const direct = new SkillService(new Context(), {})
expect(await direct.renderModelListing()).toBe('')
const short = new Context()
await short.plugin(SkillService)
short.skills.registerProvider(new MemoryProvider([memorySkill('short-skill', 'Short', 10)]))
expect(await short.skills.renderModelListing()).toContain('description: Short')
const templated = new Context()
await templated.plugin(SystemPrompt, { persona: 'base' })
await templated.plugin(SkillService)
templated.skills.registerProvider(new MemoryProvider([memorySkill('templated-skill', 'Use {{placeholder}} safely', 10)]))
const prompt = renderPrompt(await templated.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))
expect(prompt).toContain('description: Use { {placeholder} } safely')
})
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
disposeSecond()
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
disposeFirst()
expect(await ctx.skills.get('same-skill')).toBeUndefined()
})
})

View File

@@ -1,15 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../agent" },
{ "path": "../system-prompt" }
]
}

View File

@@ -1,15 +0,0 @@
# @deepseek-ai/dsh-tool-skill
The model-facing `skill` tool for loading full skill instructions.
Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
## Tool: `skill`
| Arg | Type | Notes |
|---|---|---|
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns a text block containing `<skill_content name="...">`, the skill body, and provider resource guidance. Local filesystem skills include a base directory for resolving relative files; remote or embedded providers can return URL or opaque provider-managed guidance instead. Unknown names, invalid names, and skills marked `disableModelInvocation: true` return `isError` tool results through the normal tool registry error path.
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.

View File

@@ -1,39 +0,0 @@
{
"name": "@deepseek-ai/dsh-tool-skill",
"description": "Model-facing skill loading tool for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,73 +0,0 @@
/**
* Model-facing `skill` tool.
*
* @module @deepseek-ai/dsh-tool-skill
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { isSkillName, type SkillDefinition } from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['tools', 'skills']
export function apply(ctx: Context): void {
const skillTool = defineTool({
name: 'skill',
description: 'Load the full instructions for one available skill by name. Use this when the current task matches a skill listed in the system prompt.',
parameters: {
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
},
async execute(args, exec) {
if (!isSkillName(args.name)) {
throw new Error(`invalid skill name "${args.name}"`)
}
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd })
if (!skill) {
throw new Error(`unknown skill "${args.name}"`)
}
if (skill.disableModelInvocation === true) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
return [{ type: 'text', text: renderSkillContent(skill) }]
},
presentCall(args) {
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
},
})
ctx.tools.register(skillTool)
}
function renderSkillContent(skill: SkillDefinition): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
'',
skill.content,
'',
...resourceHint,
'</skill_content>',
].join('\n')
}
function renderResourceHint(skill: SkillDefinition): string[] {
const base = skill.resourceBase
if (base === undefined) {
return [`Resources for this skill are managed by provider "${skill.provider}".`]
}
switch (base.kind) {
case 'directory':
return [
`Base directory for this skill: ${base.path}`,
'Resolve relative files mentioned by this skill against the base directory before using them.',
]
case 'url':
return [`Base URL for this skill: ${base.url}`]
case 'opaque':
return [`Resources for this skill: ${base.description}`]
default:
return assertNever(base, 'SkillResourceBase.kind')
}
}

View File

@@ -1,149 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
async function setup(home: string): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(toolSkill)
return ctx
}
describe('dsh-tool-skill', () => {
it('registers the skill tool schema and removes it on dispose', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const home = await tempDir('tool-schema')
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
const fiber = await ctx.plugin(toolSkill)
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
card: 'generic',
title: 'Load skill project-skill',
kind: 'read',
rawInput: 'project-skill',
})
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
})
it('loads a skill for the calling agent cwd', async () => {
const home = await tempDir('tool-load')
const project = await tempDir('tool-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
const ctx = await setup(home)
const result = await ctx.tools.execute({
callId: CallId('c1'),
name: 'skill',
arguments: { name: 'project-skill' },
agent: { session: { header: { cwd: project } } } as never,
})
expect(result.isError).toBe(false)
const block = result.content[0]
expect(block?.type).toBe('text')
if (block?.type !== 'text') throw new Error('expected text skill result')
expect(block.text).toContain('<skill_content name="project-skill">')
expect(block.text).toContain('Project instructions.')
})
it('renders provider-managed resource hints for non-local skills', async () => {
const home = await tempDir('tool-resource-hints')
const ctx = await setup(home)
ctx.skills.register({
name: 'opaque-skill',
description: 'Opaque skill',
source: 'runtime',
provider: 'runtime',
resourceBase: { kind: 'opaque', description: 'runtime memory' },
content: 'Opaque instructions.',
})
ctx.skills.register({
name: 'url-skill',
description: 'URL skill',
source: 'runtime',
provider: 'runtime',
resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
content: 'URL instructions.',
})
ctx.skills.register({
name: 'provider-skill',
description: 'Provider skill',
source: 'runtime',
provider: 'runtime',
content: 'Provider instructions.',
})
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
throw new Error('expected text tool results')
}
expect(opaque.content[0].text).toContain('Resources for this skill: runtime memory')
expect(url.content[0].text).toContain('Base URL for this skill: https://skills.example.test/url-skill')
expect(provider.content[0].text).toContain('Resources for this skill are managed by provider "runtime"')
})
it('fails loud on an unknown resource base kind', async () => {
const home = await tempDir('tool-resource-assert-never')
const ctx = await setup(home)
ctx.skills.register({
name: 'rogue-resource-skill',
description: 'Rogue resource skill',
source: 'runtime',
provider: 'runtime',
resourceBase: { kind: 'future' } as never,
content: 'Rogue instructions.',
})
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
expect(result.isError).toBe(true)
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected text tool result')
expect(block.text).toContain('unreachable variant')
})
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
const home = await tempDir('tool-errors')
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
const ctx = await setup(home)
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
expect(unknown.isError).toBe(true)
expect(invalid.isError).toBe(true)
expect(disabled.isError).toBe(true)
})
})

View File

@@ -1,16 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../agent" },
{ "path": "../skill" },
{ "path": "../tools" }
]
}