feat(skill): hot-refresh skill catalogs

This commit is contained in:
Yichen Jiang
2026-07-27 16:50:56 +08:00
parent 79eb3a9035
commit b76659aa57
46 changed files with 2372 additions and 153 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f
README.zh.md: d219f710c0185298af89ba2e074d9e3b2e093896
# pnpm run verify-translation-pairing --write packages/skill/README.md
README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822
README.zh.md: 6f10c3e907e9cc97bf742fa6155db1442cb44edf

View File

@@ -6,8 +6,8 @@ The canonical three-package capability seam for reusable agent instructions: a p
| Package | Role | ctx key |
|---|---|---|
| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` |
| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) |
| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) |
| `skill/` | Provider registry, precedence resolution, complete/incomplete catalog snapshots, and full-definition lookup | `ctx.skills` |
| `skill-local/` | Project/custom/user filesystem provider with membership watching | (registers on `ctx.skills`) |
| `tool-skill/` | Initial and replacement catalogs plus the model-facing `skill` loader | (registers on `ctx.tools`) |
The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md).

View File

@@ -6,8 +6,8 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
| `skill/` | 提供方注册表、优先级解析、稳定目录快照和完整定义查找 | `ctx.skills` |
| `skill-local/` | 项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills` |
| `tool-skill/` | 会话前缀目录和面向模型的 `skill` 加载器 | (注册到 `ctx.tools` |
| `skill/` | 提供方注册表、优先级解析、完整/不完整目录快照和完整定义查找 | `ctx.skills` |
| `skill-local/` | 带目录成员关系监视的项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills` |
| `tool-skill/` | 初始目录和替换目录,以及面向模型的 `skill` loader | (注册到 `ctx.tools` |
接口位于 `skill/skill/`。提供方同步注册,并通过 `ctx.skills` 执行异步发现;`tool-skill` 只消费该接口,因此嵌入式或远程提供方可替换或补充 `skill-local`,无需改变面向模型的契约。`agent-core` 默认加载该家族,但它仍然是核心控制主干之外的功能,与 [`bash/`](../bash/README.md)、[`fs/`](../fs/README.md)、[`web/`](../web/README.md) 和 [`subagent/`](../subagent/README.md) 并列。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: c488fdc4b1d97b5aa1113e41a470484063526ded
README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
README.md: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065
README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09

View File

@@ -17,6 +17,12 @@ Requires `ctx.skills` (`inject: ['skills']`).
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); 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. |
| `watch` | `true` | Watch host-local roots and invalidate the local provider when catalog membership or frontmatter may have changed. |
| `watchUsePolling` | `false` | Use Chokidar polling instead of native events for existing skill roots. |
| `watchStabilityThresholdMs` | `200` | Stable-write window for Chokidar `add` and `change` events. |
| `watchPollIntervalMs` | `100` | Chokidar polling/stability interval and missing-path probe interval. |
| `watchMaxProjects` | `128` | Maximum distinct project roots retained in the watcher LRU. |
| `watchFollowSymlinks` | `true` | Follow symbolic links while watching existing roots. |
## Discovery
@@ -32,23 +38,34 @@ Default roots are resolved in this provider's rank order:
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 treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills.
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. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable 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.
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. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.
## Catalog Change Detection
Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged, make the current provider observation incomplete, and are retried; effect teardown closes every watcher and contains late callbacks.
## 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.
The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name.
## Model Experience
Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden.
Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the initial or replacement catalog and a selected current instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
Watcher invalidation can cause the named consumer to append a replacement catalog after the reusable session prefix. Body-only edits leave the catalog digest unchanged.
## Known Limitations and Deferred Work
- **Discovery is one level deep** — only `<root>/<name>/SKILL.md` and `<root>/<name>.md` are recognized; nested skill trees and package manifests are ignored.
- **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection.
- **Unreadable or malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from a skipped one.
- **No filesystem watching** — edits rely on the registry cache being evicted or invalidated by provider reload before a previously collected cwd is rediscovered.
- **Malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one; unexpected I/O failures preserve the last-good catalog instead.
- **Missing-root observation polls one path segment** — roots absent at startup use `fs.watchFile` at `watchPollIntervalMs` until Chokidar can attach, trading bounded detection latency for reliable creation detection across IDE, Git, and shell workflows.
- **No body revision protocol** — a loaded body is ordinary retained tool history; later file edits affect later calls but neither rewrite old results nor announce that the body changed.

View File

@@ -17,6 +17,12 @@
| `dshHome` | `$DSH_HOME` or `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根;扫描该目录下的 `skills`。 |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根。 |
| `customSkillDirs` | `[]` | 在项目根之后、用户根之前扫描的其他本地 skill 根。 |
| `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 |
| `watchUsePolling` | `false` | 对现有 skill 根使用 Chokidar 轮询,而不是原生事件。 |
| `watchStabilityThresholdMs` | `200` | Chokidar `add``change` 事件的稳定写入窗口。 |
| `watchPollIntervalMs` | `100` | Chokidar 轮询/稳定性间隔和缺失路径探测间隔。 |
| `watchMaxProjects` | `128` | watcher LRU 中保留的不同项目根数量上限。 |
| `watchFollowSymlinks` | `true` | 监视现有根时跟随符号链接。 |
## 发现
@@ -32,23 +38,34 @@
项目根是包含 `.git` 的最近祖先;如果不存在,则使用当前 cwd。用户 DSH 根会跳过其 `.system` 子级,因此系统所有目录不会被当作普通用户 skill。该提供方提供项目和用户 skill其他提供方可提供内置系统 skill。
`ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O使最小本地上下文仍能加载 skill。缺失、不可读或格式错误的 skill 文件会警告并跳过,而不会使整个请求失败
`ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录
## 目录变更检测
现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents``skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
如果第一方文件系统 `write``edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录使提供方的当前观察不完整并触发重试effect 释放会关闭所有 watcher并收束延迟回调。
## Skill 格式
Skill 可以是单层目录 bundle`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML它要求 `name``description`,而 `whenToUse``disableModelInvocation``metadata` 可选。名称必须使用 kebab-case。
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter系统会拒绝陈旧名称并使提供方失效下一次目录观察会发布新名称。
## 模型体验
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到初始目录或替换目录中,并将所选当前指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
#### KV 缓存影响
不直接导致失效;指定的消费方负责其引起的任何请求前缀变更
watcher 触发的失效可促使指定的消费方在可复用会话前缀之后追加替换目录。仅涉及正文的编辑不会改变目录 digest
## 已知限制与待完成工作
- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md``<root>/<name>.md`;忽略嵌套 skill 树和包 manifest。
- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd不支持其他项目根标记或 monorepo 子项目选择。
- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。
- **无文件系统 watcher**:先前已收集 cwd 重新发现之前,编辑操作依赖注册表缓存被驱逐,或因提供方重新加载而失效。
- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录
- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile``watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。
- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。

View File

@@ -34,6 +34,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^5.0.0",
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
},

View File

