refactor: split skill providers
This commit is contained in:
@@ -7,12 +7,13 @@ The packages every harness build is assembled from: the session log, the system-
|
||||
| `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 discovery + request-time skill listing | `ctx.skills` |
|
||||
| `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 providerless/executor-less/UI-less spine as code | (loads the spine) |
|
||||
| `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 whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + skills + 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 exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.
|
||||
`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-agent-core
|
||||
|
||||
The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
|
||||
This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle.
|
||||
|
||||
@@ -14,9 +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-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-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
@@ -27,6 +30,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
@@ -35,11 +39,12 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
|
||||
// so validation and defaulting can never drift from the owners'.
|
||||
// { agents?, persona?, skills? } — the schema is z.intersect([AgentLoop.Config,
|
||||
// SystemPrompt.Config, { skills }]), 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`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. 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 `''`), 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.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-core",
|
||||
"description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)",
|
||||
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + invariants + tool-bash + tool-skill + agent-loop)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* The providerless, executor-less, UI-less agent spine as ONE bundle plugin.
|
||||
* The default executor-less, UI-less agent spine as ONE bundle plugin.
|
||||
*
|
||||
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
|
||||
* service, the session store, system-prompt assembly, the tool registry, the
|
||||
* skill registry, the agent registry, the dev-mode invariants, the model-facing
|
||||
* `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* skill registry plus local skill provider, the agent registry, the dev-mode
|
||||
* invariants, the model-facing `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* list as its OWN config (default `[]`), so each app supplies its own
|
||||
* pre-created agents.
|
||||
*
|
||||
@@ -19,6 +19,9 @@
|
||||
* (a console logger, `hmr`) — these are the coupled "front-door cluster" the
|
||||
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
|
||||
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
|
||||
* - additional SKILL PROVIDERS; the bundle ships the local filesystem provider
|
||||
* because local skills are default agent behavior, while embedded or remote
|
||||
* providers remain deployment choices.
|
||||
*
|
||||
* This is the interface/implementation/consumer seam at the composition level:
|
||||
* the bundle owns the shared spine, the leaf owns the backends, the app package
|
||||
@@ -49,7 +52,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SkillService, { type Config as SkillConfig } from '@deepseek-ai/dsh-skill'
|
||||
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
@@ -58,12 +62,20 @@ 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. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level prompt/cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` to the system-prompt plugin (the
|
||||
* deployment's persona section), and `skills` to the skill service. All three
|
||||
* are optional INPUT here because each owner's schema supplies the default
|
||||
* deployment's persona section), and `skills` to the skill registry/local
|
||||
* provider. All three are optional INPUT here because each owner's schema supplies the default
|
||||
* (`[]` / `''` / the DSH skill roots); the schema is the INTERSECTION of the
|
||||
* owners' own schemas, so validation and defaulting can never drift from them.
|
||||
*/
|
||||
@@ -72,12 +84,15 @@ export interface Config {
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
/** Skill discovery roots, system-skill installation, and prompt/cache bounds. */
|
||||
/** Skill registry and local provider config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
export const SkillConfigSchema = SkillService.Config
|
||||
export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
registry: SkillService.Config,
|
||||
local: SkillLocal.Config,
|
||||
})
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([
|
||||
@@ -105,12 +120,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// introduce different ones.
|
||||
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(SkillService, config.skills ?? {})
|
||||
ctx.plugin(SkillService, config.skills?.registry ?? {})
|
||||
ctx.plugin(SkillLocal, config.skills?.local ?? {})
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolSkill)
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
export type { SkillConfig }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
@@ -9,7 +9,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/**
|
||||
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
|
||||
* up the whole providerless spine in one `ctx.plugin`, and the forwarded
|
||||
* up the whole default spine in one `ctx.plugin`, and the forwarded
|
||||
* `agents` config reaches the loop (default `[]`, or a pre-created agent).
|
||||
*
|
||||
* The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE
|
||||
@@ -65,7 +65,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
describe('dsh-agent-core bundle', () => {
|
||||
it('brings up the full providerless spine', async () => {
|
||||
it('brings up the full default spine', async () => {
|
||||
const ctx = await mount()
|
||||
// One service from each layer of the spine proves the children loaded.
|
||||
expect(ctx.get('timer')).toBeDefined()
|
||||
@@ -79,15 +79,12 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('includes the default skill system and skill tool', async () => {
|
||||
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
|
||||
const ctx = await mount()
|
||||
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(expect.arrayContaining([
|
||||
'dsh-plugin-creator',
|
||||
'dsh-skill-creator',
|
||||
]))
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -122,18 +119,25 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config to the skill service', async () => {
|
||||
it('forwards skill config to the registry and local provider', 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-'))
|
||||
await mkdir(custom, { recursive: true })
|
||||
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
|
||||
const ctx = await mount({
|
||||
agents: [],
|
||||
skills: {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(agentsHome, '.agents'),
|
||||
installSystemSkills: false,
|
||||
registry: { promptFieldMaxLength: 6 },
|
||||
local: {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(agentsHome, '.agents'),
|
||||
customSkillDirs: [custom],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['custom-skill'])
|
||||
expect(await ctx.skills.renderModelListing()).toContain('description: Cus...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -143,10 +147,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
agentCore.apply(ctx, { agents: [] })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(expect.arrayContaining([
|
||||
'dsh-plugin-creator',
|
||||
'dsh-skill-creator',
|
||||
]))
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../core/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../core/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tool-skill"
|
||||
},
|
||||
|
||||
37
packages/core/skill-local/README.md
Normal file
37
packages/core/skill-local/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @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.
|
||||
38
packages/core/skill-local/package.json
Normal file
38
packages/core/skill-local/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
413
packages/core/skill-local/src/index.ts
Normal file
413
packages/core/skill-local/src/index.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
352
packages/core/skill-local/tests/skill-local.spec.ts
Normal file
352
packages/core/skill-local/tests/skill-local.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
15
packages/core/skill-local/tsconfig.json
Normal file
15
packages/core/skill-local/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -1,54 +1,38 @@
|
||||
# @deepseek-ai/dsh-skill
|
||||
|
||||
Agent skill discovery and model-facing skill guidance.
|
||||
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.list({ cwd? })` Returns model-invocable skill summaries for the current workspace.
|
||||
- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
|
||||
- `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 |
|
||||
|---|---|---|
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; system skills live under `skills/.system`. |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
|
||||
| `extraRoots` | `[]` | Additional skill roots scanned after user roots and before system skills. |
|
||||
| `installSystemSkills` | `true` | Whether startup materializes bundled system skills under `dshHome`. |
|
||||
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing; must be at least `3` because truncated fields reserve `...`. |
|
||||
| `collectCacheMaxEntries` | `128` | Maximum cwd/root discovery promises kept in memory. |
|
||||
| `collectCacheMaxEntries` | `128` | Maximum cwd/provider discovery promises kept in memory. |
|
||||
|
||||
### Discovery
|
||||
## Provider Contract
|
||||
|
||||
Default roots are resolved in this conflict priority order:
|
||||
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.
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Project DSH | `<projectRoot>/.dsh/skills` |
|
||||
| Project agents | `<projectRoot>/.agents/skills` |
|
||||
| Runtime | `ctx.skills.register(...)` |
|
||||
| User DSH | `~/.dsh/skills` |
|
||||
| User agents | `~/.agents/skills` |
|
||||
| Extra | `Config.extraRoots` |
|
||||
| System | `~/.dsh/skills/.system` |
|
||||
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.
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, that ancestor lookup probes `.git` through the filesystem service rather than the host filesystem so remote or sandboxed workspaces keep their own project boundary. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
|
||||
## Runtime Skills
|
||||
|
||||
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and installs system skills through `ctx.fs.writeText`. Without a filesystem service, the package falls back to Node filesystem I/O for project-root lookup, discovery, reads, and installation so the service can still run in minimal test contexts. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
|
||||
|
||||
Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and active disposer calls invalidate the cache; duplicate runtime registrations do not alter the active set. Disk-only changes are picked up on the next invalidation or process restart.
|
||||
|
||||
## 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.
|
||||
`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 local absolute paths. `description` and `whenToUse` are whitespace-normalized and capped in the listing so one pathological skill cannot bloat every model request. Models load full instructions through the `skill` tool.
|
||||
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.
|
||||
|
||||
## System Skills
|
||||
|
||||
On startup, the service ensures bundled system skills exist under `~/.dsh/skills/.system` unless `installSystemSkills: false` is configured. Project, runtime, user, and extra-root skills can override system skills by name.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill",
|
||||
"description": "Agent skill discovery and prompt listing for the DeepSeek Harness",
|
||||
"description": "Agent skill provider registry and prompt listing for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -23,19 +23,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.4.2"
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
/**
|
||||
* Agent skill discovery and prompt listing.
|
||||
* Agent skill registry and request-time catalog rendering.
|
||||
*
|
||||
* 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.
|
||||
* 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 { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { Context, Service } 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 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. */
|
||||
@@ -29,10 +27,16 @@ export function isSkillName(name: string): boolean {
|
||||
return SKILL_NAME.test(name)
|
||||
}
|
||||
|
||||
/** Origin bucket for a discovered skill. The value is prompt-visible metadata, not part of precedence by itself. */
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system'
|
||||
/** 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 & {})
|
||||
|
||||
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into the request prompt. */
|
||||
/** 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
|
||||
@@ -42,43 +46,68 @@ export interface SkillSummary {
|
||||
whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
disableModelInvocation?: boolean
|
||||
/** Base directory for resolving skill-relative references. */
|
||||
directory: string
|
||||
/** 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 frontmatter removal. */
|
||||
/** Markdown instruction body after any provider-specific metadata removal. */
|
||||
content: string
|
||||
/** Absolute file path when the skill came from disk; runtime skills may omit it. */
|
||||
/** 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, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
|
||||
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
|
||||
|
||||
/** Workspace selector used for cwd-sensitive project-root discovery. */
|
||||
/** Workspace selector used for cwd-sensitive provider discovery. */
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
}
|
||||
|
||||
/** Skill plugin configuration. */
|
||||
/** 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 {
|
||||
/** 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
|
||||
/** 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
|
||||
/** Maximum rendered description/whenToUse length in the prompt listing; minimum 3. */
|
||||
promptFieldMaxLength?: number
|
||||
/** Maximum number of cwd/root discovery promises kept in the in-memory cache. */
|
||||
/** Maximum number of cwd/provider discovery promises kept in the in-memory cache. */
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
|
||||
@@ -86,88 +115,63 @@ 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 SkillRoot {
|
||||
path: string
|
||||
source: SkillSource
|
||||
skipSystem?: boolean
|
||||
interface IndexedCandidate {
|
||||
candidate: SkillCandidate
|
||||
provider: SkillProvider
|
||||
providerOrder: number
|
||||
localOrder: number
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
]
|
||||
interface CollectResult {
|
||||
entries: IndexedCandidate[]
|
||||
cacheable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill discovery service. It scans project/user/system skill roots, exposes
|
||||
* model-visible summaries, loads full skill bodies on demand, and injects the
|
||||
* stable `## Skills` listing into each agent request.
|
||||
* 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({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
extraRoots: z.array(z.string()).default([]),
|
||||
installSystemSkills: z.boolean().default(true),
|
||||
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
|
||||
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
|
||||
})
|
||||
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly extraRoots: string[]
|
||||
private readonly installSystemSkills: boolean
|
||||
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<SkillDefinition[]>>()
|
||||
private readonly collectCache = new Map<string, Promise<IndexedCandidate[]>>()
|
||||
private providerRevision = 0
|
||||
private nextProviderOrder = 0
|
||||
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 ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
|
||||
this.installSystemSkills = config.installSystemSkills ?? true
|
||||
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)
|
||||
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('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
const result = await next()
|
||||
@@ -186,24 +190,56 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a runtime skill contribution.
|
||||
* Same-name runtime registrations are first-wins: a duplicate logs a warning
|
||||
* and returns a no-op disposer so it cannot remove the active contribution.
|
||||
* 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 = normalizeSkill(skill)
|
||||
const normalized = normalizeRuntimeSkill(skill)
|
||||
const existing = this.runtime.get(normalized.name)
|
||||
if (existing !== undefined) {
|
||||
this.ctx.logger.warn(`runtime skill "${normalized.name}" from ${normalized.source} ignored because it is already registered from ${existing.source}`)
|
||||
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()')
|
||||
@@ -217,6 +253,7 @@ export class SkillService extends Service {
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSummary)
|
||||
@@ -225,17 +262,19 @@ export class SkillService extends Service {
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* @param name - kebab-case skill name.
|
||||
* @param options - lookup options; `cwd` selects the project roots to scan.
|
||||
* @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
|
||||
return (await this.collect(options)).find(skill => skill.name === name)
|
||||
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 the project roots to scan.
|
||||
* @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> {
|
||||
@@ -259,15 +298,16 @@ export class SkillService extends Service {
|
||||
].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)
|
||||
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(roots)
|
||||
const cachedPromise = collected.catch((error: unknown) => {
|
||||
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
|
||||
})
|
||||
@@ -279,345 +319,116 @@ export class SkillService extends Service {
|
||||
return cachedPromise
|
||||
}
|
||||
|
||||
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
|
||||
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
const collected = await this.listAllCandidates(options)
|
||||
collected.entries.sort(compareIndexedCandidates)
|
||||
const seen = new Set<string>()
|
||||
const result: SkillDefinition[] = []
|
||||
|
||||
const add = (skill: SkillDefinition): void => {
|
||||
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.directory} ignored because a higher-priority skill already exists`)
|
||||
return
|
||||
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(skill)
|
||||
result.push(entry)
|
||||
}
|
||||
|
||||
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
|
||||
return { entries: result, cacheable: collected.cacheable }
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
|
||||
const project: SkillRoot[] = []
|
||||
if (cwd !== undefined) {
|
||||
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
|
||||
project.push(
|
||||
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
|
||||
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
|
||||
)
|
||||
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
|
||||
}
|
||||
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()
|
||||
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.runtimeRevision += 1
|
||||
this.providerRevision += 1
|
||||
this.collectCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
|
||||
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
|
||||
const dir = join(systemRoot, skill.name)
|
||||
const file = join(dir, 'SKILL.md')
|
||||
if (await skillFileExists(ctx, file)) {
|
||||
return
|
||||
}
|
||||
await writeSkillText(ctx, file, renderSkillFile(skill))
|
||||
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
|
||||
}))
|
||||
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 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[]> {
|
||||
const skills: SkillDefinition[] = []
|
||||
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 parsed = entry.type === 'directory'
|
||||
? await parseSkillFile(join(entry.path, 'SKILL.md'), entry.path, root.source, ctx)
|
||||
: entry.type === 'file' && entry.name.endsWith('.md')
|
||||
? await parseSkillFile(entry.path, root.path, root.source, ctx)
|
||||
: undefined
|
||||
if (parsed) skills.push(parsed)
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
interface SkillRootEntry {
|
||||
name: string
|
||||
type: 'directory' | 'file' | 'other'
|
||||
path: string
|
||||
}
|
||||
|
||||
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 {
|
||||
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, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | 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
|
||||
}
|
||||
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...optionalMetadata(parsed.data),
|
||||
directory,
|
||||
path,
|
||||
source,
|
||||
content: parsed.body.trim(),
|
||||
...toSummary(skill),
|
||||
rank: RUNTIME_RANK,
|
||||
locator: skill,
|
||||
...skill.path !== undefined ? { path: skill.path } : {},
|
||||
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function skillFileExists(ctx: Context, path: string): Promise<boolean> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
const target = await fs.resolve(path)
|
||||
return await fs.stat(target) !== undefined
|
||||
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}"`)
|
||||
}
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
// Expected first-run path: the bundled system skill has not been installed.
|
||||
return false
|
||||
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}"`)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSkillText(ctx: Context, path: string, content: string): Promise<void> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
await fs.writeText(await fs.resolve(path), content)
|
||||
return
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
|
||||
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 normalizeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
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 }
|
||||
return {
|
||||
...skill,
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
source: skill.source,
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, directory, source } = skill
|
||||
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
directory,
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,12 +436,22 @@ 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(truncated)
|
||||
return escapeText(breakPromptTemplateDelimiters(truncated))
|
||||
}
|
||||
|
||||
function breakPromptTemplateDelimiters(value: string): string {
|
||||
return value.replaceAll('{{', '{ {').replaceAll('}}', '} }')
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
@@ -639,29 +460,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
}
|
||||
}
|
||||
|
||||
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('<', '<')
|
||||
}
|
||||
@@ -670,8 +468,8 @@ function escapeText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string {
|
||||
return JSON.stringify({ runtimeRevision, roots })
|
||||
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,748 +1,260 @@
|
||||
import { describe, expect, it, vi } 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 { describe, expect, it } from 'vitest'
|
||||
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 SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
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`)
|
||||
}
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
}
|
||||
|
||||
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')
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
provider: 'memory',
|
||||
source: 'memory',
|
||||
rank,
|
||||
locator: { content: body },
|
||||
}
|
||||
}
|
||||
|
||||
describe('SkillService', () => {
|
||||
it('discovers project, user, agents, and system skill roots in priority order', async () => {
|
||||
const home = await tempDir('skill-home')
|
||||
const agentsHome = await tempDir('agents-home')
|
||||
const project = await tempDir('skill-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
class MemoryProvider implements SkillProvider {
|
||||
readonly name = 'memory'
|
||||
listCalls = 0
|
||||
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'same', 'system skill')
|
||||
await writeSkill(join(agentsHome, '.agents/skills'), 'same', 'user agents skill')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
|
||||
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
|
||||
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'system-only', 'system only')
|
||||
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, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
|
||||
|
||||
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
|
||||
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
|
||||
['same', 'project dsh skill'],
|
||||
['system-only', 'system only'],
|
||||
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),
|
||||
])
|
||||
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
|
||||
})
|
||||
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)
|
||||
|
||||
it('sorts the final model-visible list by skill name after priority conflict resolution', async () => {
|
||||
const home = await tempDir('skill-sorted-home')
|
||||
const agentsHome = await tempDir('skill-sorted-agents')
|
||||
const project = await tempDir('skill-sorted-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(project, '.dsh/skills'), 'z-project', 'Project skill')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'm-user', 'User skill')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'a-system', 'System skill')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'm-user', 'Shadowed system skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list({ cwd: project })).map(skill => [skill.name, skill.description])).toEqual([
|
||||
['a-system', 'System skill'],
|
||||
['m-user', 'User skill'],
|
||||
['z-project', 'Project skill'],
|
||||
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('gives project skills priority over runtime skills while runtime overrides user and system skills', async () => {
|
||||
const home = await tempDir('skill-runtime-priority')
|
||||
const project = await tempDir('skill-runtime-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'runtime-name', 'System loses')
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
ctx.skills.register({
|
||||
name: 'project-name',
|
||||
description: 'Runtime loses to project',
|
||||
content: 'Runtime body.',
|
||||
directory: 'memory://project-name',
|
||||
source: 'runtime',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'runtime-name',
|
||||
description: 'Runtime wins',
|
||||
content: 'Runtime body.',
|
||||
directory: 'memory://runtime-name',
|
||||
source: 'runtime',
|
||||
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')
|
||||
|
||||
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')
|
||||
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('does not scan .system twice through the user dsh root', async () => {
|
||||
const home = await tempDir('skill-system')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'builtin', 'builtin skill')
|
||||
|
||||
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
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(['builtin'])
|
||||
})
|
||||
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'])
|
||||
|
||||
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
|
||||
const home = await tempDir('skill-flat')
|
||||
await writeFlatSkill(join(home, '.dsh/skills'), 'flat-skill', 'flat description', 'Flat instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
|
||||
await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
|
||||
await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
|
||||
await writeFile(join(home, '.dsh/skills/plain-markdown.md'), '# Notes\nNot a skill.')
|
||||
await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
|
||||
await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
|
||||
await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
|
||||
await writeFile(join(home, '.dsh/skills/notes.txt'), 'ignored')
|
||||
await mkdir(join(home, '.dsh/skills/not-a-skill'), { recursive: true })
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'hidden description', 'Hidden.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body'])
|
||||
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
|
||||
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 = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
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 poisoning discovery cache', 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 = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
await writeFile(join(root, 'bad-yaml.md'), '---\nname: fixed-skill\ndescription: Fixed skill\n---\n\nFixed body.\n')
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
const dispose = ctx.skills.register({
|
||||
const disposeRuntime = ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime skill',
|
||||
content: 'Runtime body.',
|
||||
directory: 'memory://runtime',
|
||||
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(['fixed-skill', 'good-skill', 'runtime-skill'])
|
||||
dispose()
|
||||
})
|
||||
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' })
|
||||
|
||||
it('does not cache a rejected discovery promise', async () => {
|
||||
const home = await tempDir('skill-rejected-cache')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
const internals = ctx.skills as unknown as {
|
||||
collectFresh(roots: unknown): Promise<unknown[]>
|
||||
}
|
||||
const original = internals.collectFresh.bind(ctx.skills)
|
||||
let fail = true
|
||||
internals.collectFresh = async (roots: unknown) => {
|
||||
if (fail) throw new Error('transient discovery failure')
|
||||
return await original(roots)
|
||||
}
|
||||
|
||||
await expect(ctx.skills.list()).rejects.toThrow('transient discovery failure')
|
||||
fail = false
|
||||
await writeSkill(join(home, '.dsh/skills'), 'late-good', 'Late good')
|
||||
await expect(ctx.skills.list()).resolves.toMatchObject([{ name: 'late-good' }])
|
||||
})
|
||||
|
||||
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 = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
|
||||
})
|
||||
|
||||
it('honors prompt and cache bounds from config', async () => {
|
||||
const home = await tempDir('skill-config-bounds')
|
||||
const firstProject = await tempDir('skill-config-first')
|
||||
const secondProject = await tempDir('skill-config-second')
|
||||
await mkdir(join(firstProject, '.git'), { recursive: true })
|
||||
await mkdir(join(secondProject, '.git'), { recursive: true })
|
||||
await writeSkill(join(firstProject, '.dsh/skills'), 'first-skill', 'abcdefghij')
|
||||
await writeSkill(join(secondProject, '.dsh/skills'), 'second-skill', 'Second')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
installSystemSkills: false,
|
||||
promptFieldMaxLength: 6,
|
||||
collectCacheMaxEntries: 1,
|
||||
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.renderModelListing({ cwd: firstProject })).toContain('description: abc...')
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
await writeSkill(join(firstProject, '.dsh/skills'), 'late-first', 'Late first')
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
await ctx.skills.list({ cwd: secondProject })
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill', 'late-first'])
|
||||
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('rejects invalid positive-integer config caps', async () => {
|
||||
const home = await tempDir('skill-invalid-config')
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(SkillService, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
installSystemSkills: false,
|
||||
promptFieldMaxLength: 0,
|
||||
})).rejects.toThrow('promptFieldMaxLength')
|
||||
await expect(ctx.plugin(SkillService, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
installSystemSkills: false,
|
||||
promptFieldMaxLength: 2,
|
||||
})).rejects.toThrow('greater than or equal to 3')
|
||||
await expect(ctx.plugin(SkillService, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
installSystemSkills: false,
|
||||
collectCacheMaxEntries: 1.5,
|
||||
})).rejects.toThrow('collectCacheMaxEntries')
|
||||
})
|
||||
|
||||
it('renders no model listing when no model-invocable skills exist', async () => {
|
||||
const home = await tempDir('skill-empty-listing')
|
||||
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, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect(await ctx.skills.renderModelListing()).toBe('')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd(home) }))).not.toContain('## Skills')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('## Skills')
|
||||
})
|
||||
|
||||
it('supports default home root resolution without installing system skills', async () => {
|
||||
const previousDshHome = process.env.DSH_HOME
|
||||
const envHome = await tempDir('skill-env-home')
|
||||
try {
|
||||
process.env.DSH_HOME = join(envHome, '.dsh')
|
||||
await new Context().plugin(SkillService, { installSystemSkills: false })
|
||||
|
||||
delete process.env.DSH_HOME
|
||||
await new Context().plugin(SkillService, { installSystemSkills: false })
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = previousDshHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps constructor defaults when schema preprocessing is not involved', async () => {
|
||||
const home = await tempDir('skill-constructor-defaults')
|
||||
const service = new SkillService(new Context(), {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
})
|
||||
|
||||
expect((await service.list()).map(skill => skill.name)).toEqual(['dsh-plugin-creator', 'dsh-skill-creator'])
|
||||
})
|
||||
|
||||
it('installs system skills into the DSH home without overwriting existing files', async () => {
|
||||
const home = await tempDir('skill-install')
|
||||
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
|
||||
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
|
||||
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Custom system skill\n---\n\nCustom body.\n')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
|
||||
['dsh-plugin-creator', 'Custom system skill'],
|
||||
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
|
||||
])
|
||||
expect(await readFile(existing, 'utf8')).toContain('Custom body.')
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
|
||||
})
|
||||
|
||||
it('uses the filesystem service when installing bundled system skills', async () => {
|
||||
const home = await tempDir('skill-install-fs')
|
||||
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
|
||||
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
|
||||
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TestFileSystem)
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
|
||||
['dsh-plugin-creator', 'Existing system skill'],
|
||||
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
|
||||
])
|
||||
expect(await readFile(existing, 'utf8')).toContain('Existing body.')
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
|
||||
})
|
||||
|
||||
it('renders bundled system skill files with and without routing metadata', async () => {
|
||||
const home = await tempDir('skill-install-render')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.skills.list()
|
||||
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md'), 'utf8')).not.toContain('whenToUse:')
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('whenToUse:')
|
||||
})
|
||||
|
||||
it('uses the filesystem service for skill file reads when it is available', async () => {
|
||||
const home = await tempDir('skill-read-fs')
|
||||
const root = join(home, '.dsh/skills')
|
||||
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 ctx.plugin(SkillService, { promptFieldMaxLength: 6 })
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
{
|
||||
...memorySkill('escaped-skill', 'Use </available_skills><oops> safely', 10),
|
||||
whenToUse: 'Handle <tag> & marker',
|
||||
},
|
||||
]))
|
||||
|
||||
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'))
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
|
||||
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the filesystem service when locating a workspace project root', async () => {
|
||||
const home = await tempDir('skill-project-root-fs')
|
||||
const project = await tempDir('skill-project-root-backend')
|
||||
const nestedCwd = join(project, 'packages/app')
|
||||
await mkdir(nestedCwd, { recursive: true })
|
||||
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(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, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
|
||||
['backend-root', 'project-agents'],
|
||||
])
|
||||
})
|
||||
|
||||
it('degrades when bundled system skill installation fails', async () => {
|
||||
const home = await tempDir('skill-install-fail')
|
||||
await writeFile(join(home, '.dsh'), 'not a directory')
|
||||
await writeSkill(join(home, '.agents/skills'), 'fallback-skill', 'Fallback skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fallback-skill'])
|
||||
})
|
||||
|
||||
it('memoizes disk discovery until runtime skill registrations change', async () => {
|
||||
const home = await tempDir('skill-cache')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'initial-skill', 'Initial skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
|
||||
await writeSkill(join(home, '.dsh/skills'), 'late-skill', 'Late skill')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
|
||||
|
||||
const dispose = ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'runtime',
|
||||
content: 'Runtime body.',
|
||||
directory: 'memory://runtime',
|
||||
source: 'runtime',
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill', 'runtime-skill'])
|
||||
|
||||
dispose()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill'])
|
||||
})
|
||||
|
||||
it('includes extra roots, optional metadata, and explicit false disable flags', async () => {
|
||||
const home = await tempDir('skill-extra')
|
||||
const extra = await tempDir('skill-extra-root')
|
||||
await writeFile(join(extra, 'extra-skill.md'), [
|
||||
'---',
|
||||
'name: extra-skill',
|
||||
'description: Extra skill',
|
||||
'whenToUse: For extra-root tests',
|
||||
'disableModelInvocation: false',
|
||||
'metadata:',
|
||||
' owner: tests',
|
||||
'---',
|
||||
'',
|
||||
'Extra body.',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
extraRoots: [extra],
|
||||
installSystemSkills: false,
|
||||
})
|
||||
|
||||
expect(await ctx.skills.list()).toEqual([{
|
||||
name: 'extra-skill',
|
||||
description: 'Extra skill',
|
||||
whenToUse: 'For extra-root tests',
|
||||
disableModelInvocation: false,
|
||||
directory: extra,
|
||||
source: 'extra',
|
||||
}])
|
||||
expect((await ctx.skills.get('extra-skill'))?.metadata).toEqual({ owner: 'tests' })
|
||||
expect(await ctx.skills.renderModelListing()).toContain('whenToUse: For extra-root tests')
|
||||
})
|
||||
|
||||
it('bounds prompt listing fields without changing stored skill content', async () => {
|
||||
const home = await tempDir('skill-prompt-bounds')
|
||||
const longDescription = 'a'.repeat(600)
|
||||
await writeSkill(join(home, '.dsh/skills'), 'long-skill', longDescription, 'Full body.')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const listing = await ctx.skills.renderModelListing()
|
||||
expect(listing).toContain(`${'a'.repeat(497)}...`)
|
||||
expect(listing).not.toContain('a'.repeat(600))
|
||||
expect((await ctx.skills.get('long-skill'))?.description).toBe(longDescription)
|
||||
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('escapes prompt listing text fields without changing stored skill content', async () => {
|
||||
const home = await tempDir('skill-prompt-escape')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'escaped-skill.md'), [
|
||||
'---',
|
||||
'name: escaped-skill',
|
||||
'description: Use </available_skills><oops> safely',
|
||||
'whenToUse: Handle <tag> & marker',
|
||||
'---',
|
||||
'Full body.',
|
||||
].join('\n'))
|
||||
|
||||
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
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 listing = await ctx.skills.renderModelListing()
|
||||
expect(listing).toContain('description: Use </available_skills><oops> safely')
|
||||
expect(listing).toContain('whenToUse: Handle <tag> & marker')
|
||||
expect(listing).not.toContain('description: Use </available_skills><oops> safely')
|
||||
expect((await ctx.skills.get('escaped-skill'))?.description).toBe('Use </available_skills><oops> safely')
|
||||
})
|
||||
|
||||
it('adds skill guidance through system prompt assembly without including bodies', async () => {
|
||||
const home = await tempDir('skill-guidance')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'research-helper', 'Research helper', 'Long body that must not be listed.')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'base' })
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd(home) }))
|
||||
|
||||
expect(prompt).toContain('base')
|
||||
expect(prompt).toContain('## Skills\n')
|
||||
expect(prompt).toContain('research-helper')
|
||||
expect(prompt).toContain('source="project-dsh"')
|
||||
expect(prompt).not.toContain(home)
|
||||
expect(prompt).not.toContain('Long body')
|
||||
expect(prompt.match(/## Skills/g)).toHaveLength(1)
|
||||
|
||||
const copyCtx = new Context()
|
||||
await copyCtx.plugin(SystemPrompt, { persona: 'base' })
|
||||
await copyCtx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
copyCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return { ...result, sections: [...result.sections] }
|
||||
})
|
||||
const copiedPrompt = renderPrompt(await copyCtx.systemPrompt.assemble({ agent: agentForCwd(home) }))
|
||||
expect(copiedPrompt.match(/## Skills/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cleans up runtime registered skills when the contributing fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const home = await tempDir('skill-runtime')
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'runtime',
|
||||
content: 'Runtime body.',
|
||||
directory: 'memory://runtime',
|
||||
source: 'runtime',
|
||||
})
|
||||
}, { inject: ['skills'] }))
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill'])
|
||||
await fiber.dispose()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('bounds discovery cache entries across many project roots', async () => {
|
||||
const home = await tempDir('skill-cache-bound-home')
|
||||
const projects = await Promise.all(Array.from({ length: 129 }, async (_, index) => {
|
||||
const project = await tempDir(`skill-cache-bound-project-${index}`)
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeSkill(join(project, '.dsh/skills'), `project-${index}`, `Project ${index}`)
|
||||
return project
|
||||
}))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const firstProject = projects[0]
|
||||
if (firstProject === undefined) throw new Error('expected at least one project')
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['project-0'])
|
||||
await writeSkill(join(firstProject, '.dsh/skills'), 'late-project-0', 'Late project 0')
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['project-0'])
|
||||
|
||||
for (const project of projects.slice(1)) {
|
||||
await ctx.skills.list({ cwd: project })
|
||||
}
|
||||
|
||||
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['late-project-0', 'project-0'])
|
||||
})
|
||||
|
||||
it('removes runtime registered skills when the returned disposer is called', async () => {
|
||||
const home = await tempDir('skill-runtime-disposer')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const dispose = ctx.skills.register({
|
||||
name: 'manual-dispose',
|
||||
description: 'manual',
|
||||
content: 'Manual body.',
|
||||
directory: 'memory://manual',
|
||||
source: 'runtime',
|
||||
})
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['manual-dispose'])
|
||||
dispose()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the first runtime skill when a duplicate name is registered', async () => {
|
||||
const home = await tempDir('skill-runtime-duplicate')
|
||||
const ctx = new Context()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
const firstDispose = ctx.skills.register({
|
||||
name: 'same-runtime',
|
||||
description: 'first',
|
||||
content: 'First body.',
|
||||
directory: 'memory://first',
|
||||
source: 'runtime',
|
||||
})
|
||||
const duplicateDispose = ctx.skills.register({
|
||||
name: 'same-runtime',
|
||||
description: 'second',
|
||||
content: 'Second body.',
|
||||
directory: 'memory://second',
|
||||
source: 'runtime',
|
||||
})
|
||||
|
||||
await expect(ctx.skills.get('same-runtime')).resolves.toMatchObject({
|
||||
description: 'first',
|
||||
content: 'First body.',
|
||||
directory: 'memory://first',
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('runtime skill "same-runtime"'))
|
||||
|
||||
duplicateDispose()
|
||||
await expect(ctx.skills.get('same-runtime')).resolves.toMatchObject({
|
||||
description: 'first',
|
||||
content: 'First body.',
|
||||
})
|
||||
|
||||
firstDispose()
|
||||
await expect(ctx.skills.get('same-runtime')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid runtime skill registrations', async () => {
|
||||
const home = await tempDir('skill-runtime-invalid')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'Bad_Name',
|
||||
description: 'bad',
|
||||
content: 'bad',
|
||||
directory: 'memory://bad',
|
||||
source: 'runtime',
|
||||
})).toThrow('invalid skill name')
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'empty-description',
|
||||
description: '',
|
||||
content: 'bad',
|
||||
directory: 'memory://bad',
|
||||
source: 'runtime',
|
||||
})).toThrow('requires a description')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../fs/fs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../agent" }
|
||||
{ "path": "../agent" },
|
||||
{ "path": "../system-prompt" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` to resolve project-local skills. A successful call returns a text block containing `<skill_content name="...">`, the skill body, the skill base directory, and relative-path guidance. Unknown names, invalid names, and skills marked `disableModelInvocation: true` return `isError` tool results through the normal tool registry error path.
|
||||
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.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
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'
|
||||
@@ -39,14 +40,34 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
'',
|
||||
skill.content,
|
||||
'',
|
||||
`Base directory for this skill: ${skill.directory}`,
|
||||
'Resolve relative files mentioned by this skill against the base directory before using them.',
|
||||
...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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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> {
|
||||
@@ -23,7 +24,8 @@ async function setup(home: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.plugin(toolSkill)
|
||||
return ctx
|
||||
}
|
||||
@@ -34,7 +36,8 @@ describe('dsh-tool-skill', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const home = await tempDir('tool-schema')
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
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'])
|
||||
@@ -70,6 +73,65 @@ describe('dsh-tool-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.')
|
||||
|
||||
Reference in New Issue
Block a user