Merge latest master into codex/migrate-to-oxlint

This commit is contained in:
Tianyi Cui
2026-07-30 00:13:30 +08:00
84 changed files with 4195 additions and 445 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 8a8002ef75299579d7df1eb562a7e8bfaf2aeb2a
README.zh.md: 27e6ab94d4d1cee7fcb7943e0f07c4de2a2f7503
README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f
README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e

View File

@@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -16,7 +16,7 @@
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。

View File

@@ -83,7 +83,7 @@ function progressLabel(todos: readonly TodoItem[]): string {
}
export function TodoPanel({ todos }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(true)
if (todos.length === 0) return null
return (

View File

@@ -31,11 +31,18 @@ describe('TodoPanel', () => {
expect(container.innerHTML).toBe('')
})
it('shows progress, one row per item with its status glyph', () => {
it('starts collapsed with the progress summary visible', () => {
render(<TodoPanel todos={LIST} />)
expect(screen.getByTestId('todo-panel')).toBeTruthy()
expect(screen.getByText('To-dos')).toBeTruthy()
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
expect(screen.queryByRole('list')).toBeNull()
})
it('expands to show one row per item with its status glyph', () => {
render(<TodoPanel todos={LIST} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
const items = screen.getAllByRole('listitem')
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
expect(screen.getByText('搭骨架')).toBeTruthy()
@@ -44,8 +51,9 @@ describe('TodoPanel', () => {
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
})
it('collapse hides the list; expand restores; header keeps the count summary', () => {
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
render(<TodoPanel todos={LIST} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
const header = screen.getByRole('button', { expanded: true })
fireEvent.click(header)
expect(screen.queryByRole('list')).toBeNull()
@@ -58,7 +66,7 @@ describe('TodoPanel', () => {
it('collapsed header still shows zero in-progress when nothing is active', () => {
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
expect(screen.queryByText('都完了')).toBeNull()
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
README.zh.md: ed582128246a62297f555f8abe09f427cb9d256a
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。

View File

@@ -753,16 +753,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Registry of skill providers.',
methods: [
{
signature: 'registerProvider(provider: SkillProvider): () => void',
jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */',
signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void',
jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */',
},
{
signature: 'register(skill: SkillRegistration): () => void',
jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
},
{
signature: 'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */',
jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */',
},
{
signature: 'async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot>',
jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */',
},
{
signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
@@ -1270,6 +1274,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
name: 'skills/change',
mode: 'emit',
signature: '\'skills/change\'(): void',
jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */',
summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.',
},
{
name: 'slash/input-begin-command',
mode: 'bail',
@@ -2339,21 +2350,37 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
},
{
name: 'SkillCatalogSnapshot',
declaration: 'export interface SkillCatalogSnapshot {\n readonly skills: SkillSummary[];\n readonly complete: boolean;\n}',
},
{
name: 'SkillDefinition',
declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
},
{
name: 'SkillInvocationPolicy',
declaration: 'export interface SkillInvocationPolicy {\n readonly modelInvocable: boolean;\n readonly userInvocable: boolean;\n}',
},
{
name: 'SkillLookupOptions',
declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}',
},
{
name: 'SkillProvider',
declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>;\n}',
declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>;\n}',
},
{
name: 'SkillProviderControl',
declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}',
},
{
name: 'SkillProviderObservation',
declaration: 'export interface SkillProviderObservation {\n readonly candidates: readonly SkillCandidate[];\n readonly complete: boolean;\n}',
},
{
name: 'SkillRegistration',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n readonly provider?: string;\n};',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'invocation\' | \'provider\'> & {\n readonly invocation?: SkillInvocationPolicy;\n readonly provider?: string;\n};',
},
{
name: 'SkillResourceBase',
@@ -2365,7 +2392,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
},
{
name: 'SpillLocator',

View File

@@ -55,6 +55,7 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",

View File

@@ -8,9 +8,12 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { createUserMessage,
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import {
createUserMessage,
CallId,
LlmAdapter,
LlmError,
@@ -404,6 +407,142 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('snapshots a created project skill through catalog refresh and progressive loading', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-'))
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-'))
try {
await mkdir(join(root, '.git'), { recursive: true })
const skillPath = '.agents/skills/hot-skill/SKILL.md'
const skillSource = '---\nname: hot-skill\ndescription: Hot-added skill\n---\n\nUse the freshly loaded body.\n'
const adapter = new MockAdapter([
toolCallResponse('mkdir-skill', 'bash', {
command: 'mkdir -p .agents/skills/hot-skill',
description: 'Create the project skill directory',
}),
toolCallResponse('write-skill', 'write', {
file_path: skillPath,
content: skillSource,
}),
toolCallResponse('load-skill', 'skill', { name: 'hot-skill' }),
textResponse('SKILL_REFRESH_OK'),
])
const ctx = await mount({
workspaceContext: false,
skills: {
local: {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
},
},
})
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(ToolFs)
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('skill-refresh-session'),
meta: { cwd: root },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Create and load the project skill.' }],
source: { kind: 'user' },
}))
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toHaveLength(4)
expect(adapter.requests.slice(0, 2).map(request => request.messages.map(messageText).join('\n')))
.toEqual([
expect.not.stringContaining('hot-skill'),
expect.not.stringContaining('hot-skill'),
])
const catalogRequest = adapter.requests[2]?.messages.map(messageText).join('\n')
expect(catalogRequest).toContain('The following skills are available in this session:')
expect(catalogRequest).toContain('- `hot-skill`: Hot-added skill')
const loadedRequest = JSON.stringify(adapter.requests[3]?.messages)
expect(loadedRequest).toContain('<skill_instructions>')
expect(loadedRequest).toContain('Use the freshly loaded body.')
const transcript = handle.agent.session.events.flatMap<Record<string, unknown>>((event) => {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'dsh-tool-skill') {
return [{
type: event.type,
source: event.data.source,
text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n'),
}]
}
if (event.type === 'tool/result'
&& ['write-skill', 'load-skill'].includes(event.data.message.source.callId)) {
const result = event.data.message.content[0]
return [{
type: event.type,
callId: event.data.message.source.callId,
isError: result.isError,
text: result.content.map(block => block.type === 'text' ? block.text : '').join('\n')
.replaceAll(root, '{{cwd}}'),
}]
}
return []
})
expect(transcript).toMatchInlineSnapshot(`
[
{
"callId": "write-skill",
"isError": false,
"text": "<path>{{cwd}}/.agents/skills/hot-skill/SKILL.md</path>
<type>file</type>
<content>
Created file
</content>",
"type": "tool/result",
},
{
"source": {
"kind": "plugin",
"plugin": "dsh-tool-skill",
},
"text": "<system-reminder>
A skill is a reusable set of task-specific instructions. The following skills are available in this session:
<available_skills>
- \`hot-skill\`: Hot-added skill
</available_skills>
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
</system-reminder>",
"type": "user/message",
},
{
"callId": "load-skill",
"isError": false,
"text": "<skill_content name="hot-skill">
<skill_resources>
Base directory for this skill: {{cwd}}/.agents/skills/hot-skill
Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.
</skill_resources>
<skill_instructions>
Use the freshly loaded body.
</skill_instructions>
</skill_content>",
"type": "tool/result",
},
]
`)
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('shares top-level dshHome between local skills and the managed bash environment', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-'))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: b5f80dcb3a077a411db3b721737a9c16b56fcecf
README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae
README.md: 1f0daedc54888a1951bc83c474f83287aaf42307
README.zh.md: abf5417cdbe93f1199c621ac101249986969da93

View File

@@ -22,7 +22,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -22,7 +22,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -1457,7 +1457,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
try {
const skills = await skillRegistry.list({ cwd })
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,

View File

@@ -20,6 +20,6 @@ export interface SkillEntry {
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
export interface SkillsApi {
/** Lists model-invocable skills for the addressed session's project root. */
/** Lists skills usable by the browser's user-selected model-reference path. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -180,17 +180,35 @@ describe('skill.list', () => {
it('lists skills for the session cwd taken from the header', async () => {
const ctx = await harness()
const seenCwds: (string | undefined)[] = []
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'probe',
list: (options) => {
seenCwds.push(options.cwd)
return Promise.resolve([{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
source: 'custom', provider: 'probe', rank: 0, locator: null,
}])
return Promise.resolve([
{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'user-only', description: 'User-only',
invocation: { modelInvocable: false, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'model-only', description: 'Model-only',
invocation: { modelInvocable: true, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'trusted-only', description: 'Trusted-only',
invocation: { modelInvocable: false, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
])
},
get: () => Promise.resolve(undefined),
})
}))
const api = createApiProxy(ctx, DEFAULTS)
// No agent is registered for this session: header resolution must not
// touch (or resume through) the Agent registry.
@@ -219,11 +237,11 @@ describe('skill.list', () => {
it('folds a provider failure into internal', async () => {
const ctx = await harness()
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'broken',
list: () => Promise.reject(new Error('directory exploded')),
get: () => Promise.resolve(undefined),
})
}))
const api = createApiProxy(ctx, DEFAULTS)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const response = await api.skills.list(request({ sessionId: session.id }))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/README.md
README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f
README.zh.md: 0db8933ecff2a8ab4767b29bc5319453ddfb87fa
README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822
README.zh.md: 5981173d05e74e0576e83f4f0ec42894050675cf

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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
README.md: c488fdc4b1d97b5aa1113e41a470484063526ded
README.zh.md: c6e32a3ddbc2279428e23051e5636cf12c558ba9
README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453
README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Local filesystem provider for the `ctx.skills` registry.
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
## Plugin
@@ -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,36 @@ 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 and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. 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.
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 an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation.
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 to the existing request history. 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

@@ -4,7 +4,7 @@
`ctx.skills` 注册表的本地文件系统提供方。
该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
## 插件
@@ -17,6 +17,12 @@
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 |
| `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.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,36 @@
项目根目录是包含 `.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。
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。
## 模型体验
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
## 已知限制与暂缓事项
- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`;忽略嵌套 skill 树和包 manifest(元数据清单)。
- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。
- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。
- **无文件系统监听**:在重新发现先前已收集的 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'
@@ -22,8 +24,11 @@ import {
isSkillName,
type SkillCandidate,
type SkillDefinition,
type SkillInvocationPolicy,
type SkillLookupOptions,
type SkillProvider,
type SkillProviderControl,
type SkillProviderObservation,
type SkillSource,
} from '@deepseek-ai/dsh-skill'
@@ -32,6 +37,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
const BUNDLED_RANK = 600
export const name = 'skill-local'
@@ -45,6 +53,18 @@ 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
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
bundledSkillDir?: string
}
@@ -53,6 +73,12 @@ 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),
bundledSkillDir: z.string(),
})
@@ -61,6 +87,7 @@ interface SkillRoot {
source: SkillSource
rank: number
skipSystem?: boolean
projectRoot?: string
trustedHost?: boolean
}
@@ -74,7 +101,7 @@ interface ParsedSkill {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
invocation: SkillInvocationPolicy
metadata?: Record<string, unknown>
content: string
}
@@ -84,10 +111,29 @@ 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)
let provider!: LocalSkillProvider
ctx.skills.registerProvider((control) => {
provider = new LocalSkillProvider(ctx, control, config)
return 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`. */
@@ -96,12 +142,20 @@ export class LocalSkillProvider implements SkillProvider {
private readonly dshHome: string
private readonly agentsHome: string
private readonly customSkillDirs: string[]
private readonly watchManager: SkillWatchManager
private readonly bundledSkillDir: string | undefined
private disposal: Promise<void> | undefined
constructor(private readonly ctx: Context, config: Config = {}) {
constructor(
private readonly ctx: Context,
control: SkillProviderControl,
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, control.invalidate, resolveWatchConfig(config))
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
}
@@ -109,17 +163,25 @@ export class LocalSkillProvider implements SkillProvider {
/**
* Discover local skill summaries for a cwd-sensitive workspace.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @returns local provider candidates with stable root ranks.
* @returns local provider candidates with stable root ranks; watcher startup
* failure returns readable candidates as an incomplete observation.
*/
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
async list(options: SkillLookupOptions): Promise<SkillCandidate[] | SkillProviderObservation> {
const roots = await this.roots(options.cwd)
let complete = true
try {
await this.watchManager.observeRoots(roots)
} catch (error) {
if (this.disposal !== undefined) throw error
complete = false
}
const candidates: SkillCandidate[] = []
for (const root of roots) {
for (const skill of await discoverRoot(root, this.ctx)) {
candidates.push(skill)
}
}
return candidates
return complete ? candidates : { candidates, complete }
}
/**
@@ -136,7 +198,7 @@ export class LocalSkillProvider implements SkillProvider {
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
invocation: parsed.invocation,
source: candidate.source,
provider: this.name,
resourceBase: { kind: 'directory', path: locator.directory },
@@ -146,13 +208,30 @@ 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.
* @returns a shared promise that settles when every watcher reaches quiescence.
*/
dispose(): Promise<void> {
this.disposal ??= this.watchManager.dispose()
return this.disposal
}
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(
@@ -167,6 +246,453 @@ 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 {
mode: RootWatchMode
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 readonly lifecycle = new AbortController()
private closing = false
private invalidationQueued = false
constructor(
private readonly ctx: Context,
private readonly invalidate: () => void,
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.invalidate()
}
observeHostMutation(path: string): void {
if (this.closing) return
const normalized = resolve(path)
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
this.invalidate()
}
async dispose(): Promise<void> {
this.closing = true
this.lifecycle.abort(new Error('skill-local watcher disposed'))
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> {
/* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */
if (this.closing || !this.config.enabled) return Promise.resolve()
if (state.opening !== undefined) return state.opening
const opening = this.ensureCurrentWatcher(state)
state.opening = opening
void opening.then(
() => {
state.opening = undefined
},
() => {
state.opening = undefined
},
)
return opening
}
private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {
const watcher = state.watcher
if (watcher !== undefined && !state.unhealthy) {
const current = await resolveRootWatchMode(state.root.path)
// A child unlink can publish an empty catalog before root unlinkDir arrives.
// Discovery therefore revalidates the retained handle independently.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return
}
await this.replaceWatcher(state)
}
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. */
// oxlint-disable-next-line typescript/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) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
if (!this.closing) {
state.unhealthy = true
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
}
throw error
}
}
// TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis
// service; keep skill filtering and invalidation here.
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 => {
void this.handleAncestorWatchEvent(state, mode)
}
watchFile(mode.nextPath, {
persistent: false,
interval: this.config.pollIntervalMs,
}, listener)
return {
mode,
close() {
unwatchFile(mode.nextPath, listener)
},
}
}
private async handleAncestorWatchEvent(
state: RootWatchState,
mode: Extract<RootWatchMode, { kind: 'ancestor' }>,
): Promise<void> {
let current: RootWatchMode
try {
current = await resolveRootWatchMode(state.root.path)
} catch (error) {
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
return
/* v8 ignore stop */
}
if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return
this.queueInvalidation()
state.unhealthy = true
this.scheduleRewatch(state)
}
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,
})
const handle: WatchHandle = {
mode,
close: () => watcher.close(),
}
let ready = false
const readiness = Promise.withResolvers<undefined>()
const signal = this.lifecycle.signal
if (signal.aborted) {
await this.closeWatcher(handle)
signal.throwIfAborted()
}
const onAbort = (): void => { readiness.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
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, event, path) })
}
try {
await readiness.promise
} catch (error) {
await this.closeWatcher(handle)
throw error
} finally {
signal.removeEventListener('abort', onAbort)
}
return handle
}
private handleWatchEvent(
state: RootWatchState,
event: SkillWatchEvent,
path: string,
): void {
if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return
this.queueInvalidation()
if (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
/* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */
if (this.closing) return
this.invalidate()
})
}
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,
event: SkillWatchEvent,
path: string,
): boolean {
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)
@@ -184,7 +710,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
invocation: parsed.invocation,
provider: 'local',
source: root.source,
rank: root.rank,
@@ -204,9 +730,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[]> {
@@ -222,9 +751,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[] = []
@@ -263,11 +794,18 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
return undefined
}
let invocation
try {
invocation = parseInvocationPolicy(parsed.data)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
return undefined
}
return {
name,
description,
...optionalString(parsed.data, 'whenToUse'),
...optionalBoolean(parsed.data, 'disableModelInvocation'),
invocation,
...optionalMetadata(parsed.data),
content: parsed.body.trim(),
}
@@ -285,31 +823,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, t
}
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
}
@@ -420,9 +966,43 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
}
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy {
rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')
const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')
const userInvocable = frontmatterBoolean(data, 'user-invocable')
return {
modelInvocable: disableModelInvocation !== true,
userInvocable: userInvocable !== false,
}
}
function rejectLegacyInvocationKey(data: Record<string, unknown>, legacy: string, canonical: string): void {
if (Object.hasOwn(data, legacy)) {
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
}
}
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
if (!Object.hasOwn(data, key)) return undefined
const value = data[key]
return typeof value === 'boolean' ? { [key]: value } : {}
if (typeof value === 'boolean') return value
if (value === 1 || value === '1') return true
if (value === 0 || value === '0') return false
if (typeof value === 'string') {
switch (value.toLowerCase()) {
case 'true':
case 'yes':
case 'on':
return true
case 'false':
case 'no':
case 'off':
return false
}
}
throw new TypeError(`frontmatter field "${key}" must be a boolean`)
}
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {

View File

@@ -0,0 +1,370 @@
import { EventEmitter } from 'node:events'
import type { Stats } from 'node:fs'
import { mkdir, rm, 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>
}
interface FakeWatchFileControl {
path: string
listener(current: Stats, previous: Stats): void
}
interface FakeStatGate {
started: PromiseWithResolvers<undefined>
release: PromiseWithResolvers<undefined>
}
const watcherHarness = vi.hoisted(() => ({
watchers: [] as FakeWatcherControl[],
startupErrors: [] as Error[],
closeErrors: 0,
deferredReady: 0,
watchFiles: [] as FakeWatchFileControl[],
statGates: [] as FakeStatGate[],
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) {
watcherHarness.watchFiles.push({ path, listener })
},
unwatchFile(path: string, listener: FakeWatchFileControl['listener']) {
const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener)
if (index !== -1) watcherHarness.watchFiles.splice(index, 1)
},
}
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async stat(...args: Parameters<typeof actual.stat>) {
const gate = watcherHarness.statGates.shift()
if (gate !== undefined) {
gate.started.resolve(undefined)
await gate.release.promise
}
return await actual.stat(...args)
},
}
})
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
watcherHarness.watchFiles.length = 0
watcherHarness.statGates.length = 0
})
describe('skill-local watcher failures', () => {
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
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,
})
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
expect(watcherHarness.watchFiles).toHaveLength(2)
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
for (const control of watcherHarness.watchFiles) {
control.listener({} as Stats, {} as Stats)
}
await settle()
expect(invalidations).toBe(0)
expect(watcherHarness.watchFiles).toHaveLength(2)
await fiber.dispose()
})
it('keeps skills loadable across persistent watcher startup failures without caching them', 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 once'),
new Error('watch failed twice'),
new Error('watch failed three times'),
)
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()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.')
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect(watcherHarness.watchers).toHaveLength(3)
expect(watcherHarness.watchers[0]?.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'])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
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('re-probes a retained root after child unlink and observes immediate recreation', async () => {
const home = await tempDir('skill-watch-root-reprobe')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'old-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(['old-skill'])
const original = watcherHarness.watchers[0]
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md'))
await settle()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
const missingRoot = watcherHarness.watchFiles.find(control => control.path === root)
expect(missingRoot).toBeDefined()
await writeSkill(root, 'recreated-skill')
missingRoot!.listener({} as Stats, {} as Stats)
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) })
await settle()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill'])
await fiber.dispose()
})
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)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
await disposal
disposeProvider()
await settle()
expect(first.closeCalls).toBeGreaterThan(0)
})
it('closes an opening watcher when disposal wins the mode probe', async () => {
const home = await tempDir('skill-watch-probe-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const statGate: FakeStatGate = {
started: Promise.withResolvers<undefined>(),
release: Promise.withResolvers<undefined>(),
}
watcherHarness.statGates.push(statGate)
const ctx = new Context()
await ctx.plugin(SkillService)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await statGate.started.promise
const disposal = provider.dispose()
statGate.release.resolve(undefined)
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
await disposal
expect(watcherHarness.watchers).toHaveLength(1)
expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0)
disposeProvider()
})
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)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
first.emitter.emit('error', new Error('opening failed during disposal'))
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('opening failed during disposal')
await disposal
disposeProvider()
})
})

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')
@@ -200,7 +221,7 @@ describe('LocalSkillProvider', () => {
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
})
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => {
const home = await tempDir('skill-flat')
const root = join(home, '.dsh/skills')
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
@@ -209,7 +230,8 @@ describe('LocalSkillProvider', () => {
'name: rich-skill',
'description: rich description',
'whenToUse: For richer local parsing',
'disableModelInvocation: false',
'disable-model-invocation: off',
'user-invocable: YES',
'metadata:',
' owner: tests',
'---',
@@ -225,26 +247,110 @@ describe('LocalSkillProvider', () => {
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
await writeFile(join(root, 'notes.txt'), 'ignored')
await mkdir(join(root, 'not-a-skill'), { recursive: true })
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.')
await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n')
await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.')
await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n')
const ctx = await setupLocal(home)
const listedBeforeDelete = await ctx.skills.list()
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
if (flatSummary === undefined) throw new Error('expected flat-skill')
await writeFile(join(root, 'flat-skill.md'), '')
await rm(join(root, 'flat-skill.md'))
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
'flat-skill',
'model-only-skill',
'no-trailing-body',
'rich-skill',
'user-only-skill',
])
expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true })
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
expect(await ctx.skills.get('no-trailing-body')).toMatchObject({
invocation: { modelInvocable: true, userInvocable: true },
})
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
invocation: { modelInvocable: false, userInvocable: true },
content: 'User-only.',
})
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
invocation: { modelInvocable: true, userInvocable: false },
content: 'Model-only.',
})
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
disableModelInvocation: false,
invocation: { modelInvocable: true, userInvocable: true },
metadata: { owner: 'tests' },
})
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('accepts the documented boolean spellings for invocation frontmatter', async () => {
const home = await tempDir('skill-invocation-booleans')
const root = join(home, '.dsh/skills')
await mkdir(root, { recursive: true })
const truthy = ['true', 'TRUE', '"true"', 'yes', 'ON', '1', '"1"']
const falsy = ['false', 'FALSE', '"false"', 'no', 'OFF', '0', '"0"']
for (const [index, value] of truthy.entries()) {
await writeFile(join(root, `truthy-${index}.md`), [
'---',
`name: truthy-${index}`,
`description: Truthy ${index}`,
`disable-model-invocation: ${value}`,
'---',
'',
'Truthy.',
].join('\n'))
}
for (const [index, value] of falsy.entries()) {
await writeFile(join(root, `falsy-${index}.md`), [
'---',
`name: falsy-${index}`,
`description: Falsy ${index}`,
`user-invocable: ${value}`,
'---',
'',
'Falsy.',
].join('\n'))
}
const ctx = await setupLocal(home)
for (const [index] of truthy.entries()) {
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({
modelInvocable: false,
userInvocable: true,
})
}
for (const [index] of falsy.entries()) {
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({
modelInvocable: true,
userInvocable: false,
})
}
})
it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => {
const home = await tempDir('skill-invalid-invocation')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'good-skill', 'Good skill')
const invalid = [
['legacy-model', 'disableModelInvocation: true'],
['legacy-positive-model', 'modelInvocable: false'],
['legacy-user', 'userInvocable: false'],
['bad-string', 'disable-model-invocation: maybe'],
['bad-value', 'user-invocable: null'],
] as const
for (const [name, field] of invalid) {
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
}
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
})
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
const home = await tempDir('skill-frontmatter-crlf')
const root = join(home, '.dsh/skills')
@@ -335,7 +441,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'],
@@ -359,6 +465,92 @@ describe('LocalSkillProvider', () => {
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
})
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')
@@ -367,7 +559,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 = []
@@ -394,6 +586,223 @@ 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([])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
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)
let provider!: SkillLocal.LocalSkillProvider
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
return provider
})
const beforeDisposal = await provider.list({})
expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
await provider.dispose()
await provider.dispose()
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
const afterDisposal = await provider.list({})
expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
disposeProvider()
})
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
@@ -408,7 +817,7 @@ describe('LocalSkillProvider', () => {
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled 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-bundled-skill', 'env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
@@ -416,11 +825,14 @@ describe('LocalSkillProvider', () => {
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
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
expect(new SkillLocal.LocalSkillProvider(empty, {
signal: new AbortController().signal,
invalidate() {},
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
README.md: 639616d0b75f960e9ccd48546d44db841372bbe2
README.zh.md: d2191d54502121c68976ead2541a0588a0530160
README.md: f538ae668ccff291be86348627d5547150f460df
README.zh.md: 8a44f684ea4d9519a0af7866d272a8e7834aeda6

View File

@@ -10,10 +10,15 @@ 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.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.
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `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 regardless of invocation policy.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `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 an active provider's registration control invalidates. 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
@@ -21,33 +26,48 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|---|---|---|
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. |
### Invocation policy
`SkillSummary.invocation` is a required typed policy object whose positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently. Providers return this resolved shape on every candidate and definition; only the `SkillRegistration` input may omit it, in which case `register()` supplies `{ modelInvocable: true, userInvocable: true }`. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs.
| Policy | Model | User |
|---|---|---|
| `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: false }` | included | excluded |
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
## Provider Contract
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
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 and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. 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 the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
## Runtime Skills
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service materializes one top-level definition to supply omitted invocation and provider defaults. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Consumer boundary
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide durable session catalogs and the `skill` tool, so providers remain independent of the model surface.
## 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 durable initial or 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 the durable initial catalog and append-only 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 retain and call its registration-scoped `invalidate()` capability 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.
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.

View File

@@ -10,10 +10,15 @@
### 公开 API
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR(热模块替换);Cordis 返回的原始 disposer 支持有序组合拆卸。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
### 事件
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
### 配置
@@ -21,33 +26,48 @@
|---|---|---|
| `collectCacheMaxEntries` | `128` | 内存中保留的最大已完成 cwd/提供方目录数。 |
### 调用策略
`SkillSummary.invocation` 是一个必填的类型化策略对象,其正向布尔字段 `modelInvocable` 和 `userInvocable` 分别描述两个接口。提供方会在每个候选项和定义中返回这一已解析形状;只有 `SkillRegistration` 输入可以省略它,此时 `register()` 会补入 `{ modelInvocable: true, userInvocable: true }`。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
| 策略 | 模型 | 用户 |
|---|---|---|
| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约
提供方同步注册,并在可等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现由提供方可等待的 `list(options)` 调用执行。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败:系统记录并跳过该失败,且不缓存结果。只缓存已完成的目录;提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
## 运行时 skill
`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化提供默认 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。
`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化补入默认调用策略和 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。
## 消费方边界
注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供会话前缀目录和 `skill` 工具,因此提供方仍与模型接口独立。
注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供持久会话目录和 `skill` 工具,因此提供方仍与模型接口独立。
## 模型体验
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到持久的初始目录或替换目录消息中,并将已加载指令渲染到已保留工具结果中。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
不直接影响提示词。指定的消费方负责持久初始目录,以及失效后的仅追加式目录替换。
## 已知限制与暂缓事项
- **已完成的目录没有 TTL 或监听失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存的 cwd 会保持陈旧,直到缓存条目被淘汰或提供方/运行时重新加载。
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
- **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。
- **提供方列表失败会使该请求无法使用整个来源**:注册表会记录并跳过该来源,不提供模型可见诊断或部分目录恢复契约。
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。

View File

@@ -15,6 +15,7 @@ import type Schema from 'schemastery'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
const MAX_COLLECT_ATTEMPTS = 2
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
@@ -36,16 +37,24 @@ export type SkillResourceBase =
| { readonly kind: 'url'; readonly url: string }
| { readonly kind: 'opaque'; readonly description: string }
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
/** Invocation controls shared by skill discovery consumers. */
export interface SkillInvocationPolicy {
/** Whether model-facing catalogs and loaders include this skill. */
readonly modelInvocable: boolean
/** Whether human-facing command catalogs and loaders include this skill. */
readonly userInvocable: boolean
}
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
export interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
/** Kebab-case identifier used to address the skill. */
readonly name: string
/** Short routing description shown to the model. */
/** Short routing description shown by discovery consumers. */
readonly description: string
/** Optional extra routing guidance shown to the model. */
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
readonly disableModelInvocation?: boolean
/** Resolved model and user invocation controls. */
readonly invocation: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
@@ -77,7 +86,12 @@ export interface SkillDefinition extends SkillSummary {
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
export type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
/** Invocation controls; omission permits both model and user surfaces. */
readonly invocation?: SkillInvocationPolicy
/** Provider label; omission uses the registry-owned runtime provider. */
readonly provider?: string
}
/** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions {
@@ -87,6 +101,40 @@ export interface SkillLookupOptions {
readonly signal?: AbortSignal | undefined
}
/**
* Return whether a skill may be advertised to and loaded by a model.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits model invocation.
*/
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation.modelInvocable
}
/**
* Return whether a skill may be advertised to and loaded by a human-facing command.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits user invocation.
*/
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation.userInvocable
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted invocation-neutral summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed without a concurrent catalog revision. */
readonly complete: boolean
}
/** Provider candidates plus whether the current discovery is authoritative. */
export interface SkillProviderObservation {
/** Candidates available from the current provider discovery. */
readonly candidates: readonly SkillCandidate[]
/** Whether discovery completed and these candidates may be cached. */
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. */
@@ -97,9 +145,10 @@ export interface SkillProvider {
* authentication, and discovery are awaited inside this method. Implementations
* should settle promptly when `options.signal` aborts.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
* @returns provider candidates as a complete-array shorthand, or an explicit
* observation when usable candidates came from incomplete discovery.
*/
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
@@ -109,6 +158,14 @@ export interface SkillProvider {
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
export interface SkillProviderControl {
/** Aborts if registration fails or when the exact provider registration is disposed. */
readonly signal: AbortSignal
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
readonly invalidate: () => void
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalogs kept in memory. */
@@ -119,6 +176,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 {
@@ -135,7 +203,7 @@ interface CollectResult {
/**
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted model-visible summaries, and
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
* loads full skill bodies on demand.
*/
export class SkillService extends Service {
@@ -145,7 +213,7 @@ export class SkillService extends Service {
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillRegistration>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private nextProviderOrder = 0
@@ -161,39 +229,57 @@ export class SkillService extends Service {
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
* the provider and invalidates catalog caches.
* @param provider - the provider to register by `provider.name`.
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(provider: SkillProvider): () => void {
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
const lifecycle = new AbortController()
let active = false
let provider: SkillProvider
const control: SkillProviderControl = {
signal: lifecycle.signal,
invalidate: () => {
if (active) this.invalidateProvider(provider)
},
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
providers.delete(name)
invalidateCache()
try {
provider = create(control)
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
}, 'skills.registerProvider()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
active = true
providers.set(name, { provider, order })
invalidateCache()
yield () => {
active = false
providers.delete(name)
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
invalidateCache()
}
}, 'skills.registerProvider()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
throw error
}
}
/**
* 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
* receives a no-op disposer so it cannot remove the winner.
* @param skill - the complete skill definition to expose for discovery.
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
*/
register(skill: SkillRegistration): () => void {
@@ -203,15 +289,20 @@ export class SkillService extends Service {
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
const definition: SkillDefinition = {
...skill,
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },
provider: skill.provider ?? RUNTIME_PROVIDER,
}
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(skill.name, skill)
runtime.set(definition.name, definition)
updateRevision()
invalidateCache()
yield () => {
runtime.delete(skill.name)
runtime.delete(definition.name)
updateRevision()
invalidateCache()
}
@@ -221,18 +312,33 @@ export class SkillService extends Service {
}
/**
* List model-invocable skill summaries for a workspace. Lookup options and
* provider candidates are readonly same-process values borrowed throughout
* discovery.
* List invocation-neutral skill summaries for a workspace. Consumers apply
* model or user invocation policy at their operational boundary. Lookup
* options and provider candidates are readonly same-process values borrowed
* throughout discovery.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries, excluding skills disabled for model invocation.
* @returns all sorted winning summaries.
*/
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 invocation-neutral catalog and whether discovery completed within a stable revision.
* 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 discovery-completeness state.
*/
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
const collected = await this.collect(options)
return {
skills: collected.entries
.map(entry => entry.candidate)
.map(toSummary)
.sort(compareSkillSummary),
complete: collected.cacheable,
}
}
/**
@@ -247,7 +353,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,21 +361,32 @@ 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)
let attempt = 1
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)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
if (attempt < MAX_COLLECT_ATTEMPTS) {
attempt += 1
continue
}
return { entries: result.entries, cacheable: false }
}
if (result.cacheable) {
this.collectCache.set(key, result.entries)
if (this.collectCache.size > this.collectCacheMaxEntries) {
@@ -277,7 +394,7 @@ export class SkillService extends Service {
this.collectCache.delete(oldest.value)
}
}
return result.entries
return result
}
}
@@ -323,11 +440,9 @@ export class SkillService extends Service {
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (output === undefined) continue
if (!Array.isArray(output)) {
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
}
const listed = output as readonly SkillCandidate[]
for (const candidate of listed) {
const observation = normalizeProviderObservation(output, provider.name)
if (!observation.complete) cacheable = false
for (const candidate of observation.candidates) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
@@ -339,7 +454,45 @@ export class SkillService extends Service {
private invalidateCache(): void {
this.providerRevision += 1
this.collectCache.clear()
this.notifyChange()
}
private invalidateProvider(provider: SkillProvider): void {
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
}
/** 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)}`)
}
}
}
}
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
if (Array.isArray(output)) {
return { candidates: output as readonly SkillCandidate[], complete: true }
}
if (output === null || typeof output !== 'object') {
throw invalidProviderObservation(providerName)
}
const observation = output as Partial<SkillProviderObservation>
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
throw invalidProviderObservation(providerName)
}
return observation as SkillProviderObservation
}
function invalidProviderObservation(providerName: string): TypeError {
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
}
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
@@ -349,19 +502,18 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillRegistration
return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER })
return Promise.resolve(candidate.locator as SkillDefinition)
},
}
function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
return {
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
invocation: skill.invocation,
source: skill.source,
provider: skill.provider ?? RUNTIME_PROVIDER,
provider: skill.provider,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
rank: RUNTIME_RANK,
locator: skill,
@@ -383,9 +535,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
}
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`)
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
}
@@ -409,6 +559,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
function validateRuntimeSkill(skill: SkillRegistration): void {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`)
}
/** Validate a definition loaded from a provider-controlled parser or remote source. */
@@ -416,7 +567,7 @@ function validateDefinition(skill: SkillDefinition): void {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const invocation = skill.invocation
const source = skill.source
const provider = skill.provider
const content = skill.content
@@ -425,9 +576,7 @@ function validateDefinition(skill: SkillDefinition): void {
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
}
validateInvocation(invocation, `loaded skill "${name}"`)
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
@@ -436,18 +585,32 @@ function validateDefinition(skill: SkillDefinition): void {
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
invocation,
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
}
}
function validateInvocation(invocation: unknown, subject: string): void {
if (invocation === undefined) return
if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) {
throw new TypeError(`${subject} with a non-object invocation policy`)
}
const policy = invocation as Record<string, unknown>
if (typeof policy.modelInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`)
}
if (typeof policy.userInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)
}
}
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
return compareCodePoints(left.name, right.name)
}

View File

@@ -1,11 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SkillService, {
isModelInvocable,
isUserInvocable,
type SkillCandidate,
type SkillDefinition,
type SkillInvocationPolicy,
type SkillLookupOptions,
type SkillProvider,
type SkillProviderObservation,
} from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
name,
description,
invocation: { modelInvocable: true, userInvocable: true },
provider: 'memory',
source: 'memory',
rank,
@@ -34,6 +44,10 @@ class MemoryProvider implements SkillProvider {
}
}
function registerProvider(ctx: Context, provider: SkillProvider): () => void {
return ctx.skills.registerProvider(() => provider)
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
@@ -49,6 +63,7 @@ describe('SkillService registry', () => {
return [{
name: 'shadowed',
description: 'Higher priority',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'override',
source: 'override',
rank: 5,
@@ -59,8 +74,8 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
const disposeMemory = ctx.skills.registerProvider(provider)
ctx.skills.registerProvider(overrideProvider)
const disposeMemory = registerProvider(ctx, provider)
registerProvider(ctx, overrideProvider)
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
['a-skill', 'A skill', 'memory'],
@@ -74,6 +89,7 @@ describe('SkillService registry', () => {
return [{
name: 'same-rank-skill',
description: 'Same rank',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'same-rank',
source: 'same-rank',
rank: 10,
@@ -84,40 +100,96 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
ctx.skills.registerProvider(sameRankProvider)
registerProvider(ctx, sameRankProvider)
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
await expect(ctx.plugin({
name: 'duplicate-memory',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(new MemoryProvider([]))
registerProvider(pluginCtx, new MemoryProvider([]))
},
})).rejects.toThrow('already registered')
expect(() => ctx.skills.registerProvider({
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
let rejectedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
rejectedSignal = control.signal
return {
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
}
})).toThrow('reserved')
expect(rejectedSignal?.aborted).toBe(true)
const factoryFailure = new Error('factory failed')
let failedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
failedSignal = control.signal
throw factoryFailure
})).toThrow(factoryFailure)
expect(failedSignal?.reason).toBe(factoryFailure)
const effectContext = new Context()
const effectService = new SkillService(effectContext)
const effectFailure = new Error('effect registration failed')
vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure })
let effectSignal: AbortSignal | undefined
expect(() => effectService.registerProvider((control) => {
effectSignal = control.signal
return {
name: 'effect-provider',
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
}
})).toThrow(effectFailure)
expect(effectSignal?.reason).toBe(effectFailure)
disposeMemory()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('returns an invocation-neutral catalog and resolves model and user policy independently', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const registrations = [
{ name: 'both', invocation: undefined },
{ name: 'model-only', invocation: { modelInvocable: true, userInvocable: false } },
{ name: 'user-only', invocation: { modelInvocable: false, userInvocable: true } },
{ name: 'trusted-only', invocation: { modelInvocable: false, userInvocable: false } },
] as const
for (const registration of registrations) {
ctx.skills.register({
name: registration.name,
description: registration.name,
source: 'runtime',
...registration.invocation === undefined ? {} : { invocation: registration.invocation },
content: `${registration.name} body.`,
})
}
const listed = await ctx.skills.list()
expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only'])
expect(listed.find(skill => skill.name === 'both')?.invocation).toEqual({ modelInvocable: true, userInvocable: true })
expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only'])
expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only'])
expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' })
expect((await ctx.skills.get('both'))?.invocation).toEqual({ modelInvocable: true, userInvocable: true })
})
it('validates parsed candidate fields', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad-candidate',
list: () => Promise.resolve([{
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
disableModelInvocation: 'false' as unknown as boolean,
invocation: { modelInvocable: false, userInvocable: true },
}]),
get: () => Promise.resolve(undefined),
})
@@ -125,27 +197,30 @@ describe('SkillService registry', () => {
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
badBoolean.skills.registerProvider({
registerProvider(badBoolean, {
name: 'bad-boolean',
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
disableModelInvocation: 'false' as unknown as boolean,
invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true },
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
badList.skills.registerProvider({
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
it('rejects malformed provider results and every malformed candidate scalar', async () => {
const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }]
for (const [index, output] of malformedOutputs.entries()) {
const badList = new Context()
await badList.plugin(SkillService)
registerProvider(badList, {
name: `malformed-list-${index}`,
list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation')
}
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
@@ -163,7 +238,7 @@ describe('SkillService registry', () => {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
disableModelInvocation: false,
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,
@@ -171,7 +246,7 @@ describe('SkillService registry', () => {
path: '/skills/candidate/SKILL.md',
...patch,
} as SkillCandidate
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([candidate]),
get: () => Promise.resolve(undefined),
@@ -190,12 +265,13 @@ describe('SkillService registry', () => {
const candidate: SkillCandidate = {
name: 'skill-a',
description: 'Skill A',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'contextual',
source: 'test',
rank: 1,
locator: 'skill-a',
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'contextual',
async list(received) {
listedWith = received
@@ -218,12 +294,13 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'cached',
async list() {
return [{
name: 'cached-skill',
description: 'Cached skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'cached',
source: 'test',
rank: 1,
@@ -261,18 +338,20 @@ describe('SkillService registry', () => {
resolve({
name: 'held-skill',
description: 'Held skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'held',
source: 'test',
content: 'Held body.',
})
}
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'held',
async list() {
return [{
name: 'held-skill',
description: 'Held skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'held',
source: 'test',
rank: 1,
@@ -320,11 +399,12 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const invocation = { modelInvocable: true, userInvocable: true }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
@@ -337,7 +417,7 @@ describe('SkillService registry', () => {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'definition resources' },
@@ -347,7 +427,7 @@ describe('SkillService registry', () => {
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'detached',
async list() {
listCalls += 1
@@ -366,6 +446,7 @@ describe('SkillService registry', () => {
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
@@ -379,11 +460,12 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
const invocation = { modelInvocable: true, userInvocable: true }
const registration = {
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
disableModelInvocation: false,
invocation,
source: 'runtime',
resourceBase,
metadata,
@@ -399,6 +481,7 @@ describe('SkillService registry', () => {
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toBe(resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(loaded?.resourceBase).toBe(resourceBase)
expect(loaded?.metadata).toBe(metadata)
expect(loaded?.provider).toBe('runtime')
@@ -410,7 +493,23 @@ describe('SkillService registry', () => {
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
{ patch: { invocation: null as never }, expected: 'non-object invocation policy' },
{
patch: { invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true } },
expected: 'invocation.modelInvocable',
},
{
patch: { invocation: { modelInvocable: true, userInvocable: 'true' as unknown as boolean } },
expected: 'invocation.userInvocable',
},
{
patch: { invocation: { userInvocable: true } as unknown as SkillInvocationPolicy },
expected: 'invocation.modelInvocable',
},
{
patch: { invocation: { modelInvocable: true } as unknown as SkillInvocationPolicy },
expected: 'invocation.userInvocable',
},
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
@@ -422,11 +521,12 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const providerName = `definition-provider-${index}`
const skillName = `definition-${index}`
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([{
name: skillName,
description: 'Candidate',
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,
@@ -436,7 +536,7 @@ describe('SkillService registry', () => {
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
disableModelInvocation: false,
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
content: 'Definition body.',
@@ -455,7 +555,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad',
async list() {
return [memorySkill('Bad_Name', 'bad', 1)]
@@ -474,7 +574,7 @@ describe('SkillService registry', () => {
for (const candidate of invalidCandidates) {
const invalid = new Context()
await invalid.plugin(SkillService)
invalid.skills.registerProvider({
registerProvider(invalid, {
name: candidate.name,
async list() {
return [candidate]
@@ -492,7 +592,7 @@ describe('SkillService registry', () => {
it('sorts model-visible summaries without locale-sensitive collation', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider(new MemoryProvider([
registerProvider(ctx, new MemoryProvider([
memorySkill('z-skill', 'Z skill', 10),
memorySkill('a-skill', 'A skill', 10),
]))
@@ -517,7 +617,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
ctx.skills.registerProvider(provider)
registerProvider(ctx, provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
provider.replace([memorySkill('second-skill', 'Second', 10)])
@@ -544,7 +644,7 @@ describe('SkillService registry', () => {
let fail = true
let flakyCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'flaky',
async list() {
flakyCalls += 1
@@ -555,7 +655,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 +668,231 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3)
})
it('keeps candidates from incomplete provider observations loadable without caching them', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
registerProvider(ctx, {
name: 'incomplete-candidates',
async list() {
listCalls += 1
return {
candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }],
complete: false,
}
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'available-skill' }],
complete: false,
})
expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill'])
expect(listCalls).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)])
let invalidate = (): void => {}
let signal: AbortSignal | undefined
const dispose = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
signal = control.signal
return provider
})
expect((await ctx.skills.snapshot()).complete).toBe(true)
provider.replace([memorySkill('second-skill', 'Second', 10)])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
invalidate()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
dispose()
expect(signal?.aborted).toBe(true)
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
registerProvider(ctx, replacement)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
invalidate()
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 })
let invalidate = (): void => {}
const disposeProvider = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
expect(changes).toBe(1)
invalidate()
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)
invalidate()
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') })
// oxlint-disable-next-line typescript/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(() => registerProvider(ctx, 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)
}
let invalidate = (): void => {}
ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
const pending = ctx.skills.list()
await started.promise
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
invalidate()
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
expect(provider.listCalls).toBe(2)
})
it('bounds repeated in-flight invalidation and leaves the result uncached', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
ctx.skills.registerProvider(control => ({
name: 'self-invalidating',
async list() {
listCalls += 1
control.invalidate()
return [{
...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10),
provider: 'self-invalidating',
}]
},
async get() {
return undefined
},
}))
expect(await ctx.skills.snapshot()).toEqual({
skills: [{
name: 'bounded-skill',
description: 'Attempt 2',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'self-invalidating',
source: 'memory',
}],
complete: false,
})
expect(listCalls).toBe(2)
expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4')
expect(listCalls).toBe(4)
})
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',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'renamed',
source: 'test',
rank: 1,
locator: 'old-name',
}]
},
async get(candidate) {
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
},
}
registerProvider(ctx, 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)
registerProvider(ctx, {
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)
@@ -576,7 +903,7 @@ describe('SkillService registry', () => {
throw new Error('provider failure coercion failed')
},
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
@@ -601,7 +928,7 @@ describe('SkillService registry', () => {
let release: (() => void) | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const dispose = ctx.skills.registerProvider({
const dispose = registerProvider(ctx, {
name: 'delayed',
async list() {
markStarted?.()
@@ -631,7 +958,7 @@ describe('SkillService registry', () => {
const held = new Promise<SkillCandidate[]>((resolve) => {
release = () => { resolve([]) }
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'uncooperative',
list(options) {
seenSignal = options.signal
@@ -668,6 +995,13 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
expect(() => ctx.skills.register({
name: 'bad-invocation',
description: 'Bad invocation',
source: 'runtime',
invocation: [] as never,
content: 'bad',
})).toThrow('non-object invocation policy')
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: 6b0af04a15bfda985be3e04868f18b9af702eae7
README.zh.md: b2f073491a7c6d3c1cb0d6020d619ae725fed305
README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce
README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3

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 catalog
## Catalog lifecycle
The plugin injects one durable user-role `<system-reminder>` catalog at the first `agent/step` of a live session. It resolves skills for the calling session's cwd, forwards the step 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.
At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `<system-reminder>` before the request. Catalog messages contain 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 catalog is a sourced `user/message` injected before the first request and retained in ordinary session history.
The digest covers the exact rendered text between the `<available_skills>` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. 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 [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
## Tool: `skill`
@@ -22,9 +26,9 @@ Execution uses the calling agent's `session.header.cwd` so workspace-sensitive p
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.
An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results.
An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
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 one durable user-role message.
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. 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
Append-only after the existing reusable prefix. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may affect cache reuse from the newly appended catalog position.
The initial durable catalog is appended after the existing reusable prefix. Dynamic changes are append-only history after that catalog, so earlier reusable tokens stay intact while each newly appended catalog and later turns form a new suffix. A new or resumed instance with a changed digest may affect cache reuse from the newly appended catalog position.
### 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']`)。
## 会话目录
## 目录生命周期
该插件在活动会话的第一个 `agent/step` 注入一条持久的用户角色 `<system-reminder>` 目录。它为调用会话的 cwd 解析 skill,将步骤中止信号转发到发现流程,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。这项对工具定义的精确匹配检查使提示词指引、模型可见 schema 和可执行分派保持对齐。
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。目录是一条带来源的 `user/message`,在第一个请求前注入,并保留在普通会话历史中。
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
## 工具:`skill`
@@ -22,9 +26,9 @@
资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和资源文件按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
该工具在 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 Cache 影响
仅追加,位于现有可重用前缀之后。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从新追加的目录位置起影响缓存重用。
初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能会从新追加的目录位置起影响缓存重用。
### 工具 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,18 +4,27 @@
* @module @deepseek-ai/dsh-tool-skill
*/
import { createHash } from 'node:crypto'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import {
isModelInvocable,
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_ENTRIES_START = '<available_skills>\n'
const CATALOG_ENTRIES_END = '</available_skills>'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
/** Model-facing skill catalog configuration. */
export interface Config {
@@ -88,11 +97,19 @@ export function apply(ctx: Context, config: Config = {}): void {
if (!isSkillName(args.name)) {
throw new Error(`invalid skill name "${args.name}"`)
}
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal }
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
if (!summary) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (!isModelInvocable(summary)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
const skill = await ctx.skills.get(args.name, lookup)
if (!skill) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (skill.disableModelInvocation === true) {
if (!isModelInvocable(skill)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
return {
@@ -117,19 +134,22 @@ 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.
const catalogLoaded = new WeakSet<object>()
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (catalogLoaded.has(agent.session)) return
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) {
catalogLoaded.add(agent.session)
return
}
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
if (skills.length > 0) {
const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
agent.inject(catalog)
}
catalogLoaded.add(agent.session)
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 skills = snapshot.skills.filter(isModelInvocable)
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
const history = catalogHistory(agent)
if (history.visibleDigest === digest) return
if (!history.published && skills.length === 0) return
const catalog = history.published
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
agent.inject(catalog)
})
}
@@ -180,7 +200,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
}
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
const entries = renderCatalogEntries(skills, descriptionMaxLength)
return createUserMessage({
content: [{
type: 'text',
@@ -196,10 +216,84 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
'</system-reminder>',
].join('\n'),
}],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
source: PLUGIN_SOURCE,
})
}
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
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 createUserMessage({
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'),
}],
source: PLUGIN_SOURCE,
})
}
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
}
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
}
function digestCatalogEntries(entries: string): string {
return createHash('sha256')
.update(entries)
.digest('hex')
}
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
const visible = new Set(agent.session.surface.nodes)
const events = agent.session.events
let published = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// The loop bounds prove the read-only event view contains this index.
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
const digest = catalogContentDigest(event.data.content)
if (digest === undefined) continue
published = true
if (visible.has(event.seq)) return { visibleDigest: digest, published }
}
return { published }
}
function catalogContentDigest(content: UserMessage['content']): string | undefined {
if (content.length !== 1 || content[0]?.type !== 'text') return undefined
const text = content[0].text
const start = text.indexOf(CATALOG_ENTRIES_START)
if (start === -1) return undefined
const entriesStart = start + CATALOG_ENTRIES_START.length
const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
if (end === -1) return undefined
const renderedEntries = text.slice(entriesStart, end)
const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
return digestCatalogEntries(entries)
}
function catalogDescription(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= maxLength

View File

@@ -4,12 +4,11 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm'
import {} from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
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, 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'
@@ -30,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
}
@@ -57,6 +57,50 @@ function agentForCwd(cwd: string): Agent {
}
}
function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
return {
id: SessionId(id),
options: {},
session,
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
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', createUserMessage({
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal)
}
function catalogMessages(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 === 'dsh-tool-skill')
}
function catalogContent(entries: string[]): Message['content'] {
return [{
type: 'text',
text: ['<system-reminder>', '<available_skills>', ...entries, '</available_skills>', '</system-reminder>'].join('\n'),
}]
}
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
}
@@ -66,8 +110,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab
return agent.session.deriveMessages()
}
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'],
@@ -80,9 +124,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)
@@ -106,7 +151,7 @@ describe('dsh-tool-skill', () => {
const home = await tempDir('tool-prefix-signal')
const ctx = await setup(home)
let seenSignal: AbortSignal | undefined
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'signal-probe',
async list(options) {
seenSignal = options.signal
@@ -115,7 +160,7 @@ describe('dsh-tool-skill', () => {
async get() {
return undefined
},
})
}))
const controller = new AbortController()
await composePrefix(ctx, '/workspace', controller.signal)
@@ -142,6 +187,20 @@ describe('dsh-tool-skill', () => {
provider: 'runtime',
content: 'A body.',
})
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill.',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only body.',
})
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill.',
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only body.',
})
ctx.on('agent/step', (agent) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } }))
})
@@ -161,6 +220,7 @@ describe('dsh-tool-skill', () => {
'',
'<available_skills>',
'- `a-skill`: Use {{placeholder}} &lt;safely&gt; &amp; carefully.',
'- `model-only-skill`: Model-only skill.',
'- `z-skill`: Long description Long description Long descript...',
'</available_skills>',
'',
@@ -181,25 +241,253 @@ describe('dsh-tool-skill', () => {
expect(rendered).not.toContain('secret-source')
expect(rendered).not.toContain('/secret/path')
expect(rendered).not.toContain('Secret body')
expect(rendered).not.toContain('user-only-skill')
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
})
it('does not inject a catalog when no skills are available', async () => {
it('does not inject a catalog when no model-invocable skills are available', async () => {
const home = await tempDir('tool-empty-catalog')
const ctx = await setup(home)
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only body.',
})
expect(await composePrefix(ctx, '/workspace')).toEqual([])
const agent = agentForCwd('/workspace')
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
expect(await composePrefixForAgent(ctx, agent)).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
},
}
let invalidate = (): void => {}
ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
const session = new Session(SessionId('incomplete-prefix'))
const agent = sessionAgent(session)
openMessageTurn(session)
await composePrefixForAgent(ctx, agent)
expect(catalogMessages(session)).toEqual([])
failing = false
invalidate()
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toEqual([])
})
it('records an empty baseline across repeated step observations', async () => {
const home = await tempDir('tool-empty-step')
const ctx = await setup(home)
const session = new Session(SessionId('empty-step'))
const agent = sessionAgent(session)
openMessageTurn(session)
await fireStep(ctx, agent, 1, 1)
await fireStep(ctx, agent, 1, 2)
expect(catalogMessages(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 fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(1)
const disposeSecond = ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
await fireStep(ctx, agent, 1, 2)
const addition = catalogMessages(session)[1]
if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
expect(JSON.stringify(addition.data.content)).toContain('first-skill')
expect(JSON.stringify(addition.data.content)).toContain('second-skill')
disposeSecond()
disposeFirst()
await fireStep(ctx, agent, 1, 3)
const removal = catalogMessages(session)[2]
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')
await fireStep(ctx, agent, 1, 4)
expect(catalogMessages(session)).toHaveLength(3)
})
it('resumes from the latest valid visible catalog content', 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', createUserMessage({
content: catalogContent(['- `old-skill`: Old skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'missing catalog markers' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(6)
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
})
it('re-establishes the current catalog after compaction hides its durable message', 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')
const initial = catalogMessages(session)[0]
if (initial === undefined) throw new Error('expected initial catalog')
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq },
sourceEventSeqs: [initial.seq],
})
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(2)
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-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 fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(1)
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 fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(1)
})
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 composePrefixForAgent(ctx, agent)
expect(catalogMessages(session)).toEqual([])
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toEqual([])
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
await scope.dispose()
})
@@ -229,8 +517,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')
})
@@ -339,18 +628,94 @@ describe('dsh-tool-skill', () => {
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
const home = await tempDir('tool-errors')
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n')
const ctx = await setup(home)
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only instructions.',
})
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
expect(unknown.isError).toBe(true)
expect(invalid.isError).toBe(true)
expect(disabled.isError).toBe(true)
expect(modelOnly.isError).toBe(false)
const unknownBlock = unknown.content[0]
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
})
it('checks model policy before provider loading and rechecks the loaded definition', async () => {
const home = await tempDir('tool-policy-before-load')
const ctx = await setup(home)
const getCalls: string[] = []
ctx.skills.registerProvider(() => ({
name: 'policy-probe',
async list() {
return [
{
name: 'denied-skill',
description: 'Denied skill',
invocation: { modelInvocable: false, userInvocable: true },
provider: 'policy-probe',
source: 'test',
rank: 1,
locator: 'denied-skill',
},
{
name: 'policy-race-skill',
description: 'Policy race skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'policy-probe',
source: 'test',
rank: 1,
locator: 'policy-race-skill',
},
{
name: 'vanishing-skill',
description: 'Vanishing skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'policy-probe',
source: 'test',
rank: 1,
locator: 'vanishing-skill',
},
]
},
async get(candidate) {
getCalls.push(candidate.name)
if (candidate.name === 'vanishing-skill') return undefined
return {
...candidate,
invocation: { modelInvocable: false, userInvocable: true },
content: 'Instructions must not be disclosed.',
}
},
}))
const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } })
const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } })
const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } })
expect(denied.isError).toBe(true)
expect(raced.isError).toBe(true)
expect(vanished.isError).toBe(true)
expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill'])
for (const result of [denied, raced]) {
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected text tool result')
expect(block.text).toContain('is not available for model invocation')
expect(block.text).not.toContain('Instructions must not be disclosed.')
}
const vanishedBlock = vanished.content[0]
if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result')
expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d
README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b
README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4
README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82

