Merge remote-tracking branch 'origin/pr-555-head' into worktree/pr555-simplify

# Conflicts:
#	apps/web/tests/image-display.snapshot.ts
This commit is contained in:
Tianyi Cui
2026-07-30 00:06:12 +08:00
53 changed files with 2933 additions and 280 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md
2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c
2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db
@@ -0,0 +1,48 @@
# Agent Note: Skill catalog hot refresh
Status: implemented
English | [中文](2026-07-27-skill-catalog-hot-refresh.zh.md)
## Problem
Skill summaries are model routing input, but local skills can appear, disappear, or be renamed after a session starts. IDEs, Git operations, shell commands, and other processes can all mutate `.agents/skills` without going through the harness filesystem tools. A startup-only catalog leaves the model unaware of new skills and able to call deleted names. Treating every instruction-body edit as a catalog revision would instead couple progressive loading to unnecessary prompt churn.
Filesystem updates are also non-atomic from the observer's perspective. An editor or Git operation may briefly remove a file, a watched root may not exist at startup, and discovery may fail transiently. Publishing those intermediate observations as authoritative empty catalogs would be worse than retaining the last complete view.
## Decision
The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries once; if the retry is also superseded, the latest candidates return as an incomplete, uncached observation. A late invalidation after disposal or replacement is a no-op because the capability has been revoked.
`@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set.
A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks.
`@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `<available_skills>` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact.
The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft.
Instruction bodies keep progressive disclosure. Every `skill(name)` call asks the provider to reread and parse the current file; there is no body cache, hash, revision, or proactive notification. Previously logged tool results remain unchanged. If the loaded frontmatter name no longer matches the selected candidate, the registry rejects the stale name and invalidates that provider so a later catalog observation can publish the new name.
## Verification
Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, bounded generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool.
## Alternatives considered
- **Put the live catalog in World State** — rejected because catalog replacements are model-visible session inputs and must be reconstructable from the event log. Durable injected history already provides replay, resume, fork, and compaction semantics without another mutable state plane.
- **Rely only on `fs/observed`** — rejected because IDEs, Git, shell commands, and external processes do not cross that seam. The event remains a latency fast path for first-party tools, while host watching supplies coverage.
- **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results.
- **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing.
- **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds.
- **Keep `invalidateProvider(provider)` public** — rejected because it exposes a registry mutation method and makes callers resupply an identity the registry already owns. The factory-issued closure binds invalidation to one registration and becomes inert on disposal, so observers need neither registry access nor provider identity.
- **Extract a generic Cordis file-watching service now** — deferred until another consumer establishes the reusable service contract. The local provider marks its Chokidar and missing-root observation boundary for that extraction; skill-path filtering and the call to the provider's invalidation closure remain skill-specific.
## Consequences
- New, deleted, and renamed local skills become visible at model-step boundaries without restarting the agent, including when the skills root did not exist at startup.
- The TUI's `/skill:` completions converge on the same complete catalog without blocking each keystroke on discovery; an open slash-name draft refreshes when the catalog arrives.
- Catalog messages are append-only, logged, whole-list snapshots. They preserve earlier reusable tokens; replacements retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change.
- Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded.
- Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs.
- Remote or future mutable providers retain their own registration-scoped invalidation closure and call it from their observation mechanism; the registry does not impose a universal watcher or TTL.
@@ -0,0 +1,48 @@
# Agent Note: Skill 目录热刷新
Status: implemented
[English](2026-07-27-skill-catalog-hot-refresh.md) | 中文
## 问题
skill(技能)摘要是模型的路由输入,但本地 skill 可在会话启动后新增、消失或重命名。IDE、Git 操作、shell 命令和其他进程都可以修改 `.agents/skills`,而不经过 harness 文件系统工具。仅在启动时构建目录,会让模型无法获知新 skill,并且仍能调用已删除的名称。反之,如果把每次指令正文编辑都视为目录修订,就会让渐进式加载与不必要的提示词频繁变化耦合。
从观察方来看,文件系统更新也不是原子完成的。编辑器或 Git 操作可能会短暂移除文件,受监视的根目录在启动时可能不存在,发现也可能暂时失败。把这些中间观察结果发布为权威空目录,比保留最后一个完整视图更糟。
## 决策
skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会重试一次;如果这次重试也被后续修订取代,则最新候选项会作为不完整且不缓存的观测返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。
`@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name``description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。
系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write``edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。
`@deepseek-ai/dsh-tool-skill``agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `<available_skills>` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。
TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。
指令正文继续采用渐进式披露。每次调用 `skill(name)` 时,系统都会要求提供方重新读取并解析当前文件;不存在正文缓存、哈希、修订或主动通知。先前记录的工具结果保持不变。如果加载后的 frontmatter 名称不再匹配所选候选项,注册表会拒绝这个陈旧名称,并使该提供方失效,以便后续目录观察发布新名称。
## 验证
注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、有界 generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。
## 考虑过的替代方案
- **将实时目录放入 World State**:不予采纳,因为目录替换是模型可见的会话输入,必须能够从事件日志重建。持久注入历史已经提供回放、恢复、fork 和压缩语义,无需再引入一套可变状态层。
- **只依赖 `fs/observed`**:不予采纳,因为 IDE、Git、shell 命令和外部进程都不会经过该 seam。该事件仍作为第一方工具的低延迟快速路径,宿主监视则补齐覆盖。
- **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。
- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。
- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。
- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。
- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。
## 影响
- 新增、删除和重命名的本地 skill 会在模型步骤边界变得可见,无需重启 agent(智能体),即使 skill 根目录在启动时不存在也一样。
- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。
- 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。
- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。
- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。
- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。
+9 -16
View File
@@ -147,27 +147,14 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
}).toMatchInlineSnapshot(`
{
"panelHeader": "To-dos1/3 tasks · 1 in progress",
"panelItems": [
{
"status": "completed",
"text": "梳理需求",
},
{
"status": "in_progress",
"text": "实现 fixture 样本",
},
{
"status": "pending",
"text": "浏览器验收",
},
],
"panelItems": [],
"row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
"rowState": "ok",
}
`)
})
it('collapses the plan strip to the count summary and restores it', async () => {
it('expands the default-collapsed plan strip and restores its folded state', async () => {
boot()
await openFixtureSession()
@@ -176,19 +163,25 @@ it('collapses the plan strip to the count summary and restores it', async () =>
const header = panel.querySelector('button')
if (header === null) throw new Error('todo panel header missing')
fireEvent.click(header)
expect({
collapsedHeader: visibleText(header),
expanded: header.getAttribute('aria-expanded'),
listGone: panel.querySelector('ul') === null,
}).toMatchInlineSnapshot(`
{
"collapsedHeader": "To-dos1/3 tasks · 1 in progress",
"expanded": "false",
"listGone": true,
}
`)
fireEvent.click(header)
expect(panel.querySelectorAll('li')).toHaveLength(3)
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(header)
expect(panel.querySelector('ul')).toBeNull()
expect(header.getAttribute('aria-expanded')).toBe('false')
})
it('hides the plan strip when the next turn starts', async () => {
+16 -4
View File
@@ -1257,7 +1257,7 @@ export interface Config {
}
```
Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts)
Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts)
## `@deepseek-ai/dsh-skill-local`
@@ -1272,12 +1272,24 @@ 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
}
```
Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts)
Source: [`packages/skill/skill-local/src/index.ts:48`](../packages/skill/skill-local/src/index.ts)
## `@deepseek-ai/dsh-spill-local`
@@ -1742,7 +1754,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package
## `@deepseek-ai/dsh-tool-skill`
Requires: `tools` · `skills`
Requires: `agents` · `tools` · `skills`
```ts config-catalog
/** Model-facing skill catalog configuration. */
@@ -1752,7 +1764,7 @@ export interface Config {
}
```
Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts)
Source: [`packages/skill/tool-skill/src/index.ts:25`](../packages/skill/tool-skill/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent`
+19
View File
@@ -642,6 +642,25 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru
Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts)
## `skills/*`
### `skills/change` — emit
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.
```ts cordis-catalog
/**
* 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
```
Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts)
## `slash/*`
### `slash/input-begin-command` — bail
+13 -4
View File
@@ -1672,11 +1672,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins
* 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
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void
/**
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
@@ -1696,6 +1696,15 @@ register(skill: SkillRegistration): () => void
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
/**
* Observe the current model-invocable 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>
/**
* Load and validate the winning candidate, passing its opaque discovery locator back to the
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
@@ -1707,9 +1716,9 @@ async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
```
Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md)
Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillProviderControl](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md)
Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts)
Source: [`packages/skill/skill/src/index.ts:178`](../../packages/skill/skill/src/index.ts)
## `ctx.spillStore` — `SpillStore` (abstract seam)
+2 -2
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 docs/core-data-structures/skills.md
skills.md: 2f47881ba5b694ab5affa43add60f340c4d17dbb
skills.zh.md: b5a212f81cc17975cb203738f2636bd5f5695f4f
skills.md: a52a21d2bfe4c4e4529953fdf09998a9a281cefd
skills.zh.md: 6ebc167f7e5dc436ac1f79b8747940e4beb30222
+48 -7
View File
@@ -2,7 +2,7 @@
English | [中文](skills.zh.md)
The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md).
The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans and watches project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md).
Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts).
@@ -10,7 +10,19 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind
`ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated.
Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast.
Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options.
An array returned by `SkillProvider.list()` is complete-discovery shorthand. `SkillProviderObservation` lets a provider expose candidates that remain directly loadable while reporting that the observation is not authoritative.
```ts type-equiv
/** Provider candidates plus whether the current discovery is authoritative. */
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
}
```
```ts type-equiv
/** Provider interface for one source of skills, such as local directories or a remote registry. */
@@ -23,9 +35,10 @@ 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.
@@ -36,6 +49,16 @@ interface SkillProvider {
}
```
```ts type-equiv
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
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
}
```
## Local discovery priority
The shipped local provider scans roots in rank order:
@@ -51,6 +74,8 @@ The shipped local provider scans roots in rank order:
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider.
Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU.
## Skill identity
Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`<name>/SKILL.md`) and flat Markdown files (`<name>.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1.
@@ -84,6 +109,18 @@ interface SkillSummary {
}
```
`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing a consumer to retain its last-good model catalog and retry.
```ts type-equiv
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
interface SkillCatalogSnapshot {
/** Sorted model-invocable summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed without a concurrent catalog revision. */
readonly complete: boolean
}
```
`SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`.
```ts type-equiv
@@ -133,6 +170,8 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root.
Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery.
```ts type-equiv
/** Caller context used for cwd-sensitive and abortable provider work. */
interface SkillLookupOptions {
@@ -143,7 +182,7 @@ interface SkillLookupOptions {
}
```
The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound.
The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md).
```ts type-equiv
/** Skill registry configuration. */
@@ -155,6 +194,8 @@ interface Config {
## Session catalog and tool contract
`dsh-tool-skill` injects a durable user-role `<system-reminder>` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`.
`dsh-tool-skill` injects the initial durable user-role `<system-reminder>` at the first `agent/step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`.
The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions.
Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `<available_skills>` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State.
The model-facing `skill({ name })` tool validates the kebab-case name, rereads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results.
+48 -7
View File
@@ -2,7 +2,7 @@
[English](skills.md) | 中文
[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill)`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。
[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill)`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。
源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。
@@ -10,7 +10,19 @@
`ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。
重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败
重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并从不完整观测中省略;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`
`SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。
```ts type-equiv
/** Provider candidates plus whether the current discovery is authoritative. */
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
}
```
```ts type-equiv
/** Provider interface for one source of skills, such as local directories or a remote registry. */
@@ -23,9 +35,10 @@ 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.
@@ -36,6 +49,16 @@ interface SkillProvider {
}
```
```ts type-equiv
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
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
}
```
## 本地发现优先级
内置的本地提供方按 rank 顺序扫描各根目录:
@@ -51,6 +74,8 @@ interface SkillProvider {
项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。
Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。
## Skill 身份
skill 名称为 kebab-case`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。
@@ -84,6 +109,18 @@ interface SkillSummary {
}
```
`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集并排序的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此消费方可以保留上一份可用模型目录并重试。
```ts type-equiv
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
interface SkillCatalogSnapshot {
/** Sorted model-invocable summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed without a concurrent catalog revision. */
readonly complete: boolean
}
```
`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。
```ts type-equiv
@@ -133,6 +170,8 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider
skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。
注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。
```ts type-equiv
/** Caller context used for cwd-sensitive and abortable provider work. */
interface SkillLookupOptions {
@@ -143,7 +182,7 @@ interface SkillLookupOptions {
}
```
注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`。消费方拥有其目录描述上限
注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`,以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)
```ts type-equiv
/** Skill registry configuration. */
@@ -155,6 +194,8 @@ interface Config {
## 会话目录与工具契约
`dsh-tool-skill` 在存活会话第一个 `agent/step` 注入一条持久 user-role `<system-reminder>`。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。
`dsh-tool-skill` 在存活会话第一个观察到非空完整视图的 `agent/step` 注入初始的持久 user-role `<system-reminder>`。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。
面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径
在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `<available_skills>` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State
面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 重新读取完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。
+2 -1
View File
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
@@ -35,6 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:157`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:232`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:246`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:239`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+1 -1
View File
@@ -26,7 +26,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
+30 -8
View File
@@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { execa } from 'execa'
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
@@ -36,7 +37,16 @@ while time.monotonic() < deadline:
if chunk:
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action = actions[action_index]
if "writeFile" in action:
target = os.path.join(cwd, action["writeFile"]["path"])
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
handle.write(action["writeFile"]["content"])
if "send" in action:
os.write(fd, action["send"].encode())
else:
os.write(fd, action["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
@@ -56,11 +66,14 @@ if actual_exit != int(expected_exit):
sys.exit(125)
`
/** One terminal action sent after its marker has rendered. */
interface TuiPtyAction {
readonly waitFor: string
readonly send: string
}
/** One terminal input or workspace mutation performed after its marker renders. */
type TuiPtyAction =
| { readonly waitFor: string; readonly send: string }
| {
readonly waitFor: string
readonly writeFile: { readonly path: string; readonly content: string }
readonly send?: string
}
/** Inputs for a keyless real-Loader TUI process smoke. */
export interface TuiPtySmokeOptions {
@@ -157,7 +170,16 @@ async function runWindowsPtySmoke(
terminal.onData((chunk) => {
output += chunk
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
terminal.write(actions[actionIndex]!.send)
const action = actions[actionIndex]!
if ('writeFile' in action) {
const target = join(cwd, action.writeFile.path)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, action.writeFile.content)
const input = action.send
if (input !== undefined) terminal.write(input)
} else {
terminal.write(action.send)
}
actionIndex += 1
}
})
@@ -250,6 +250,36 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
const skill = [
'---',
'name: hot-added-skill',
'description: HOT_ADDED_COMPLETION_MARKER',
'---',
'',
'Hot-added body.',
'',
].join('\n')
const output = await smoke({
label: 'tui-agent hot-added skill autocomplete',
tempDirPrefix: 'tui-agent-hot-skill-',
configPath: scriptedConfigPath,
actions: [
{
waitFor: 'scripted TUI ready.',
writeFile: {
path: '.agents/skills/hot-added-skill/SKILL.md',
content: skill,
},
send: '/skill:hot',
},
{ waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' },
],
})
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fuzzy-completes an @file path without reading or submitting the file', async () => {
const output = await smoke({
label: 'tui-agent file autocomplete',
@@ -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: 7504b380ec368ce5843dec932793ac0e8f4be421
README.zh.md: 2e811d3ee3d82d4e176e9d274d683b48ed10451b
README.md: 74137edccc3ca68c40afd545350ce3811d6ae2e2
README.zh.md: c974640ba08451d0a666061c6ec46ca88d66a85e
+1 -1
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.
+1 -1
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 提供其余状态和回调。
@@ -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 (
@@ -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()
})
+26 -3
View File
@@ -767,8 +767,8 @@ 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',
@@ -778,6 +778,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
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 */',
},
{
signature: 'async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot>',
jsDoc: '/**\n * Observe the current model-invocable 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>',
jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */',
@@ -1284,6 +1288,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',
@@ -2381,6 +2392,10 @@ 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}',
@@ -2391,7 +2406,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
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',
@@ -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:^",
@@ -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-'))
@@ -180,7 +180,7 @@ 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)
@@ -190,7 +190,7 @@ describe('skill.list', () => {
}])
},
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 +219,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 }))
+2 -2
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
+3 -3
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).
+3 -3
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) 并列。
+2 -2
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: d4b6253c6667f4786d53ad6c291f9e96f82546c3
README.zh.md: 0c02080fce49c13c4a128676c39b793f3054fdb8
+23 -6
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,34 @@ Default roots are resolved in this provider's rank order:
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.
## Catalog Change Detection
Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged 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.
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.
+23 -6
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,34 @@
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。
`ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。缺失、不可读或格式错误的 skill 文件会警告并跳过,而不会使整个请求失败
`ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录
## 目录变更检测
现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents``skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
如果第一方文件系统 `write``edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。
## Skill 格式
Skill 可以是单层目录 bundle`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name``description`,而 `whenToUse``disableModelInvocation``metadata` 可选。名称必须使用 kebab-case。
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。
## 模型体验
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
#### KV 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 工作流的可靠创建检测
- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。
+1
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"
},
+559 -21
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'
@@ -24,6 +26,8 @@ import {
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
type SkillProviderControl,
type SkillProviderObservation,
type SkillSource,
} from '@deepseek-ai/dsh-skill'
@@ -32,6 +36,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 +52,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 +72,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 +86,7 @@ interface SkillRoot {
source: SkillSource
rank: number
skipSystem?: boolean
projectRoot?: string
trustedHost?: boolean
}
@@ -84,10 +110,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 +141,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 +162,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 }
}
/**
@@ -146,13 +207,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 +245,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.
// eslint-disable-next-line @typescript-eslint/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. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
if (this.closing || state.owners.size === 0) {
await this.closeWatcher(watcher)
return
}
/* v8 ignore stop */
state.watcher = watcher
state.unhealthy = false
} catch (error) {
// eslint-disable-next-line @typescript-eslint/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)
@@ -204,9 +729,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 +750,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[] = []
@@ -285,31 +815,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
}
@@ -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()
})
})
@@ -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')
@@ -232,7 +253,7 @@ describe('LocalSkillProvider', () => {
const listedBeforeDelete = await ctx.skills.list()
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
if (flatSummary === undefined) throw new Error('expected flat-skill')
await writeFile(join(root, 'flat-skill.md'), '')
await rm(join(root, 'flat-skill.md'))
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
@@ -335,7 +356,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 +380,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 +474,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 +501,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 +732,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 +740,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
+2 -2
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: 65ff110999ea416648f3d676eb813dd4ceb194f8
README.zh.md: 79e77d2a2846489125ba0ecaafc138172ba2fd86
+15 -8
View File
@@ -10,11 +10,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.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 `{ skills, complete }`. `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 model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Events
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after 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
| Field | Default | Meaning |
@@ -23,11 +28,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
## 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
@@ -35,19 +42,19 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou
## 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.
+15 -8
View File
@@ -10,11 +10,16 @@
### 公开 API
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留`ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR(热模块替换);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? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
### 事件
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
### 配置
| 字段 | 默认值 | 含义 |
@@ -23,11 +28,13 @@
## 提供方契约
提供方同步注册,并在可等待的 `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
@@ -35,19 +42,19 @@
## 消费方边界
注册表不渲染模型指引,也不注册面向模型的工具。[`@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。
+155 -38
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
@@ -87,6 +88,22 @@ export interface SkillLookupOptions {
readonly signal?: AbortSignal | undefined
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted model-invocable 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 +114,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 +127,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 +145,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 {
@@ -161,32 +198,50 @@ 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()')
// eslint-disable-next-line @typescript-eslint/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()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
throw error
}
}
/**
@@ -228,11 +283,26 @@ export class SkillService extends Service {
* @returns sorted summaries, excluding skills disabled for model invocation.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSkillSummary)
return (await this.snapshot(options)).skills
}
/**
* Observe the current model-invocable catalog and whether 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)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSkillSummary),
complete: collected.cacheable,
}
}
/**
@@ -247,7 +317,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 +325,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 +358,7 @@ export class SkillService extends Service {
this.collectCache.delete(oldest.value)
}
}
return result.entries
return result
}
}
@@ -323,11 +404,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 +418,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 = {
+299 -39
View File
@@ -1,6 +1,6 @@
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, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
@@ -34,6 +34,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()
@@ -59,8 +63,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'],
@@ -84,24 +88,52 @@ 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'])
@@ -111,7 +143,7 @@ describe('SkillService registry', () => {
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),
@@ -125,7 +157,7 @@ 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),
@@ -137,15 +169,18 @@ describe('SkillService registry', () => {
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
})
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' },
@@ -171,7 +206,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),
@@ -195,7 +230,7 @@ describe('SkillService registry', () => {
rank: 1,
locator: 'skill-a',
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'contextual',
async list(received) {
listedWith = received
@@ -218,7 +253,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'cached',
async list() {
return [{
@@ -267,7 +302,7 @@ describe('SkillService registry', () => {
})
}
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'held',
async list() {
return [{
@@ -347,7 +382,7 @@ describe('SkillService registry', () => {
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'detached',
async list() {
listCalls += 1
@@ -422,7 +457,7 @@ 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,
@@ -455,7 +490,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 +509,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 +527,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 +552,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 +579,7 @@ describe('SkillService registry', () => {
let fail = true
let flakyCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'flaky',
async list() {
flakyCalls += 1
@@ -555,7 +590,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 +603,229 @@ 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') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment
const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected')))
let observed = 0
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
const provider = new MemoryProvider([])
expect(() => 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',
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',
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 +836,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 +861,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 +891,7 @@ describe('SkillService registry', () => {
const held = new Promise<SkillCandidate[]>((resolve) => {
release = () => { resolve([]) }
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'uncooperative',
list(options) {
seenSignal = options.signal
+2 -2
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: 53f1494b8d0a348910ff7fb3965ae1798fc3b3b8
README.zh.md: f30d47fa1f774c449069278d17e8c6c5c3d1a43d
+14 -8
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`
@@ -24,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr
An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results.
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries.
## Model Experience
@@ -32,7 +36,7 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as
#### What the model sees
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is 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.
+14 -8
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 Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
## 工具:`skill`
@@ -24,7 +28,7 @@
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
工具在 v1 中不调用 `agent.inject()`结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。
工具执行不调用 `agent.inject()`新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。
## 模型体验
@@ -32,7 +36,7 @@
#### 模型看到的内容
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
##### Skill 目录模板
@@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca
#### Token 影响
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送初始目录 token。每次实际目录变更都会添加一条保留的完整替换消息。
#### KV Cache 影响
仅追加,位于现有可重用前缀之后。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从新追加的目录位置起影响缓存重用。
初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能从新追加的目录位置起影响缓存重用。
### 工具 schema
@@ -146,3 +150,5 @@ Load referenced resources only as needed.
- **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。
- **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。
- **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分内容、流式输出或缓存内容句柄。
- **目录替换采用全量列表**:一个名称或描述发生变化,就会追加当前所有可见摘要;这样能显式停用陈旧名称,但 token 成本与目录大小成正比。
- **正文不做版本化**:仅修改正文不会改变目录 digest,也不会通知模型;后续工具调用会读取提供方的当前内容,而先前工具结果仍是历史事实。
+1
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:^",
+95 -15
View File
@@ -4,6 +4,7 @@
* @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'
@@ -13,9 +14,12 @@ import type { UserMessage } from '@deepseek-ai/dsh-session'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['tools', 'skills']
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
const CATALOG_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 {
@@ -117,19 +121,21 @@ 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 digest = catalogDigest(snapshot.skills, catalogDescriptionMaxLength)
const history = catalogHistory(agent)
if (history.visibleDigest === digest) return
if (!history.published && snapshot.skills.length === 0) return
const catalog = history.published
? renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength)
: renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength)
agent.inject(catalog)
})
}
@@ -180,7 +186,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 +202,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.
// eslint-disable-next-line @typescript-eslint/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
@@ -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)
@@ -191,15 +236,233 @@ describe('dsh-tool-skill', () => {
expect(await composePrefix(ctx, '/workspace')).toEqual([])
})
it('omits an incomplete initial catalog and retries on a later request boundary', async () => {
const home = await tempDir('tool-incomplete-prefix')
const ctx = await setup(home)
let failing = true
const provider = {
name: 'recovering',
async list() {
if (failing) throw new Error('temporarily unavailable')
return []
},
async get() {
return undefined
},
}
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 +492,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')
})
+2 -2
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: 00771491b2f122b80a6e990f49cf8d2b9d212cc0
README.zh.md: da0f7d25febcff9436e5b32640ed19746115227a
README.md: 0b9c89c7bd0e17eb3afcfabfee4ed53e3a132876
README.zh.md: 12478b0f2a778e912793c3906e343f238ceb71a6
+1 -1
View File
@@ -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; model-disabled skills are omitted from autocomplete but stay loadable by exact name. 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.
#### Token effect
+1 -1
View File
@@ -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 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。
#### Token 影响
+26 -8
View File
@@ -1044,10 +1044,12 @@ export function createTuiChat(
}
// 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
// TUI retains the last complete catalog for synchronous editor completion
// and refreshes it after registry invalidation. Disabled-for-model skills are
// absent from snapshots, so they never appear as completions; a user can
// still invoke one by typing its exact name.
let skillCommands: SlashCommand[] = []
let skillCommandScan = 0
const refreshCommandAutocomplete = (): void => {
const base = new CombinedAutocompleteProvider(
[
@@ -1068,24 +1070,36 @@ 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
// 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 = snapshot.skills.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
@@ -1488,6 +1505,7 @@ export function createTuiChat(
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
disposeSkillChanges()
disposePromptChanges()
for (const value of promptValues) value.dispose()
stopBannerReveal()
+139 -5
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 } 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'
@@ -3890,6 +3890,133 @@ describe('skill slash command', () => {
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',
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',
source: 'runtime',
provider: 'runtime',
}],
complete: true,
})
await tick()
pendingSnapshots[0]?.resolve({
skills: [{ name: 'stale-first', description: 'STALE_FIRST', source: 'runtime', provider: 'runtime' }],
complete: true,
})
pendingSnapshots[1]?.resolve({
skills: [{ name: 'stale-second', description: 'STALE_SECOND', 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)
})
it('loads a skill as a user turn, appending typed instructions', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:demo-skill')
@@ -3965,7 +4092,7 @@ describe('skill slash command', () => {
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
list: () => Promise.reject(new Error('list boom')),
snapshot: () => Promise.reject(new Error('list boom')),
get: () => Promise.reject(new Error('get boom')),
} as never)
},
@@ -3979,13 +4106,13 @@ describe('skill slash command', () => {
})
it('drops skill list and lookup results that settle after disposal', async () => {
const pendingList: Array<(value: SkillSummary[]) => void> = []
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) }),
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
} as never)
},
@@ -3998,7 +4125,14 @@ describe('skill slash command', () => {
await tick()
await dispose(result)
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
result.ctx.emit('skills/change')
expect(pendingSnapshots).toHaveLength(1)
for (const resolve of pendingSnapshots) {
resolve({
skills: [{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }],
complete: true,
})
}
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
pendingGet[1]?.reject(new Error('late failure'))
await tick()
+23
View File
@@ -2281,6 +2281,9 @@ importers:
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../../bash/bash-local
'@deepseek-ai/dsh-bash-sandbox':
specifier: workspace:^
version: link:../../bash/bash-sandbox
@@ -4134,6 +4137,9 @@ importers:
packages/skill/skill-local:
dependencies:
chokidar:
specifier: ^5.0.0
version: 5.0.0
schemastery:
specifier: ^3.18.0
version: 3.18.0
@@ -4175,6 +4181,9 @@ importers:
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../skill
@@ -8539,6 +8548,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -10274,6 +10287,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
refa@0.12.1:
resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -13507,6 +13524,10 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -15593,6 +15614,8 @@ snapshots:
readdirp@4.1.2: {}
readdirp@5.0.0: {}
refa@0.12.1:
dependencies:
'@eslint-community/regexpp': 4.12.2
+3
View File
@@ -108,6 +108,7 @@ export const LINK_MAP: Record<string, string> = {
PreparedLlmCall: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
SkillProviderControl: 'skills.md',
CreateSessionOptions: 'persistence.md',
SessionHeader: 'persistence.md',
SessionLocation: 'persistence.md',
@@ -157,9 +158,11 @@ export const LINK_MAP: Record<string, string> = {
SessionTitleObservationResult: 'session-query.md',
SessionTitleProvider: 'session-title.md',
SessionTitleSnapshot: 'session-title.md',
SkillCatalogSnapshot: 'skills.md',
SkillDefinition: 'skills.md',
SkillLookupOptions: 'skills.md',
SkillProvider: 'skills.md',
SkillProviderObservation: 'skills.md',
SkillRegistration: 'skills.md',
SkillSummary: 'skills.md',
SaveTextSpill: 'spill.md',
+3 -2
View File
@@ -310,9 +310,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
source: 'packages/skill/tool-skill/src/index.ts',
requires: ['ctx.tools', 'ctx.skills'],
writes: ['tool/call', 'tool/result'],
requires: ['ctx.tools', 'ctx.agents', 'ctx.skills'],
writes: ['tool/call', 'tool/result', 'user/message replacement catalogs via agent.inject()'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
+15
View File
@@ -994,6 +994,11 @@
"symbol": "SkillSummary",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillCatalogSnapshot",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillCandidate",
@@ -1014,11 +1019,21 @@
"symbol": "SkillLookupOptions",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProviderObservation",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProvider",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProviderControl",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "Config",