@@ -10,9 +10,11 @@
*/
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { unwatchFile, watchFile, type Stats } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { homedir } from 'node:os'
import type { Context } from 'cordis'
import chokidar from 'chokidar'
import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
@@ -32,6 +34,9 @@ const PROJECT_AGENTS_RANK = 200
const CUSTOM_RANK = 300
const USER_DSH_RANK = 400
const USER_AGENTS_RANK = 500
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200
const DEFAULT_WATCH_POLL_INTERVAL_MS = 100
const DEFAULT_WATCH_MAX_PROJECTS = 128
export const name = 'skill-local'
export const inject = ['skills']
@@ -44,12 +49,30 @@ export interface Config {
agentsHome?: string
/** Additional skill roots scanned after project roots and before user roots. */
customSkillDirs?: string[]
/** Whether host-local skill roots are watched for catalog changes. */
watch?: boolean
/** Whether Chokidar uses polling instead of native filesystem events. */
watchUsePolling?: boolean
/** Milliseconds a changed skill entry must remain stable before it is observed. */
watchStabilityThresholdMs?: number
/** Milliseconds between Chokidar stability or polling probes. */
watchPollIntervalMs?: number
/** Maximum distinct project roots whose skill directories remain watched. */
watchMaxProjects?: number
/** Whether watched symbolic links follow their target files. */
watchFollowSymlinks?: boolean
}
export const Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
customSkillDirs: z.array(z.string()).default([]),
watch: z.boolean().default(true),
watchUsePolling: z.boolean().default(false),
watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS),
watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS),
watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS),
watchFollowSymlinks: z.boolean().default(true),
})
interface SkillRoot {
@@ -57,6 +80,7 @@ interface SkillRoot {
source: SkillSource
rank: number
skipSystem?: boolean
projectRoot?: string
}
interface SkillRootEntry {
@@ -79,10 +103,26 @@ interface LocalLocator {
directory: string
}
interface ResolvedWatchConfig {
enabled: boolean
usePolling: boolean
stabilityThresholdMs: number
pollIntervalMs: number
maxProjects: number
followSymlinks: boolean
}
/** 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)
ctx.effect(function* () {
yield async () => { await provider.dispose() }
}, 'skill-local watcher')
ctx.on('fs/observed', (target, _version, actor) => {
if (mutationToolName(actor) === undefined) return
provider.observeHostMutation(target.displayPath)
})
}
/** Provider that maps local project/user skill roots into `ctx.skills`. */
@@ -91,11 +131,13 @@ export class LocalSkillProvider implements SkillProvider {
private readonly dshHome: string
private readonly agentsHome: string
private readonly customSkillDirs: string[]
private readonly watchManager: SkillWatchManager
constructor(private readonly ctx: Context, config: Config = {}) {
this.dshHome = resolveDshHome(config.dshHome)
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config))
}
/**
@@ -105,6 +147,7 @@ export class LocalSkillProvider implements SkillProvider {
*/
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
const roots = await this.roots(options.cwd)
await this.watchManager.observeRoots(roots)
const candidates: SkillCandidate[] = []
for (const root of roots) {
for (const skill of await discoverRoot(root, this.ctx)) {
@@ -138,13 +181,26 @@ export class LocalSkillProvider implements SkillProvider {
}
}
/**
* Invalidate this provider synchronously after a first-party filesystem mutation.
* @param path - host display path observed after a model-facing write or edit.
*/
observeHostMutation(path: string): void {
this.watchManager.observeHostMutation(path)
}
/** Close every host watcher and contain late filesystem callbacks. */
async dispose(): Promise<void> {
await this.watchManager.dispose()
}
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 },
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },
)
}
roots.push(
@@ -156,6 +212,405 @@ export class LocalSkillProvider implements SkillProvider {
}
}
type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'
type RootWatchMode =
| { kind: 'root'; anchor: string }
| { kind: 'ancestor'; anchor: string; nextPath: string }
interface RootWatchState {
root: SkillRoot
owners: Set<string>
watcher: WatchHandle | undefined
opening: Promise<void> | undefined
unhealthy: boolean
}
interface WatchHandle {
close(): Promise<void> | void
}
/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */
class SkillWatchManager {
private readonly roots = new Map<string, RootWatchState>()
private readonly projects = new Map<string, Set<string>>()
private closing = false
private invalidationQueued = false
constructor(
private readonly ctx: Context,
private readonly provider: SkillProvider,
private readonly config: ResolvedWatchConfig,
) {}
async observeRoots(roots: readonly SkillRoot[]): Promise<void> {
if (this.closing) return
const projectRoots = new Map<string, SkillRoot[]>()
const pending: Promise<void>[] = []
for (const root of roots) {
if (root.projectRoot === undefined) {
pending.push(this.retainRoot(root, `shared:${root.path}`))
continue
}
const grouped = projectRoots.get(root.projectRoot) ?? []
grouped.push(root)
projectRoots.set(root.projectRoot, grouped)
}
for (const [projectRoot, grouped] of projectRoots) {
const owner = `project:${projectRoot}`
this.projects.delete(projectRoot)
const paths = new Set(grouped.map(root => root.path))
this.projects.set(projectRoot, paths)
for (const root of grouped) pending.push(this.retainRoot(root, owner))
}
let evictedProject = false
while (this.projects.size > this.config.maxProjects) {
const oldest = this.projects.entries().next()
/* v8 ignore next -- the loop condition proves one project exists. */
if (oldest.done) break
const [projectRoot, paths] = oldest.value
this.projects.delete(projectRoot)
const owner = `project:${projectRoot}`
for (const path of paths) pending.push(this.releaseRoot(path, owner))
evictedProject = true
}
await Promise.all(pending)
if (evictedProject) this.ctx.skills.invalidateProvider(this.provider)
}
observeHostMutation(path: string): void {
if (this.closing) return
const normalized = resolve(path)
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
this.ctx.skills.invalidateProvider(this.provider)
}
async dispose(): Promise<void> {
if (this.closing) return
this.closing = true
const states = [...this.roots.values()]
this.roots.clear()
this.projects.clear()
await Promise.all(states.map(async (state) => {
await settleWatcherOpening(state.opening)
const watcher = state.watcher
state.watcher = undefined
if (watcher !== undefined) await this.closeWatcher(watcher)
}))
}
private async retainRoot(root: SkillRoot, owner: string): Promise<void> {
let state = this.roots.get(root.path)
if (state === undefined) {
state = { root, owners: new Set(), watcher: undefined, opening: undefined, unhealthy: true }
this.roots.set(root.path, state)
}
state.owners.add(owner)
if (this.config.enabled) await this.ensureWatcher(state)
}
private async releaseRoot(path: string, owner: string): Promise<void> {
const state = this.roots.get(path)
/* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */
if (state === undefined) return
state.owners.delete(owner)
if (state.owners.size > 0) return
this.roots.delete(path)
await settleWatcherOpening(state.opening)
const watcher = state.watcher
state.watcher = undefined
if (watcher !== undefined) await this.closeWatcher(watcher)
}
private ensureWatcher(state: RootWatchState): Promise<void> {
if (this.closing || !this.config.enabled) return Promise.resolve()
if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve()
if (state.opening !== undefined) return state.opening
const opening = this.replaceWatcher(state)
state.opening = opening
void opening.then(
() => {
state.opening = undefined
},
() => {
state.opening = undefined
},
)
return opening
}
private async replaceWatcher(state: RootWatchState): Promise<void> {
const previous = state.watcher
state.watcher = undefined
if (previous !== undefined) await this.closeWatcher(previous)
/* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */
if (this.closing || state.owners.size === 0) return
try {
const watcher = await this.openStableWatcher(state)
/* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */
if (watcher === undefined) return
/* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
if (this.closing || state.owners.size === 0) {
await this.closeWatcher(watcher)
return
}
/* v8 ignore stop */
state.watcher = watcher
state.unhealthy = false
} catch (error) {
state.unhealthy = true
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
throw error
}
}
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
while (!this.closing && state.owners.size > 0) {
const mode = await resolveRootWatchMode(state.root.path)
const watcher = mode.kind === 'ancestor'
? this.openAncestorWatcher(state, mode)
: await this.openRootWatcher(state, mode)
const current = await resolveRootWatchMode(state.root.path)
/* v8 ignore else -- A host path transition between the two probes is timing-dependent. */
if (sameWatchMode(mode, current)) return watcher
/* v8 ignore next -- Covered by the same host path transition guard. */
await this.closeWatcher(watcher)
}
/* v8 ignore next -- The loop exits only when teardown wins between awaited probes. */
return undefined
}
private openAncestorWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'ancestor' }>): WatchHandle {
const listener = (_current: Stats, _previous: Stats): void => {
this.handleWatchEvent(state, mode, 'change', mode.nextPath)
}
watchFile(mode.nextPath, {
persistent: false,
interval: this.config.pollIntervalMs,
}, listener)
return {
close() {
unwatchFile(mode.nextPath, listener)
},
}
}
private async openRootWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
const watcher = chokidar.watch(mode.anchor, {
persistent: false,
ignoreInitial: true,
depth: 1,
followSymlinks: this.config.followSymlinks,
atomic: true,
awaitWriteFinish: {
stabilityThreshold: this.config.stabilityThresholdMs,
pollInterval: this.config.pollIntervalMs,
},
usePolling: this.config.usePolling,
interval: this.config.pollIntervalMs,
})
let ready = false
const readiness = Promise.withResolvers<undefined>()
const onError = (error: unknown): void => {
if (!ready) {
readiness.reject(error)
return
}
this.handleWatcherError(state, error)
}
watcher.on('error', onError)
watcher.once('ready', () => {
ready = true
readiness.resolve(undefined)
})
for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) {
watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) })
}
try {
await readiness.promise
} catch (error) {
await this.closeWatcher(watcher)
throw error
}
return watcher
}
private handleWatchEvent(
state: RootWatchState,
mode: RootWatchMode,
event: SkillWatchEvent,
path: string,
): void {
if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return
this.queueInvalidation()
if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) {
state.unhealthy = true
this.scheduleRewatch(state)
}
}
private handleWatcherError(state: RootWatchState, error: unknown): void {
if (this.closing) return
this.ctx.logger.warn(`skill-local: watcher for ${state.root.path} failed: ${errorMessage(error)}`)
state.unhealthy = true
this.queueInvalidation()
this.scheduleRewatch(state)
}
private scheduleRewatch(state: RootWatchState): void {
const currentOpening = state.opening ?? Promise.resolve()
void (async () => {
await settleWatcherOpening(currentOpening)
try {
await this.ensureWatcher(state)
} catch {
// Watch startup logged the retry failure; the next incomplete discovery retries it again.
return
}
this.queueInvalidation()
})()
}
private queueInvalidation(): void {
if (this.closing || this.invalidationQueued) return
this.invalidationQueued = true
queueMicrotask(() => {
this.invalidationQueued = false
if (this.closing) return
this.ctx.skills.invalidateProvider(this.provider)
})
}
private async closeWatcher(watcher: WatchHandle): Promise<void> {
try {
await watcher.close()
} catch (error) {
this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`)
}
}
}
async function settleWatcherOpening(opening: Promise<void> | undefined): Promise<void> {
if (opening === undefined) return
try {
await opening
} catch {
// Watch startup already logged the underlying failure; teardown only contains it.
}
}
function resolveWatchConfig(config: Config): ResolvedWatchConfig {
const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS
const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS
const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS
assertPositiveInteger('watchStabilityThresholdMs', stabilityThresholdMs)
assertPositiveInteger('watchPollIntervalMs', pollIntervalMs)
assertPositiveInteger('watchMaxProjects', maxProjects)
return {
enabled: config.watch ?? true,
usePolling: config.watchUsePolling ?? false,
stabilityThresholdMs,
pollIntervalMs,
maxProjects,
followSymlinks: config.watchFollowSymlinks ?? true,
}
}
async function resolveRootWatchMode(root: string): Promise<RootWatchMode> {
let candidate = root
while (true) {
try {
const info = await stat(candidate)
if (info.isDirectory()) {
if (candidate === root) return { kind: 'root', anchor: root }
const firstSegment = relative(candidate, root).split(sep)[0]
/* v8 ignore next -- candidate is a strict ancestor of root. */
if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root }
return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) }
}
} catch (error) {
/* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */
if (!isAbsentPathError(error)) throw error
}
const parent = dirname(candidate)
/* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */
if (parent === candidate) return { kind: 'ancestor', anchor: candidate, nextPath: root }
candidate = parent
}
}
function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean {
return left.kind === right.kind
&& left.anchor === right.anchor
&& (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath))
}
function isRelevantWatchEvent(
root: SkillRoot,
mode: RootWatchMode,
event: SkillWatchEvent,
path: string,
): boolean {
if (mode.kind === 'ancestor') {
return path === mode.nextPath
}
const segments = containedSegments(root.path, path)
if (segments === undefined) return false
if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir'
if (root.skipSystem === true && segments[0] === '.system') return false
if (segments.length === 1) {
if (event === 'addDir' || event === 'unlinkDir') return true
return segments[0]?.endsWith('.md') === true
}
return segments.length === 2
&& segments[1] === 'SKILL.md'
&& event !== 'addDir'
&& event !== 'unlinkDir'
}
function isPotentialSkillPath(root: SkillRoot, path: string): boolean {
const segments = containedSegments(root.path, path)
if (segments === undefined || segments.length === 0 || segments.length > 2) return false
if (root.skipSystem === true && segments[0] === '.system') return false
return segments.length === 1
? segments[0]?.endsWith('.md') === true
: segments[1] === 'SKILL.md'
}
function containedSegments(root: string, path: string): string[] | undefined {
const child = relative(root, path)
if (child.length === 0) return []
if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined
return child.split(sep)
}
function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined {
if (actor === undefined || !('name' in actor)) return undefined
const value = actor.name
return value === 'edit' || value === 'write' ? value : undefined
}
function assertPositiveInteger(field: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new TypeError(`skill-local: ${field} must be a positive integer`)
}
}
function isAbsentPathError(error: unknown): boolean {
return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')
}
function isAbsentSkillPathError(error: unknown): boolean {
return isAbsentPathError(error)
|| hasErrorCode(error, 'FS_NOT_FOUND')
|| hasErrorCode(error, 'FS_NOT_DIRECTORY')
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
const skills: SkillCandidate[] = []
const entries = await listSkillRootEntries(root, ctx)
@@ -193,9 +648,12 @@ async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<Skil
}
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)
try {
return (await fsListDir(fs, root.path)).map(entryFromFs)
} catch (error) {
if (isAbsentSkillPathError(error)) return []
throw error
}
}
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
@@ -211,9 +669,11 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom
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 []
} catch (error) {
/* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */
if (isAbsentSkillPathError(error)) return []
/* v8 ignore next -- Same native error branch as above. */
throw error
}
const result: SkillRootEntry[] = []
@@ -274,31 +734,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal):
}
try {
return await readFile(path, { encoding: 'utf8', signal })
} catch {
} catch (error) {
signal?.throwIfAborted()
return undefined
if (isAbsentSkillPathError(error)) return undefined
throw error
}
}
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise<string | undefined> {
// A missing or temporarily inaccessible skill file is not fatal to discovery.
signal?.throwIfAborted()
const target = await fs.resolve(path).catch(() => undefined)
let target
try {
target = await fs.resolve(path)
} catch (error) {
if (isAbsentSkillPathError(error)) return undefined
throw error
}
signal?.throwIfAborted()
if (target === undefined) return undefined
let info
try {
info = await fs.stat(target, signal)
} catch (error) {
signal?.throwIfAborted()
ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
return undefined
if (isAbsentSkillPathError(error)) return undefined
throw error
}
if (info === undefined || info.type !== 'file') return undefined
try {
return await fs.readText(target, signal)
} catch (error) {
signal?.throwIfAborted()
if (isAbsentSkillPathError(error)) return undefined
if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw error
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
return undefined
}

View File

@@ -0,0 +1,220 @@
import { EventEmitter } from 'node:events'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
interface FakeWatcherControl {
emitter: EventEmitter
closeCalls: number
options: Record<string, unknown>
}
const watcherHarness = vi.hoisted(() => ({
watchers: [] as FakeWatcherControl[],
startupErrors: [] as Error[],
closeErrors: 0,
deferredReady: 0,
}))
vi.mock('chokidar', () => ({
default: {
watch(_path: unknown, options: Record<string, unknown>) {
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
const control: FakeWatcherControl = { emitter, closeCalls: 0, options }
emitter.close = async () => {
control.closeCalls += 1
if (watcherHarness.closeErrors > 0) {
watcherHarness.closeErrors -= 1
throw new Error('close failed')
}
}
watcherHarness.watchers.push(control)
queueMicrotask(() => {
if (watcherHarness.deferredReady > 0) {
watcherHarness.deferredReady -= 1
return
}
const error = watcherHarness.startupErrors.shift()
if (error === undefined) emitter.emit('ready')
else emitter.emit('error', error)
})
return emitter
},
},
}))
const SkillLocal = await import('../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): Promise<void> {
const directory = join(root, name)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n\nBody.\n`)
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
beforeEach(() => {
watcherHarness.watchers.length = 0
watcherHarness.startupErrors.length = 0
watcherHarness.closeErrors = 0
watcherHarness.deferredReady = 0
})
describe('skill-local watcher failures', () => {
it('marks a startup failure incomplete and retries discovery without caching it', async () => {
const home = await tempDir('skill-watch-start-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'retry-skill')
watcherHarness.startupErrors.push(new Error('watch failed'))
watcherHarness.closeErrors = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchUsePolling: true,
watchFollowSymlinks: false,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: true,
})
expect(watcherHarness.watchers).toHaveLength(2)
expect(watcherHarness.watchers[1]?.options).toMatchObject({
atomic: true,
depth: 1,
followSymlinks: false,
usePolling: true,
interval: 10,
awaitWriteFinish: {
stabilityThreshold: 20,
pollInterval: 10,
},
})
await fiber.dispose()
})
it('filters events, coalesces invalidation, recovers runtime errors, and contains late callbacks', async () => {
const home = await tempDir('skill-watch-runtime-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'watched-skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill'])
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
let invalidations = 0
ctx.skills.invalidateProvider = (provider) => {
invalidations += 1
invalidateProvider(provider)
}
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected a root watcher')
first.emitter.emit('change', join(root, 'notes.txt'))
first.emitter.emit('change', join(home, 'outside.md'))
first.emitter.emit('change', join(root, 'watched-skill/references.md'))
first.emitter.emit('change', join(root, '.system/SKILL.md'))
await settle()
expect(invalidations).toBe(0)
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
await settle()
expect(invalidations).toBe(1)
watcherHarness.closeErrors = 1
watcherHarness.startupErrors.push(new Error('runtime rewatch failed'))
first.emitter.emit('error', new Error('runtime watch failed'))
await settle()
await settle()
expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2)
expect(invalidations).toBeGreaterThanOrEqual(2)
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'watched-skill' }],
complete: true,
})
await fiber.dispose()
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('error', new Error('late error'))
await settle()
})
it('settles an opening watcher when plugin disposal races its ready event', async () => {
const home = await tempDir('skill-watch-opening-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
ctx.skills.registerProvider(provider)
const discovery = provider.list({})
await settle()
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
first.emitter.emit('unlinkDir', root)
const disposal = provider.dispose()
first.emitter.emit('ready')
await Promise.all([discovery, disposal])
await settle()
expect(first.closeCalls).toBeGreaterThan(0)
})
it('contains an opening watcher rejection during provider teardown', async () => {
const home = await tempDir('skill-watch-opening-reject')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'rejected-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
ctx.skills.registerProvider(provider)
const discovery = provider.list({})
await settle()
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
const disposal = provider.dispose()
first.emitter.emit('error', new Error('opening failed during disposal'))
await expect(discovery).rejects.toThrow('opening failed during disposal')
await disposal
})
})

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rename, rm, 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 FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillLocal from '../src/index.ts'
async function tempDir(name: string): Promise<string> {
@@ -26,19 +26,26 @@ class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
failStatPaths = new Set<string>()
failListDirPaths = new Set<string>()
errorResolvePaths = new Set<string>()
errorStatPaths = new Set<string>()
errorReadPaths = new Set<string>()
missingReadPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
statSignals: Array<AbortSignal | undefined> = []
readTextSignals: Array<AbortSignal | undefined> = []
readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND')
if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed')
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
this.statSignals.push(signal)
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND')
if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed')
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
try {
const fs = await import('node:fs/promises')
@@ -70,8 +77,10 @@ class TestFileSystem extends FileSystem {
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
this.readTextSignals.push(signal)
if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND')
if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed')
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT')
return text
}
@@ -81,6 +90,7 @@ class TestFileSystem extends FileSystem {
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
this.listDirCalls += 1
if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')
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))) {
@@ -122,11 +132,22 @@ async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {})
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
...config,
})
return ctx
}
async function waitFor<T>(read: () => Promise<T>, accept: (value: T) => boolean): Promise<T> {
const deadline = Date.now() + 5000
while (true) {
const value = await read()
if (accept(value)) return value
if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
describe('dsh-skill-local plugin exports', () => {
it('declares stable plugin metadata', () => {
expect(SkillLocal.name).toBe('skill-local')
@@ -224,7 +245,7 @@ describe('LocalSkillProvider', () => {
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'), '')
await rm(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()
@@ -327,7 +348,7 @@ describe('LocalSkillProvider', () => {
size: 0,
})
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
@@ -337,6 +358,92 @@ describe('LocalSkillProvider', () => {
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
})
it('reports transient root reads as incomplete without caching an empty catalog', async () => {
const home = await tempDir('skill-transient-root')
const root = join(home, '.agents/skills')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
fs.failListDirPaths.add(root)
const path = join(root, 'stable-skill/SKILL.md')
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('failed-read'),
{ name: 'edit' },
)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
fs.failListDirPaths.clear()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => {
const home = await tempDir('skill-transient-entry')
const root = join(home, '.agents/skills')
const path = join(root, 'stable-skill/SKILL.md')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
const invalidate = (): void => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('entry-failure'),
{ name: 'write' },
)
}
expect((await ctx.skills.snapshot()).complete).toBe(true)
for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) {
failures.add(path)
invalidate()
expect((await ctx.skills.snapshot()).complete).toBe(false)
failures.clear()
}
fs.missingReadPaths.add(path)
invalidate()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
fs.missingReadPaths.clear()
invalidate()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('marks an unexpected native skill-file read failure incomplete', async () => {
const home = await tempDir('skill-native-read-failure')
const root = join(home, '.agents/skills')
await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true })
const ctx = await setupLocal(home)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
})
it('forwards cancellation to filesystem reads while loading a skill', async () => {
const home = await tempDir('skill-read-abort')
await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
@@ -345,7 +452,7 @@ describe('LocalSkillProvider', () => {
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
fs.statSignals = []
@@ -372,6 +479,219 @@ describe('LocalSkillProvider', () => {
expect(fs.readTextSignals).toEqual([controller.signal])
})
it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => {
const home = await tempDir('skill-watch-home')
const agentsRoot = join(home, '.agents/skills')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect(await ctx.skills.list()).toEqual([])
await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.')
const added = await waitFor(
async () => await ctx.skills.list(),
skills => skills.some(skill => skill.name === 'watched-skill'),
)
expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description')
await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.')
const changed = await waitFor(
async () => await ctx.skills.list(),
skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description',
)
expect(changed).toHaveLength(1)
expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.')
await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('flat-added'),
)).toEqual(['flat-added', 'watched-skill'])
await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill'))
await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('renamed-skill') && !names.includes('watched-skill'),
)).toEqual(['flat-added', 'renamed-skill'])
await rm(join(agentsRoot, 'renamed-skill'), { recursive: true })
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => !names.includes('renamed-skill'),
)).toEqual(['flat-added'])
await rm(join(home, '.agents'), { recursive: true })
expect(await waitFor(
async () => await ctx.skills.list(),
skills => skills.length === 0,
)).toEqual([])
await writeSkill(agentsRoot, 'recreated-skill', 'Recreated')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('recreated-skill'),
)).toEqual(['recreated-skill'])
} finally {
await fiber.dispose()
}
})
it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => {
const home = await tempDir('skill-observed-home')
const root = join(home, '.agents/skills')
const ctx = await setupLocal(home)
expect(await ctx.skills.list()).toEqual([])
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
let invalidations = 0
ctx.skills.invalidateProvider = (provider) => {
invalidations += 1
invalidateProvider(provider)
}
await writeSkill(root, 'observed-skill', 'Observed skill')
const path = join(root, 'observed-skill/SKILL.md')
const emitObserved = (displayPath: string, actor?: object): void => {
ctx.emit(
'fs/observed',
{ targetKey: displayPath as never, displayPath },
FsVersion('observed'),
actor,
)
}
emitObserved(path)
emitObserved(path, {})
emitObserved(path, { name: 'read' })
emitObserved(join(home, 'outside.md'), { name: 'write' })
emitObserved(root, { name: 'write' })
emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' })
emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' })
emitObserved(join(root, 'flat-skill.md'), { name: 'write' })
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('observed'),
{ name: 'edit' },
)
expect(invalidations).toBe(2)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill'])
})
it('bounds project watchers and re-observes an evicted project on its next lookup', async () => {
const home = await tempDir('skill-watch-lru-home')
const first = await tempDir('skill-watch-lru-first')
const second = await tempDir('skill-watch-lru-second')
await mkdir(join(first, '.git'), { recursive: true })
await mkdir(join(second, '.git'), { recursive: true })
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project')
await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [join(first, '.agents/skills')],
watch: true,
watchMaxProjects: 1,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project')
expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project')
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed')
expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description)
.toBe('First project refreshed')
} finally {
await fiber.dispose()
}
const noWatch = new Context()
await noWatch.plugin(SkillService)
await noWatch.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
watchMaxProjects: 1,
})
await noWatch.skills.list({ cwd: first })
await noWatch.skills.list({ cwd: second })
})
it('contains repeated disposal and late first-party observations', async () => {
const home = await tempDir('skill-watch-dispose')
const nonDirectoryRoot = join(home, 'not-a-directory')
await writeFile(nonDirectoryRoot, 'not a skill root')
await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
ctx.skills.registerProvider(provider)
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
await provider.dispose()
await provider.dispose()
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
})
it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
const home = await tempDir('skill-watch-symlink-home')
const external = await tempDir('skill-watch-symlink-external')
const root = join(home, '.dsh/skills')
await writeSkill(external, 'linked-skill', 'First linked description')
await mkdir(root, { recursive: true })
await symlink(join(external, 'linked-skill'), join(root, 'linked-skill'))
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchFollowSymlinks: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list())[0]?.description).toBe('First linked description')
await writeSkill(external, 'linked-skill', 'Second linked description')
const refreshed = await waitFor(
async () => await ctx.skills.list(),
skills => skills[0]?.description === 'Second linked description',
)
expect(refreshed[0]?.name).toBe('linked-skill')
} finally {
await fiber.dispose()
}
})
it('validates watcher tunables at plugin load', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects')
await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs')
await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs')
})
it('uses default home root resolution without exposing builtin skills', async () => {
const previousDshHome = process.env.DSH_HOME
const previousAgentsHome = process.env.DSH_AGENTS_HOME
@@ -382,14 +702,14 @@ describe('LocalSkillProvider', () => {
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal)
await ctx.plugin(SkillLocal, { watch: false })
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, {})
SkillLocal.apply(empty, { watch: false })
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 639616d0b75f960e9ccd48546d44db841372bbe2
README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
README.md: f4f933576c60c8d750e3bd8eb0183ccb4b37e2da
README.zh.md: baa0d0ca43a8da3e3177d5c05437501a1fbe1735

View File

@@ -11,10 +11,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Events
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
### Config
| Field | Default | Meaning |
@@ -27,7 +33,9 @@ A provider registers synchronously and performs remote setup, authentication, an
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
Contract violations fail fast. A rejected `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and that exact provider is invalidated so the next snapshot rediscovers its catalog.
## Runtime Skills
@@ -39,15 +47,15 @@ The registry does not render model guidance or register model-facing tools. [`@d
## Model Experience
Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results.
Indirectly, through `dsh-tool-skill`, which renders provider summaries into the initial session prefix or durable replacement catalog messages and loaded instructions into retained tool results.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
No direct prompt effect. The named consumer owns initial prefix composition and append-only catalog replacements after invalidation.
## Known Limitations and Deferred Work
- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload.
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism.
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract.
- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.

View File

@@ -11,10 +11,16 @@
### 公开 API
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect可安全用于 HMR精确的 Cordis disposer 支持有序组合拆卸。
- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false不完整观测绝不缓存使面向模型的消费方可以保留上一份可用目录并在下一个请求边界重试。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
### 事件
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
### 配置
| 字段 | 默认值 | 含义 |
@@ -27,7 +33,9 @@
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
契约违反会快速失败。被拒绝的 `list()` 视为瞬时来源失败:系统记录它、跳过它,并且不缓存。只缓存已完成目录;提供方或运行时修订变更会丢弃正在进行的结果并重试。重复名称按 rank、提供方注册顺序然后按提供方本地顺序解析。摘要按 skill 名称排序。
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序然后按提供方本地顺序解析。摘要按 skill 名称排序。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。
## 运行时 Skill
@@ -39,15 +47,15 @@
## 模型体验
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到初始会话前缀或持久的替换目录消息中,并将已加载指令渲染到已保留工具结果中。
#### KV 缓存影响
不直接导致失效;指定的消费方负责其引起的任何请求前缀变更
不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换
## 已知限制与待完成工作
- **已完成目录没有 TTL 或 watcher 失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存 cwd 会保持陈旧,直到被驱逐或重新加载提供方/运行时
- **失效由提供方驱动**:注册表没有 TTL无法推断任意远程来源是否已发生变化每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
- **提供方列表失败会移除该请求的整个来源**:注册表会记录并跳过它,不提供模型可见诊断或部分目录恢复契约
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。

View File

@@ -87,6 +87,14 @@ export interface SkillLookupOptions {
readonly signal?: AbortSignal | undefined
}
/** One catalog observation plus whether every registered provider completed discovery. */
export interface SkillCatalogSnapshot {
/** Sorted model-invocable summaries from providers that completed. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed discovery for this observation. */
readonly complete: boolean
}
/** 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. */
@@ -119,6 +127,17 @@ declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider, runtime contribution, or provider-backed catalog may
* have changed. This is an unfiltered invalidation notification; consumers
* refetch the catalog for their own lookup options. Listener failures are
* contained and cannot veto the registry mutation.
* @mode emit
*/
'skills/change'(): void
}
}
interface IndexedCandidate {
@@ -189,6 +208,17 @@ export class SkillService extends Service {
return dispose
}
/**
* Invalidate catalogs contributed by one currently registered provider. Exact object identity
* prevents a late callback from an old provider instance from invalidating its replacement.
* Calls for an already-unregistered provider are harmless.
* @param provider - exact provider instance whose external source changed.
*/
invalidateProvider(provider: SkillProvider): void {
if (this.providers.get(provider.name)?.provider !== provider) return
this.invalidateCache()
}
/**
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
@@ -228,11 +258,26 @@ export class SkillService extends Service {
* @returns sorted summaries, excluding skills disabled for model invocation.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSkillSummary)
return (await this.snapshot(options)).skills
}
/**
* Observe the current model-invocable catalog and whether all providers completed discovery.
* Incomplete observations are never cached, allowing consumers to retain last-good state and
* retry on their next request boundary.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries plus provider-completeness state.
*/
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
const collected = await this.collect(options)
return {
skills: collected.entries
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSkillSummary),
complete: collected.cacheable,
}
}
/**
@@ -247,7 +292,7 @@ export class SkillService extends Service {
if (!isSkillName(name)) return undefined
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.find(entry => entry.candidate.name === name)
const match = collected.entries.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
const definition = await waitWithAbort(
match.provider.get(match.candidate, options),
@@ -255,17 +300,21 @@ export class SkillService extends Service {
)
if (definition === undefined) return undefined
validateDefinition(definition)
if (definition.name !== match.candidate.name) {
this.invalidateProvider(match.provider)
return undefined
}
return definition
}
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
throwIfAborted(options.signal)
while (true) {
const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision
const key = collectCacheKey(options, providerRevision, runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return cached
if (cached !== undefined) return { entries: cached, cacheable: true }
const result = await this.collectFresh(options)
throwIfAborted(options.signal)
@@ -277,7 +326,7 @@ export class SkillService extends Service {
this.collectCache.delete(oldest.value)
}
}
return result.entries
return result
}
}
@@ -339,6 +388,21 @@ export class SkillService extends Service {
private invalidateCache(): void {
this.providerRevision += 1
this.collectCache.clear()
this.notifyChange()
}
/** Notify catalog observers without making their refresh work load-bearing. */
private notifyChange(): void {
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
try {
const returned: unknown = callback()
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`)
}
}
}
}

View File

@@ -555,7 +555,9 @@ describe('SkillService registry', () => {
return undefined
},
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
const incomplete = await ctx.skills.snapshot()
expect(incomplete.skills.map(skill => skill.name)).toEqual(['second-skill'])
expect(incomplete.complete).toBe(false)
expect(flakyCalls).toBe(1)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(2)
@@ -566,6 +568,156 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3)
})
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
const dispose = ctx.skills.registerProvider(provider)
expect((await ctx.skills.snapshot()).complete).toBe(true)
provider.replace([memorySkill('second-skill', 'Second', 10)])
ctx.skills.invalidateProvider(new MemoryProvider([]))
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
ctx.skills.invalidateProvider(provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
dispose()
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
ctx.skills.registerProvider(replacement)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
ctx.skills.invalidateProvider(provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
expect(replacement.listCalls).toBe(1)
})
it('emits catalog invalidations for live provider and runtime mutations', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([memorySkill('provider-skill', 'Provider', 10)])
let changes = 0
ctx.on('skills/change', () => { changes += 1 })
const disposeProvider = ctx.skills.registerProvider(provider)
expect(changes).toBe(1)
ctx.skills.invalidateProvider(new MemoryProvider([]))
expect(changes).toBe(1)
ctx.skills.invalidateProvider(provider)
expect(changes).toBe(2)
const disposeRuntime = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime',
source: 'runtime',
content: 'Runtime body.',
})
expect(changes).toBe(3)
disposeRuntime()
expect(changes).toBe(4)
disposeProvider()
expect(changes).toBe(5)
ctx.skills.invalidateProvider(provider)
expect(changes).toBe(5)
})
it('contains synchronous and asynchronous catalog observer failures', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment
const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected')))
let observed = 0
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
const provider = new MemoryProvider([])
expect(() => ctx.skills.registerProvider(provider)).not.toThrow()
await Promise.resolve()
expect(observed).toBe(1)
expect(warnings).toEqual([
'skills/change listener threw: Error: observer threw',
'skills/change listener rejected: Error: observer rejected',
])
disposeThrowing()
disposeRejecting()
disposeObserver()
})
it('retries an in-flight catalog invalidated by its provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let release: (() => void) | undefined
const started = Promise.withResolvers<undefined>()
const gate = new Promise<void>((resolve) => { release = resolve })
const provider = new MemoryProvider([memorySkill('stale-skill', 'Stale', 10)])
const originalList = provider.list.bind(provider)
provider.list = async (options) => {
if (provider.listCalls === 0) {
provider.listCalls += 1
started.resolve(undefined)
await gate
return [memorySkill('stale-skill', 'Stale', 10)]
}
return await originalList(options)
}
ctx.skills.registerProvider(provider)
const pending = ctx.skills.list()
await started.promise
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
ctx.skills.invalidateProvider(provider)
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
expect(provider.listCalls).toBe(2)
})
it('invalidates a provider whose loaded definition changed identity', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
const provider: SkillProvider = {
name: 'renamed',
async list() {
listCalls += 1
return [{
name: 'old-name',
description: 'Old name',
provider: 'renamed',
source: 'test',
rank: 1,
locator: 'old-name',
}]
},
async get(candidate) {
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
},
}
ctx.skills.registerProvider(provider)
expect(await ctx.skills.get('old-name')).toBeUndefined()
await ctx.skills.list()
expect(listCalls).toBe(2)
})
it('returns undefined when a discovered candidate disappears before loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
name: 'vanished-body',
async list() {
return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }]
},
async get() {
return undefined
},
})
await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined()
})
it('contains a provider rejection whose string coercion throws', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 50a0e06ac06ca8a3b89c3d5ac604dcf2dc423533
README.zh.md: 56fb2b87adcf072cf2b8b6670864fa274ed5f66f
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: 7cbb2bef77bd32f188a4d3068a287fbee31b9371
README.zh.md: fe17e2e7080151e9bd41d4e0b96764719b27baa5

View File

@@ -4,13 +4,17 @@ English | [中文](README.zh.md)
The model-facing skill catalog and `skill` tool.
Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools', 'skills']`).
## Session-prefix catalog
## Catalog lifecycle
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned.
The plugin contributes the initial user-role `<system-reminder>` catalog through `agent/session-prefix`. Before every later model step it observes `ctx.skills.snapshot()` and computes a digest over exact `skill` tool visibility plus the ordered rendered `name` and `description` entries. It resolves skills for the calling session's cwd and lists only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message.
When that digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog and metadata `{ kind: 'skill-catalog', version: 1, digest }`. An empty replacement explicitly retires names from earlier catalogs. The latest still-visible metadata supplies the comparison baseline across replay or plugin reload. If compaction shadows that replacement, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of the initial message; the [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns durable replacements.
## Tool: `skill`
@@ -24,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr
An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results.
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.
Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries.
## Model Experience
@@ -32,7 +36,7 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as
#### What the model sees
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix.
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The initial catalog is a user-role session prefix. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names.
##### Skill catalog template
@@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca
#### Token effect
Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed.
Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no initial catalog tokens are sent when the list is empty or the tool is hidden or shadowed. Each actual catalog change adds one retained complete replacement message.
#### KV Cache effect
Prefix-stable within a loop instance once the session prefix is composed. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may invalidate reuse from the first changed catalog token.
The initial catalog remains prefix-stable. Dynamic changes are append-only history after that prefix, so existing reusable tokens stay intact while the replacement and later turns form a new suffix.
### Tool schema
@@ -146,3 +150,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Loaded instruction bodies have no size cap** — a provider can return a skill large enough to consume substantial next-step context; only catalog descriptions are truncated.
- **Resources are guidance, not attachments** — the tool reports a base directory/URL/opaque hint but neither enumerates nor fetches referenced files for the model.
- **Loading is one-shot text** — there is no partial, streaming, or cached-content handle when a remote provider is slow or a skill body is large.
- **Catalog replacement is whole-list** — one changed name or description appends every currently visible summary; this keeps stale-name retirement explicit but costs tokens proportional to the catalog.
- **Bodies are not versioned** — body-only edits do not change the catalog digest or notify the model; a later tool call reads the current provider content while earlier tool results remain historical facts.

View File

@@ -4,13 +4,17 @@
面向模型的 skill 目录和 `skill` 工具。
需要 `ctx.tools``ctx.skills` `inject: ['tools', 'skills']`)。
需要 `ctx.agents``ctx.tools``ctx.skills``inject: ['agents', 'tools', 'skills']`)。
## 会话前缀目录
## 目录生命周期
该插件贡献一个用户角色 `<system-reminder>` 目录,并通过 `agent/session-prefix` 提供它。它为调用会话的 cwd 解析 skill将前缀中止信号转发到发现并只列出已排序的 `name``description` 条目skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill则省略目录如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。这项精确定义检查使提示词指引、模型可见 schema 和可执行分派保持对齐
该插件通过 `agent/session-prefix` 提供初始的用户角色 `<system-reminder>` 目录。之后每个模型步骤开始前,它都会观察 `ctx.skills.snapshot()`,并针对 `skill` 工具的精确可见性,以及按顺序渲染的 `name``description` 条目计算 digest。它根据调用会话的 cwd 解析 skill且只列出这些摘要skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了该消息仅存在于请求中、记录于 header 的生命周期
该 digest 变化时,`agent.inject()` 会记录一条持久的用户角色消息,其中包含完整替换目录和元数据 `{ kind: 'skill-catalog', version: 1, digest }`。空替换会显式停用较早目录中的名称。恢复后最新且仍可见的元数据充当比较基线若压缩compaction遮蔽了替换消息模型步骤前的观察会改以会话前缀为基线并在必要时重新发布当前完整目录。提供方快照不完整时插件不会发送任何内容并会保留最后一次完整的模型视图以便在下一步骤重试。若不存在先前目录且当前视图为空则不需要 tombstone
如果最初没有模型可调用 skill则省略目录如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了初始消息仅存在于请求中、记录于 header 的生命周期;[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久替换。
## 工具:`skill`
@@ -24,7 +28,7 @@
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
工具在 v1 中不调用 `agent.inject()`结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。
工具执行不调用 `agent.inject()`新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。
## 模型体验
@@ -32,7 +36,7 @@
#### 模型所见
如果存在模型可调用 skill且该精确 `skill` 工具可见agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。目录是冻结的用户角色会话前缀。
如果存在模型可调用 skill且该精确 `skill` 工具可见agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。初始目录是用户角色会话前缀。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
##### Skill 目录模板
@@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca
#### Token 影响
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送初始目录 token。每次实际目录变更都会添加一条保留的完整替换消息。
#### KV 缓存影响
会话前缀组合完成后在一个循环实例内前缀稳定。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限则可能从第一个变更目录 token 起使重用失效
初始目录保持前缀稳定。动态变更作为该前缀之后的仅追加历史,因此现有可重用 token 保持不变,替换消息和后续轮次则形成新的后缀
### 工具 schema
@@ -146,3 +150,5 @@ Load referenced resources only as needed.
- **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill只有目录描述会被截断。
- **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。
- **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分、流式或缓存内容句柄。
- **目录替换采用全量列表**:一个名称或描述发生变化,就会追加当前所有可见摘要;这样能显式停用陈旧名称,但 token 成本与目录大小成正比。
- **正文不做版本化**:仅修改正文不会改变目录 digest也不会通知模型后续工具调用会读取提供方的当前内容而先前工具结果仍是历史事实。

View File

@@ -42,6 +42,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -4,16 +4,21 @@
* @module @deepseek-ai/dsh-tool-skill
*/
import { createHash } from 'node:crypto'
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['tools', 'skills']
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
const CATALOG_META_KIND = 'skill-catalog'
const CATALOG_META_VERSION = 1
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
/** Model-facing skill catalog configuration. */
export interface Config {
@@ -35,6 +40,7 @@ export const Config: z<Config> = z.object({
export function apply(ctx: Context, config: Config = {}): void {
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
const baselineBySession = new WeakMap<object, string>()
const skillTool = defineTool({
name: 'skill',
@@ -116,11 +122,40 @@ export function apply(ctx: Context, config: Config = {}): void {
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next()
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }
const rest = await next()
if (skills.length === 0) return rest
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
signal.throwIfAborted()
if (!snapshot.complete) return rest
const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength)
baselineBySession.set(agent.session, digest)
if (!toolVisible || snapshot.skills.length === 0) return rest
return [renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength), ...rest]
})
ctx.on('agent/pre-step', async (agent, _turn, _step, signal) => {
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }
signal.throwIfAborted()
if (!snapshot.complete) return
const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength)
const effective = latestVisibleCatalogDigest(agent) ?? baselineBySession.get(agent.session)
if (effective === digest) return
if (effective === undefined && snapshot.skills.length === 0) {
baselineBySession.set(agent.session, digest)
return
}
agent.inject(
renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength).content,
{
source: PLUGIN_SOURCE,
meta: { kind: CATALOG_META_KIND, version: CATALOG_META_VERSION, digest },
},
)
})
}
@@ -171,7 +206,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
}
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): Message {
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
const entries = renderCatalogEntries(skills, descriptionMaxLength)
return {
role: 'user',
content: [{
@@ -191,6 +226,68 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
}
}
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): Message {
const entries = renderCatalogEntries(skills, descriptionMaxLength)
const availability = skills.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
]
: [
'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.',
]
return {
role: 'user',
content: [{
type: 'text',
text: [
'<system-reminder>',
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
'',
'<available_skills>',
...entries,
'</available_skills>',
'',
...availability,
'</system-reminder>',
].join('\n'),
}],
}
}
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
}
function catalogDigest(toolVisible: boolean, skills: SkillSummary[], descriptionMaxLength: number): string {
return createHash('sha256')
.update(JSON.stringify({
toolVisible,
entries: renderCatalogEntries(skills, descriptionMaxLength),
}))
.digest('hex')
}
function latestVisibleCatalogDigest(agent: Agent): string | undefined {
const visible = new Set(agent.session.surface.nodes)
for (const event of [...agent.session.events].reverse()) {
if (!visible.has(event.seq)
|| event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== name) continue
const meta = event.data.meta
if (!isRecord(meta)
|| meta.kind !== CATALOG_META_KIND
|| meta.version !== CATALOG_META_VERSION
|| typeof meta.digest !== 'string') continue
return meta.digest
}
return undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function catalogDescription(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= maxLength

View File

@@ -5,9 +5,10 @@ import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
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'
@@ -28,8 +29,9 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
await ctx.plugin(toolSkill, config)
return ctx
}
@@ -38,6 +40,48 @@ function agentForCwd(cwd: string): Agent {
return { session: { header: { cwd } } } as unknown as Agent
}
function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
return {
id: SessionId(id),
options: {},
session,
status: 'running',
ctx: new Context(),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...(options?.meta === undefined ? {} : { meta: options.meta }),
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn = 1): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
async function firePreStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, new AbortController().signal)
}
function catalogUpdates(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tool-skill')
}
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
}
@@ -50,8 +94,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab
)
}
async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> {
const agent = agentForCwd(cwd)
async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> {
const agent = typeof subject === 'string' ? agentForCwd(subject) : subject
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
inject: ['tools'],
@@ -64,9 +108,10 @@ describe('dsh-tool-skill', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const home = await tempDir('tool-schema')
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
const fiber = await ctx.plugin(toolSkill)
@@ -169,15 +214,227 @@ describe('dsh-tool-skill', () => {
expect(await composePrefix(ctx, '/workspace')).toEqual([])
})
it('omits an incomplete initial catalog and retries on a later request boundary', async () => {
const home = await tempDir('tool-incomplete-prefix')
const ctx = await setup(home)
let failing = true
const provider = {
name: 'recovering',
async list() {
if (failing) throw new Error('temporarily unavailable')
return []
},
async get() {
return undefined
},
}
ctx.skills.registerProvider(provider)
const session = new Session(SessionId('incomplete-prefix'))
const agent = sessionAgent(session)
openMessageTurn(session)
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
failing = false
ctx.skills.invalidateProvider(provider)
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
})
it('records an empty baseline when pre-step runs before prefix composition', async () => {
const home = await tempDir('tool-empty-pre-step')
const ctx = await setup(home)
const session = new Session(SessionId('empty-pre-step'))
const agent = sessionAgent(session)
openMessageTurn(session)
await firePreStep(ctx, agent, 1, 1)
await firePreStep(ctx, agent, 1, 2)
expect(catalogUpdates(session)).toEqual([])
})
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
const home = await tempDir('tool-dynamic-catalog')
const ctx = await setup(home)
const disposeFirst = ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = new Session(SessionId('dynamic-catalog'))
const agent = sessionAgent(session)
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
const disposeSecond = ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
await firePreStep(ctx, agent, 1, 2)
const addition = catalogUpdates(session)[0]
if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
expect(addition.data.meta).toMatchObject({ kind: 'skill-catalog', version: 1 })
expect(JSON.stringify(addition.data.content)).toContain('first-skill')
expect(JSON.stringify(addition.data.content)).toContain('second-skill')
disposeSecond()
disposeFirst()
await firePreStep(ctx, agent, 1, 3)
const removal = catalogUpdates(session)[1]
if (removal?.type !== 'user/message') throw new Error('expected catalog removal')
expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available')
expect(JSON.stringify(removal.data.content)).not.toContain('first-skill')
expect(JSON.stringify(removal.data.content)).not.toContain('second-skill')
})
it('resumes from the latest valid visible catalog metadata', async () => {
const home = await tempDir('tool-catalog-resume')
const ctx = await setup(home)
ctx.skills.register({
name: 'resumed-skill',
description: 'Resumed skill',
source: 'runtime',
content: 'Resumed body.',
})
const session = new Session(SessionId('catalog-resume'))
const agent = sessionAgent(session)
openMessageTurn(session)
session.append('user/message', {
content: [{ type: 'text', text: 'old catalog' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: { kind: 'skill-catalog', version: 1, digest: 'old-digest' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'malformed metadata' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: { kind: 'skill-catalog', version: 1, digest: 42 },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'non-record metadata' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: [],
}, { surfaceOp: 'append' })
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toHaveLength(4)
expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('resumed-skill')
})
it('re-establishes a replacement catalog after compaction shadows its metadata', async () => {
const home = await tempDir('tool-catalog-compaction')
const ctx = await setup(home)
ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = new Session(SessionId('catalog-compaction'))
const agent = sessionAgent(session)
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
await firePreStep(ctx, agent, 1, 1)
const replacement = catalogUpdates(session)[0]
if (replacement === undefined) throw new Error('expected replacement catalog')
session.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: replacement.seq, end: replacement.seq },
sourceEventSeqs: [replacement.seq],
})
await firePreStep(ctx, agent, 1, 2)
expect(catalogUpdates(session)).toHaveLength(2)
expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('second-skill')
})
it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => {
const home = await tempDir('tool-body-refresh')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'body-skill', 'Stable description', 'First body.')
const ctx = await setup(home)
const session = new Session(SessionId('body-refresh'))
const agent = sessionAgent(session)
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description')
await writeSkill(root, 'body-skill', 'Stable description', 'Second body.')
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('body-refresh'),
name: 'skill',
arguments: { name: 'body-skill' },
agent,
})
expect(result.isError).toBe(false)
expect(JSON.stringify(result.content)).toContain('Second body.')
expect(JSON.stringify(result.content)).not.toContain('First body.')
})
it('retains the last-good catalog while any provider discovery is incomplete', async () => {
const home = await tempDir('tool-incomplete-catalog')
const ctx = await setup(home)
const disposeStable = ctx.skills.register({
name: 'stable-skill',
description: 'Stable skill',
source: 'runtime',
content: 'Stable body.',
})
const session = new Session(SessionId('incomplete-catalog'))
const agent = sessionAgent(session)
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
ctx.skills.registerProvider({
name: 'failing',
async list() {
throw new Error('temporarily unavailable')
},
async get() {
return undefined
},
})
disposeStable()
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
})
it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
const home = await tempDir('tool-restricted-catalog')
const ctx = await setup(home)
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
const { agent, scope } = await mintAgentScope(ctx, '/workspace')
const session = new Session(SessionId('restricted-catalog'))
const agent = sessionAgent(session)
openMessageTurn(session)
const { scope } = await mintAgentScope(ctx, agent)
scope.ctx.tools.restrict({ deny: ['skill'] })
expect(ctx.tools.get('skill', agent)).toBeUndefined()
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
await scope.dispose()
})
@@ -207,8 +464,9 @@ describe('dsh-tool-skill', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
})