From 979fa8ab33dac1f54ab6ac96c572e0f548ec0eab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:06:07 +0800 Subject: [PATCH] refactor(skill): scope provider invalidation --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 8 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 8 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +-- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 12 +- docs/core-data-structures/skills.zh.md | 12 +- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 8 +- packages/skill/skill-local/src/index.ts | 39 ++++-- .../tests/skill-local-watcher.spec.ts | 42 +++--- .../skill-local/tests/skill-local.spec.ts | 31 ++-- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 11 +- packages/skill/skill/README.zh.md | 11 +- packages/skill/skill/src/index.ts | 84 ++++++----- packages/skill/skill/tests/skill.spec.ts | 132 ++++++++++++------ .../skill/tool-skill/tests/tool-skill.spec.ts | 16 ++- packages/ui/tui/tests/tui.spec.ts | 8 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 24 files changed, 295 insertions(+), 181 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index a1a9780a3f..ba65c7d05d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 61f6690cb00546664e36d3916c98caf2dcc1b79f -2026-07-27-skill-catalog-hot-refresh.zh.md: f174d27f88c861cd3d951c1379f7ebaf72888f27 +2026-07-27-skill-catalog-hot-refresh.md: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 +2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 61f6690cb0..8a53195dd8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -12,7 +12,7 @@ Filesystem updates are also non-atomic from the observer's perspective. An edito ## Decision -The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit, while `ctx.skills.invalidateProvider(provider)` dirties only the exact registered provider and discards completed catalog caches. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A stale provider callback after disposal or replacement is a no-op because invalidation uses object identity. +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. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. 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. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin exact invalidation, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, 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. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, 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 @@ -35,6 +35,8 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - **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 @@ -43,4 +45,4 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - 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 remain responsible for calling `invalidateProvider()` from their own observation mechanism; the registry does not impose a universal watcher or TTL. +- 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. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index f174d27f88..8cf5553446 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -12,7 +12,7 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位;`ctx.skills.invalidateProvider(provider)` 只会将精确的已注册提供方标记为脏,并丢弃已经完成的目录缓存。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。提供方在资源释放或被替换后到达的陈旧回调不会执行任何操作,因为失效操作使用对象身份。 +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 使用有界集合,并按最久未观察顺序淘汰。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了精确失效、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 @@ -35,6 +35,8 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 - **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 - **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 +- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。 +- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。 ## 影响 @@ -43,4 +45,4 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。 - 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 - 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 -- 远程或未来的可变提供方仍有责任通过自身观察机制调用 `invalidateProvider()`;注册表不会强制采用通用 watcher 或 TTL。 +- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b4ac98544a..5e4d5414f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1187,7 +1187,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:121`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:129`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1219,7 +1219,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:46`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:47`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 885c98b806..3c20bb6ffc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -657,7 +657,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:139`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:147`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f87767af64..9a8196f06a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1397,19 +1397,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 - -/** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ -invalidateProvider(provider: SkillProvider): void +registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which @@ -1449,9 +1441,9 @@ async snapshot(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -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) · [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:160`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:168`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index a9040ffece..a89d2150a5 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -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: e1b2bfd5336c3cbce7f3c85bf5e440519efef36b -skills.zh.md: 3a80b6a93d37e945b7a2ad7ed4bccb45724d6b15 +skills.md: 247d8d71890a1624225091a7d53dd6cee977134a +skills.zh.md: 7d5c61dcf3efc3cc90cae2d5bf9f16e4df74bd79 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index e1b2bfd533..247d8d7189 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -10,7 +10,7 @@ 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 omitted from an incomplete observation without caching it, while malformed candidates fail fast. `invalidateProvider()` clears completed catalogs only for the exact live provider object, and an in-flight discovery retries when its provider generation changes. Provider and runtime membership mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. +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 without caching it, while 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 when its provider generation changes. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,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: diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 3a80b6a93d..7d5c61dcf3 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。`invalidateProvider()` 只针对传入的活动提供方对象清除已完成目录;若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时的成员关系变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,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 顺序扫描各根目录: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6ea309d10..1942291a03 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../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-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../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), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`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:102`](../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:139`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:147`](../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:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 923c7c8747..12efd2e644 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -671,12 +671,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: 'invalidateProvider(provider: SkillProvider): void', - jsDoc: '/**\n * Invalidate catalogs contributed by one currently registered provider. Exact object identity\n * prevents a late callback from an old provider instance from invalidating its replacement.\n * Calls for an already-unregistered provider are harmless.\n * @param provider - exact provider instance whose external source changed.\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', @@ -2216,6 +2212,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillProvider', declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, + { + name: 'SkillProviderControl', + declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}', + }, { name: 'SkillRegistration', declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 480f821b95..5df3e316cc 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -171,7 +171,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) @@ -181,7 +181,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. @@ -210,11 +210,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 })) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 228aa700b7..dbf3a3c0b6 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -26,6 +26,7 @@ import { type SkillDefinition, type SkillLookupOptions, type SkillProvider, + type SkillProviderControl, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -119,8 +120,11 @@ interface ResolvedWatchConfig { /** 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') @@ -138,12 +142,18 @@ export class LocalSkillProvider implements SkillProvider { private readonly customSkillDirs: string[] private readonly watchManager: SkillWatchManager private readonly bundledSkillDir: string | undefined + private disposal: Promise | 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, this, resolveWatchConfig(config)) + 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) } @@ -197,9 +207,13 @@ export class LocalSkillProvider implements SkillProvider { this.watchManager.observeHostMutation(path) } - /** Close every host watcher and contain late filesystem callbacks. */ - async dispose(): Promise { - await this.watchManager.dispose() + /** + * Close every host watcher and contain late filesystem callbacks. + * @returns a shared promise that settles when every watcher reaches quiescence. + */ + dispose(): Promise { + this.disposal ??= this.watchManager.dispose() + return this.disposal } private async roots(cwd: string | undefined): Promise { @@ -250,7 +264,7 @@ class SkillWatchManager { constructor( private readonly ctx: Context, - private readonly provider: SkillProvider, + private readonly invalidate: () => void, private readonly config: ResolvedWatchConfig, ) {} @@ -286,18 +300,17 @@ class SkillWatchManager { evictedProject = true } await Promise.all(pending) - if (evictedProject) this.ctx.skills.invalidateProvider(this.provider) + 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.ctx.skills.invalidateProvider(this.provider) + this.invalidate() } async dispose(): Promise { - if (this.closing) return this.closing = true const states = [...this.roots.values()] this.roots.clear() @@ -376,6 +389,8 @@ class SkillWatchManager { } } + // FIXME(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 { while (!this.closing && state.owners.size > 0) { const mode = await resolveRootWatchMode(state.root.path) @@ -489,7 +504,7 @@ class SkillWatchManager { queueMicrotask(() => { this.invalidationQueued = false if (this.closing) return - this.ctx.skills.invalidateProvider(this.provider) + this.invalidate() }) } diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 5cf5de8460..c13f4f1542 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -123,12 +123,8 @@ describe('skill-local watcher failures', () => { watchStabilityThresholdMs: 20, }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill']) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected a root watcher') @@ -169,14 +165,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + 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 }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -187,6 +186,7 @@ describe('skill-local watcher failures', () => { first.emitter.emit('ready') await Promise.all([discovery, disposal]) + disposeProvider() await settle() expect(first.closeCalls).toBeGreaterThan(0) }) @@ -198,14 +198,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + 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 }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -216,5 +219,6 @@ describe('skill-local watcher failures', () => { await expect(discovery).rejects.toThrow('opening failed during disposal') await disposal + disposeProvider() }) }) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 6c218f3f61..27bd0904b1 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -572,12 +572,8 @@ describe('LocalSkillProvider', () => { const root = join(home, '.agents/skills') const ctx = await setupLocal(home) expect(await ctx.skills.list()).toEqual([]) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) await writeSkill(root, 'observed-skill', 'Observed skill') const path = join(root, 'observed-skill/SKILL.md') @@ -657,15 +653,18 @@ describe('LocalSkillProvider', () => { await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - customSkillDirs: [nonDirectoryRoot], - watch: true, - watchStabilityThresholdMs: 20, - watchPollIntervalMs: 10, + 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 }) - ctx.skills.registerProvider(provider) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) await provider.dispose() @@ -673,6 +672,7 @@ describe('LocalSkillProvider', () => { provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + disposeProvider() }) it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { @@ -740,7 +740,10 @@ describe('LocalSkillProvider', () => { 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 diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d38296e354..09a8788486 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -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: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1 -README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195 +README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56 +README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 54362bd3a0..66b240c3a6 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -10,8 +10,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API -- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. -- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement. +- `ctx.skills.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 failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. @@ -19,7 +18,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Events -- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. +- `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 @@ -29,13 +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. 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 provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. -Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and that exact provider is invalidated so the next snapshot rediscovers its catalog. +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 @@ -55,7 +54,7 @@ No direct prompt effect. The named consumer owns the durable initial catalog and ## Known Limitations and Deferred Work -- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism. +- **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. - **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state. - **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8f15a44c81..0ebbab0899 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -10,8 +10,7 @@ ### 公开 API -- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 -- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。 +- `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。 @@ -19,7 +18,7 @@ ### 事件 -- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 ### 配置 @@ -29,13 +28,13 @@ ## 提供方契约 -提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 -定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。 +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 ## 运行时 Skill @@ -55,7 +54,7 @@ ## 已知限制与待完成工作 -- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 - **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index e3a041eb7d..f103f828a0 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -117,6 +117,14 @@ export interface SkillProvider { readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } +/** 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. */ @@ -180,43 +188,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 - } - - /** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ - invalidateProvider(provider: SkillProvider): void { - if (this.providers.get(provider.name)?.provider !== provider) return - this.invalidateCache() + 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 + } } /** @@ -391,6 +406,11 @@ export class SkillService extends Service { 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'])) { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 2f0134eab7..fdf9c40abc 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -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), @@ -140,7 +172,7 @@ describe('SkillService registry', () => { it('rejects non-array provider results and every malformed candidate scalar', async () => { const badList = new Context() await badList.plugin(SkillService) - badList.skills.registerProvider({ + registerProvider(badList, { name: 'non-array-list', list: () => Promise.resolve({} as unknown as SkillCandidate[]), get: () => Promise.resolve(undefined), @@ -171,7 +203,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 +227,7 @@ describe('SkillService registry', () => { rank: 1, locator: 'skill-a', } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'contextual', async list(received) { listedWith = received @@ -218,7 +250,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 +299,7 @@ describe('SkillService registry', () => { }) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'held', async list() { return [{ @@ -347,7 +379,7 @@ describe('SkillService registry', () => { } let listCalls = 0 let received: SkillCandidate | undefined - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'detached', async list() { listCalls += 1 @@ -422,7 +454,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 +487,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 +506,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 +524,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 +549,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 +576,7 @@ describe('SkillService registry', () => { let fail = true let flakyCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'flaky', async list() { flakyCalls += 1 @@ -572,21 +604,27 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) - const dispose = ctx.skills.registerProvider(provider) + 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)]) - ctx.skills.invalidateProvider(new MemoryProvider([])) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) - ctx.skills.invalidateProvider(provider) + 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)]) - ctx.skills.registerProvider(replacement) + registerProvider(ctx, replacement) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) - ctx.skills.invalidateProvider(provider) + invalidate() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) expect(replacement.listCalls).toBe(1) }) @@ -598,11 +636,13 @@ describe('SkillService registry', () => { let changes = 0 ctx.on('skills/change', () => { changes += 1 }) - const disposeProvider = ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + const disposeProvider = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) expect(changes).toBe(1) - ctx.skills.invalidateProvider(new MemoryProvider([])) - expect(changes).toBe(1) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(2) const disposeRuntime = ctx.skills.register({ @@ -616,7 +656,7 @@ describe('SkillService registry', () => { expect(changes).toBe(4) disposeProvider() expect(changes).toBe(5) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(5) }) @@ -632,7 +672,7 @@ describe('SkillService registry', () => { const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) const provider = new MemoryProvider([]) - expect(() => ctx.skills.registerProvider(provider)).not.toThrow() + expect(() => registerProvider(ctx, provider)).not.toThrow() await Promise.resolve() expect(observed).toBe(1) expect(warnings).toEqual([ @@ -662,12 +702,16 @@ describe('SkillService registry', () => { } return await originalList(options) } - ctx.skills.registerProvider(provider) + 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)]) - ctx.skills.invalidateProvider(provider) + invalidate() release?.() expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) @@ -695,7 +739,7 @@ describe('SkillService registry', () => { return { ...candidate, name: 'new-name', content: 'Fresh body.' } }, } - ctx.skills.registerProvider(provider) + registerProvider(ctx, provider) expect(await ctx.skills.get('old-name')).toBeUndefined() await ctx.skills.list() @@ -705,7 +749,7 @@ describe('SkillService registry', () => { it('returns undefined when a discovered candidate disappears before loading', async () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'vanished-body', async list() { return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }] @@ -728,7 +772,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. @@ -753,7 +797,7 @@ describe('SkillService registry', () => { let release: (() => void) | undefined const started = new Promise((resolve) => { markStarted = resolve }) const gate = new Promise((resolve) => { release = resolve }) - const dispose = ctx.skills.registerProvider({ + const dispose = registerProvider(ctx, { name: 'delayed', async list() { markStarted?.() @@ -783,7 +827,7 @@ describe('SkillService registry', () => { const held = new Promise((resolve) => { release = () => { resolve([]) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'uncooperative', list(options) { seenSignal = options.signal diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c08df2f984..028f0f7869 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -153,7 +153,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 @@ -162,7 +162,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) const controller = new AbortController() await composePrefix(ctx, '/workspace', controller.signal) @@ -245,7 +245,11 @@ describe('dsh-tool-skill', () => { return undefined }, } - ctx.skills.registerProvider(provider) + 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) @@ -253,7 +257,7 @@ describe('dsh-tool-skill', () => { await composePrefixForAgent(ctx, agent) expect(catalogMessages(session)).toEqual([]) failing = false - ctx.skills.invalidateProvider(provider) + invalidate() await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toEqual([]) @@ -421,7 +425,7 @@ describe('dsh-tool-skill', () => { openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'failing', async list() { throw new Error('temporarily unavailable') @@ -429,7 +433,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) disposeStable() await fireStep(ctx, agent, 1, 1) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 501aa6c5c5..bc11ff68b8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3596,6 +3596,7 @@ describe('skill slash command', () => { 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) => { @@ -3619,13 +3620,16 @@ describe('skill slash command', () => { return undefined }, } - skills?.registerProvider(provider) + skills?.registerProvider((control) => { + invalidate = control.invalidate + return provider as SkillProvider + }) }, }) if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') fail = true - skills.invalidateProvider(provider) + invalidate() await tick() result.terminal.output = '' result.terminal.send('/skill:stable') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 23b114276d..f16711ab1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,7 @@ export const LINK_MAP: Record = { PreparedLlmCall: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', + SkillProviderControl: 'skills.md', CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 160f3c33c2..53fb6b4a33 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1009,6 +1009,11 @@ "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",