View File

@@ -26,7 +26,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
@@ -139,7 +139,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
#### What the model sees
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
#### Token effect

View File

@@ -26,7 +26,7 @@ Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
@@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。
#### Token 影响

View File

@@ -1043,11 +1043,12 @@ export function createTuiChat(
requestRender()
}
// Skill listing is async while `createTuiChat` is synchronous, so the
// completions rebuild once the catalog resolves. Disabled-for-model skills
// are absent from `list()`, so they never appear as completions; a user can
// still invoke one by typing its exact name.
// Skill listing is async while `createTuiChat` is synchronous, so the TUI
// retains the last complete invocation-neutral catalog for synchronous
// editor completion, filters it for user invocation, and refreshes it after
// registry invalidation.
let skillCommands: SlashCommand[] = []
let skillCommandScan = 0
const refreshCommandAutocomplete = (): void => {
const base = new CombinedAutocompleteProvider(
[
@@ -1068,24 +1069,37 @@ export function createTuiChat(
agent,
))
}
const refreshVisibleSlashAutocomplete = (): void => {
const cursor = editor.getCursor()
const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col)
if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) {
// pi-tui's provider setter closes an existing menu but does not query
// the replacement for the current draft. Tab in a slash-name context
// only requests suggestions, so it refreshes without editing the text.
editor.handleInput('\t')
}
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
const loadSkillCommands = (service: SkillService): void => {
service.list({ cwd, signal: skillAbort.signal }).then(
(summaries) => {
if (disposed || summaries.length === 0) return
const refreshSkillCommands = (service: SkillService): void => {
const scan = ++skillCommandScan
service.snapshot({ cwd, signal: skillAbort.signal }).then(
(snapshot) => {
if (disposed || scan !== skillCommandScan || !snapshot.complete) return
const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable)
// The argument-hint slot shows in the menu but is never inserted on
// selection, so it carries the skill's scope instead of an
// instructions placeholder. `SkillSource` is open-ended; every
// non-project source (user, custom, bundled, runtime, …) collapses
// to `(user)`.
skillCommands = summaries.map(skill => ({
skillCommands = invocable.map(skill => ({
name: `skill:${skill.name}`,
description: skill.description,
argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)',
}))
refreshCommandAutocomplete()
refreshVisibleSlashAutocomplete()
requestRender()
},
() => {
@@ -1094,7 +1108,10 @@ export function createTuiChat(
},
)
}
if (skills !== undefined) loadSkillCommands(skills)
const disposeSkillChanges = skills === undefined
? () => {}
: ctx.on('skills/change', () => { refreshSkillCommands(skills) })
if (skills !== undefined) refreshSkillCommands(skills)
// The agent scope is minted by agent-loop and intentionally inherits only
// that core plugin's dependencies. A child command producer declares its own
@@ -1261,19 +1278,40 @@ export function createTuiChat(
appendNotice('Skills are not available in this session.', 'warning')
return
}
skills.get(name, { cwd, signal: skillAbort.signal }).then(
(skill) => {
const lookup = { cwd, signal: skillAbort.signal }
const reportFailure = (error: unknown): void => {
if (disposed) return
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
}
skills.list(lookup).then(
(summaries) => {
if (disposed) return
if (skill === undefined) {
const summary = summaries.find(skill => skill.name === name)
if (summary === undefined) {
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
deliver(renderSkillInvocation(skill, instructions))
},
(error: unknown) => {
if (disposed) return
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
if (!summary.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}
skills.get(name, lookup).then(
(skill) => {
if (disposed) return
if (skill === undefined) {
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
if (!skill.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}
deliver(renderSkillInvocation(skill, instructions))
},
reportFailure,
)
},
reportFailure,
)
}
@@ -1488,6 +1526,7 @@ export function createTuiChat(
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
disposeSkillChanges()
disposePromptChanges()
for (const value of promptValues) value.dispose()
stopBannerReveal()

View File

@@ -18,7 +18,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -3828,10 +3828,30 @@ describe('skill slash command', () => {
if (skills === undefined) throw new Error('skills service not mounted')
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only instructions body.',
})
skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only instructions body.',
})
skills.register({
name: 'trusted-only-skill',
description: 'Trusted-only skill',
invocation: { modelInvocable: false, userInvocable: false },
source: 'runtime',
content: 'Trusted-only instructions body.',
})
}
it('labels slash completions by scope and hides model-disabled skills', async () => {
it('labels slash completions by scope and applies user invocation policy', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill')
await tick()
@@ -3839,8 +3859,139 @@ describe('skill slash command', () => {
expect(result.terminal.output).toContain('(user)')
expect(result.terminal.output).toContain('project-skill')
expect(result.terminal.output).toContain('(project)')
expect(result.terminal.output).toContain('user-only-skill')
expect(result.terminal.output).not.toContain('[instructions]')
expect(result.terminal.output).not.toContain('hidden-skill')
expect(result.terminal.output).not.toContain('model-only-skill')
expect(result.terminal.output).not.toContain('trusted-only-skill')
await dispose(result)
})
it('refreshes slash completions after runtime skill additions and complete removals', async () => {
let skills: SkillService | undefined
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
await ctx.plugin(SkillService)
skills = ctx.get('skills')
},
})
if (skills === undefined) throw new Error('skills service not mounted')
result.terminal.send('/skill:dynamic')
await tick()
result.terminal.output = ''
const disposeSkill = skills.register({
name: 'dynamic-skill',
description: 'DYNAMIC_COMPLETION_MARKER',
source: 'runtime',
content: 'Dynamic body.',
})
await tick()
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
result.terminal.send('\x03')
disposeSkill()
await tick()
result.terminal.output = ''
result.terminal.send('/skill:dynamic')
await tick()
expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER')
await dispose(result)
})
it('retains last-good slash completions across incomplete snapshots', async () => {
let skills: SkillService | undefined
let provider: SkillProvider | undefined
let invalidate = (): void => {}
let fail = false
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
await ctx.plugin(SkillService)
skills = ctx.get('skills')
provider = {
name: 'flaky-completion',
async list() {
if (fail) throw new Error('transient completion failure')
return [{
name: 'stable-skill',
description: 'STABLE_COMPLETION_MARKER',
invocation: { modelInvocable: true, userInvocable: true },
source: 'test',
provider: 'flaky-completion',
rank: 1,
locator: 'stable',
}]
},
async get() {
return undefined
},
}
skills?.registerProvider((control) => {
invalidate = control.invalidate
return provider as SkillProvider
})
},
})
if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted')
fail = true
invalidate()
await tick()
result.terminal.output = ''
result.terminal.send('/skill:stable')
await tick()
expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER')
await dispose(result)
})
it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => {
const pendingSnapshots: Array<PromiseWithResolvers<SkillCatalogSnapshot>> = []
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => {
const pending = Promise.withResolvers<SkillCatalogSnapshot>()
pendingSnapshots.push(pending)
return pending.promise
},
get: () => Promise.resolve(undefined),
} as never)
},
})
expect(pendingSnapshots).toHaveLength(1)
result.ctx.emit('skills/change')
result.ctx.emit('skills/change')
expect(pendingSnapshots).toHaveLength(3)
pendingSnapshots[2]?.resolve({
skills: [{
name: 'latest-skill',
description: 'LATEST_COMPLETION_MARKER',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}],
complete: true,
})
await tick()
pendingSnapshots[0]?.resolve({
skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
complete: true,
})
pendingSnapshots[1]?.resolve({
skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
complete: true,
})
await tick()
result.terminal.output = ''
result.terminal.send('/skill:latest')
await tick()
expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER')
expect(result.terminal.output).not.toContain('STALE_FIRST')
expect(result.terminal.output).not.toContain('STALE_SECOND')
await dispose(result)
})
@@ -3859,12 +4010,62 @@ describe('skill slash command', () => {
await dispose(result)
})
it('invokes a model-disabled skill by its exact name', async () => {
it('invokes a user-only skill by its exact name', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:hidden-skill')
result.terminal.send('/skill:user-only-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="user-only-skill">\nUser-only instructions body.\n</skill>' }]])
await dispose(result)
})
it('checks user policy before loading and rechecks the loaded definition', async () => {
const summaries: SkillSummary[] = [
{
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
provider: 'runtime',
},
{
name: 'policy-race-skill',
description: 'Policy race skill',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
},
]
const get = vi.fn((name: string) => Promise.resolve<SkillDefinition | undefined>({
name,
description: 'Policy race skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
provider: 'runtime',
content: 'Instructions must not be delivered.',
}))
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => Promise.resolve({ skills: summaries, complete: true }),
list: () => Promise.resolve(summaries),
get,
} as never)
},
})
result.terminal.send('/skill:model-only-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:policy-race-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([])
expect(get).toHaveBeenCalledTimes(1)
expect(get).toHaveBeenCalledWith('policy-race-skill', expect.objectContaining({ cwd: '/workspace' }))
expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.')
expect(result.terminal.output).toContain('Skill "policy-race-skill" is not available for user invocation.')
expect(result.terminal.output).not.toContain('Instructions must not be delivered.')
await dispose(result)
})
@@ -3919,6 +4120,7 @@ describe('skill slash command', () => {
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => Promise.reject(new Error('list boom')),
list: () => Promise.reject(new Error('list boom')),
get: () => Promise.reject(new Error('get boom')),
} as never)
@@ -3928,37 +4130,84 @@ describe('skill slash command', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('failed to load')
expect(result.terminal.output).toContain('get boom')
expect(result.terminal.output).toContain('list boom')
await dispose(result)
})
it('drops skill list and lookup results that settle after disposal', async () => {
const pendingList: Array<(value: SkillSummary[]) => void> = []
let listCalls = 0
let resolvePendingList: ((value: SkillSummary[]) => void) | undefined
const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = []
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
snapshot: () => new Promise<SkillCatalogSnapshot>((resolve) => { pendingSnapshots.push(resolve) }),
list: () => {
listCalls += 1
if (listCalls === 1 || listCalls === 2) {
const name = listCalls === 1 ? 'demo-skill' : 'error-skill'
return Promise.resolve<SkillSummary[]>([{
name,
description: 'demo',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
}
return new Promise<SkillSummary[]>((resolve) => { resolvePendingList = resolve })
},
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
} as never)
},
})
await tick()
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:error-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:other-skill')
result.terminal.send('\r')
await tick()
await dispose(result)
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
result.ctx.emit('skills/change')
expect(pendingSnapshots).toHaveLength(1)
for (const resolve of pendingSnapshots) {
resolve({
skills: [{
name: 'late',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}],
complete: true,
})
}
resolvePendingList?.([{
name: 'other-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
pendingGet[0]?.resolve({
name: 'demo-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'late body',
})
pendingGet[1]?.reject(new Error('late failure'))
await tick()
expect(result.agent.sent).toEqual([])
expect(result.terminal.output).not.toContain('late failure')
expect(result.terminal.output).not.toContain('late body')
expect(result.terminal.output).not.toContain('late failure')
})
})
@@ -3966,6 +4215,7 @@ describe('renderSkillInvocation', () => {
const skill: SkillDefinition = {
name: 'demo-skill',
description: 'Demo skill',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'Body text.',