From 12832886c5441e399e13ccc4a8b1df292795a90b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:51:47 +0800 Subject: [PATCH] refactor(skill): canonicalize invocation policy --- ...26-07-28-skill-invocation-policy.i18n.yaml | 4 +- .../2026-07-28-skill-invocation-policy.md | 8 ++-- .../2026-07-28-skill-invocation-policy.zh.md | 8 ++-- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 17 ++++--- docs/core-data-structures/skills.zh.md | 17 ++++--- .../cordis/tool-cordis/src/api-catalog.ts | 6 +-- packages/host/apiproxy/src/api-proxy.ts | 6 +-- .../apiproxy/tests/api-proxy-commands.spec.ts | 1 + packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 11 ++--- .../skill-local/tests/skill-local.spec.ts | 4 ++ packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 10 ++-- packages/skill/skill/README.zh.md | 10 ++-- packages/skill/skill/src/index.ts | 47 +++++++++++-------- packages/skill/skill/tests/skill.spec.ts | 10 ++++ packages/ui/tui/src/index.ts | 12 ++--- packages/ui/tui/tests/tui.spec.ts | 26 ++++++++-- 23 files changed, 134 insertions(+), 85 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index 290e8f10b9..84333f2c9f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.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-28-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: dd97f6f9d26de7588cac61e480c3a16fa6e6173a -2026-07-28-skill-invocation-policy.zh.md: c4c89bbe30a0bc53f36250bde74f2c66903f29d2 +2026-07-28-skill-invocation-policy.md: 2010196c8373c89a49056a5d8acd41e2e4c307ce +2026-07-28-skill-invocation-policy.zh.md: 129c5b7f62b14a0e9b9bc5ae6107ba946a5a54d7 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index dd97f6f9d2..2010196c83 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -12,11 +12,11 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su ## Decision -`SkillSummary` carries an optional typed `invocation: SkillInvocationPolicy` object. When present, its `modelInvocable: boolean` and `userInvocable: boolean` fields are both required, positive, and symmetric; future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist. The local provider still parses frontmatter as an open `Record`, then projects only recognized fields and their defaults into the normalized typed policy. +`SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record`, then projects only recognized fields and their defaults into the normalized typed policy. -`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field; an absent policy permits both surfaces. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. +`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. -The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills the other field's default whenever either key is present. Camel-case external spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. +The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. Camel-case external spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. @@ -24,7 +24,7 @@ These rules permit all four combinations: | Policy | Model surface | User surface | |---|---|---| -| no policy, or `{ modelInvocable: true, userInvocable: true }` | included | included | +| `{ modelInvocable: true, userInvocable: true }` | included | included | | `{ modelInvocable: true, userInvocable: false }` | included | excluded | | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index c4c89bbe30..129c5b7f62 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -12,11 +12,11 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 ## 决策 -`SkillSummary` 包含一个可选且类型明确的 `invocation: SkillInvocationPolicy` 对象。该对象存在时,`modelInvocable: boolean` 和 `userInvocable: boolean` 都是必填、正向且对称的字段;未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型。本地提供方仍将 frontmatter 解析为开放的 `Record`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。 +`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。 -`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。 +`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。 -本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,并在任一键存在时填充另一个字段的默认值。外部使用的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。 +本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。外部使用的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。 面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 @@ -24,7 +24,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 | 策略 | 模型侧接口 | 用户侧接口 | |---|---|---| -| 无策略,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | +| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | | `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 | | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8e2785f35e..ca9126cfa2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:144`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb5626ac63..bf7961151d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1645,7 +1645,7 @@ registerProvider(provider: SkillProvider): () => void * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and * receives a no-op disposer so it cannot remove the winner. - * @param skill - the complete skill definition to expose for discovery. + * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void @@ -1673,7 +1673,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## Lookup and configuration diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index da36f301b6..7bc2d21651 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -62,7 +62,7 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## 摘要、候选项与完整定义 -`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,而不会把任意 frontmatter 纳入领域模型。 +`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。 ```ts type-equiv /** Invocation controls shared by skill discovery consumers. */ @@ -83,8 +83,8 @@ interface SkillSummary { readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Optional model and user invocation controls. */ - readonly invocation?: SkillInvocationPolicy + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -94,7 +94,7 @@ interface SkillSummary { } ``` -`ctx.skills.list()` 保留全部四种策略组合。缺少 `invocation` 对象时两个接口均允许调用;该对象存在时,两个布尔字段都为必填。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,应用其默认值,再将其投影到这个规范化策略中。 +`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。 `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 @@ -134,11 +134,16 @@ interface SkillDefinition extends SkillSummary { } ``` -运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 +运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 ```ts type-equiv /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -type SkillRegistration = Omit & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## 查找与配置 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a6e2fc5add..635ccc28e2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -754,7 +754,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'register(skill: SkillRegistration): () => void', - jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', + jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', }, { signature: 'async list(options: SkillLookupOptions = {}): Promise', @@ -2353,7 +2353,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillRegistration', - declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', + declaration: 'export type SkillRegistration = Omit & {\n readonly invocation?: SkillInvocationPolicy;\n readonly provider?: string;\n};', }, { name: 'SkillResourceBase', @@ -2365,7 +2365,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSummary', - declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation?: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', + declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, { name: 'SpillLocator', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 015b10687b..4cd55a398a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -16,7 +16,6 @@ import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -36,8 +35,9 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' -// Type-only edge: resolves `ctx.get('commands')` and the `commands/change` event. +// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' +import type {} from '@deepseek-ai/dsh-skill' import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and @@ -1426,7 +1426,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } try { const skills = (await skillRegistry.list({ cwd })) - .filter(skill => isModelInvocable(skill) && isUserInvocable(skill)) + .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5825dbc535..db4e125bd5 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -187,6 +187,7 @@ describe('skill.list', () => { return Promise.resolve([ { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', + invocation: { modelInvocable: true, userInvocable: true }, source: 'custom', provider: 'probe', rank: 0, locator: null, }, { diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index a920bd4509..78c4ba247c 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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-local/README.md -README.md: cbef5e489e4340e10f3337ffd1bdb64490af4bc1 -README.zh.md: ae1e5658c5820ecf5a9a89f76f7aba4d6ad3e742 +README.md: addabda98490b49736c1aa5053f5734978079a20 +README.zh.md: 1ab1a43d70d8393ba8c837f384e3a422cfdc1d3c diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index cbef5e489e..addabda984 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -38,7 +38,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case. -The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. If either field is present, the provider fills both positive internal policy values from these external defaults. The camel-case spellings `disableModelInvocation`, `modelInvocable`, and `userInvocable` are rejected with a warning instead of acting as compatibility aliases. +The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. The camel-case spellings `disableModelInvocation`, `modelInvocable`, and `userInvocable` are rejected with a warning instead of acting as compatibility aliases. ## Model Experience diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index ae1e5658c5..1ab1a43d70 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -38,7 +38,7 @@ Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 Markdown 文件(`.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。 -这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。任一字段存在时,提供方都会按照这些外部默认值填充两个正向内部策略值。系统会拒绝驼峰形式的 `disableModelInvocation`、`modelInvocable` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。 +这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。系统会拒绝驼峰形式的 `disableModelInvocation`、`modelInvocable` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。 ## 模型体验 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 80e0e091b7..d382f3a781 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -75,7 +75,7 @@ interface ParsedSkill { name: string description: string whenToUse?: string - invocation?: SkillInvocationPolicy + invocation: SkillInvocationPolicy metadata?: Record content: string } @@ -137,7 +137,7 @@ export class LocalSkillProvider implements SkillProvider { name: parsed.name, description: parsed.description, ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}, - ...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {}, + invocation: parsed.invocation, source: candidate.source, provider: this.name, resourceBase: { kind: 'directory', path: locator.directory }, @@ -185,7 +185,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise, key: string): { [K in typ return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} } -function parseInvocationPolicy(data: Record): SkillInvocationPolicy | undefined { +function parseInvocationPolicy(data: Record): SkillInvocationPolicy { rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation') rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation') rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable') const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation') const userInvocable = frontmatterBoolean(data, 'user-invocable') - if (disableModelInvocation === undefined && userInvocable === undefined) return undefined return { modelInvocable: disableModelInvocation !== true, userInvocable: userInvocable !== false, diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index ae988f9562..4b7f0671d6 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -244,7 +244,11 @@ describe('LocalSkillProvider', () => { 'rich-skill', 'user-only-skill', ]) + expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true }) expect(await ctx.skills.get('flat-skill')).toBeUndefined() + expect(await ctx.skills.get('no-trailing-body')).toMatchObject({ + invocation: { modelInvocable: true, userInvocable: true }, + }) expect(await ctx.skills.get('user-only-skill')).toMatchObject({ invocation: { modelInvocable: false, userInvocable: true }, content: 'User-only.', diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 9192d99645..d477da8ab8 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: fd994fb2d20d0d8027b33d1092b9a94de0375e15 -README.zh.md: bb4e3ea8e764cf96c44235029342e2d5f9b5f578 +README.md: d2b75ff73e97089ed3d8da4192e71898e6b7eac6 +README.zh.md: 1b559e7584fc1646e868d933d13469dfb5d3cef4 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index fd994fb2d2..d2b75ff73e 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -13,7 +13,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co - `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. The result is invocation-neutral; consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. -- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Config @@ -23,16 +23,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Invocation policy -`SkillSummary.invocation` is an optional typed policy object. When present, its required positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently; omitting the object preserves the default model-and-user behavior. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs. +`SkillSummary.invocation` is a required typed policy object whose positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently. Providers return this resolved shape on every candidate and definition; only the `SkillRegistration` input may omit it, in which case `register()` supplies `{ modelInvocable: true, userInvocable: true }`. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs. | Policy | Model | User | |---|---|---| -| no `invocation`, or `{ modelInvocable: true, userInvocable: true }` | included | included | +| `{ modelInvocable: true, userInvocable: true }` | included | included | | `{ modelInvocable: true, userInvocable: false }` | included | excluded | | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | -`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field, with an absent policy permitting both surfaces. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. +`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. ## Provider Contract @@ -44,7 +44,7 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou ## Runtime Skills -`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. +`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service materializes one top-level definition to supply omitted invocation and provider defaults. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. ## Consumer boundary diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index bb4e3ea8e7..1b559e7584 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -13,7 +13,7 @@ - `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR(热模块替换);Cordis 返回的原始 disposer 支持有序组合拆卸。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 -- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 ### 配置 @@ -23,16 +23,16 @@ ### 调用策略 -`SkillSummary.invocation` 是一个可选的类型化策略对象。该对象存在时,其必填的正向布尔字段 `modelInvocable` 和 `userInvocable` 分别描述两个接口;省略该对象时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。 +`SkillSummary.invocation` 是一个必填的类型化策略对象,其正向布尔字段 `modelInvocable` 和 `userInvocable` 分别描述两个接口。提供方会在每个候选项和定义中返回这一已解析形状;只有 `SkillRegistration` 输入可以省略它,此时 `register()` 会补入 `{ modelInvocable: true, userInvocable: true }`。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。 | 策略 | 模型 | 用户 | |---|---|---| -| 无 `invocation`,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | +| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | | `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 | | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | -`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 +`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 ## 提供方契约 @@ -44,7 +44,7 @@ ## 运行时 skill -`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化提供默认 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。 +`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化补入默认调用策略和 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。 ## 消费方边界 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index a90a5e3e6c..3af58804b1 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -52,8 +52,8 @@ export interface SkillSummary { readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Optional model and user invocation controls. */ - readonly invocation?: SkillInvocationPolicy + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -85,7 +85,12 @@ export interface SkillDefinition extends SkillSummary { } /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -export type SkillRegistration = Omit & { readonly provider?: string } +export type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} /** Caller context used for cwd-sensitive and abortable provider work. */ export interface SkillLookupOptions { @@ -97,20 +102,20 @@ export interface SkillLookupOptions { /** * Return whether a skill may be advertised to and loaded by a model. - * @param skill - skill metadata carrying optional invocation controls. - * @returns whether the normalized policy permits model invocation. + * @param skill - skill metadata carrying resolved invocation controls. + * @returns whether the policy permits model invocation. */ export function isModelInvocable(skill: Pick): boolean { - return skill.invocation?.modelInvocable !== false + return skill.invocation.modelInvocable } /** * Return whether a skill may be advertised to and loaded by a human-facing command. - * @param skill - skill metadata carrying optional invocation controls. - * @returns whether the normalized policy permits user invocation. + * @param skill - skill metadata carrying resolved invocation controls. + * @returns whether the policy permits user invocation. */ export function isUserInvocable(skill: Pick): boolean { - return skill.invocation?.userInvocable !== false + return skill.invocation.userInvocable } /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -171,7 +176,7 @@ export class SkillService extends Service { private readonly collectCacheMaxEntries: number private readonly providers = new Map() - private readonly runtime = new Map() + private readonly runtime = new Map() private readonly collectCache = new Map() private providerRevision = 0 private nextProviderOrder = 0 @@ -219,7 +224,7 @@ export class SkillService extends Service { * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and * receives a no-op disposer so it cannot remove the winner. - * @param skill - the complete skill definition to expose for discovery. + * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { @@ -229,15 +234,20 @@ export class SkillService extends Service { this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`) return () => {} } + const definition: SkillDefinition = { + ...skill, + invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true }, + provider: skill.provider ?? RUNTIME_PROVIDER, + } const runtime = this.runtime const updateRevision = (): void => { this.runtimeRevision += 1 } const invalidateCache = (): void => { this.invalidateCache() } const dispose = this.ctx.effect(function* () { - runtime.set(skill.name, skill) + runtime.set(definition.name, definition) updateRevision() invalidateCache() yield () => { - runtime.delete(skill.name) + runtime.delete(definition.name) updateRevision() invalidateCache() } @@ -375,19 +385,18 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = { return Promise.resolve([]) }, get(candidate) { - const skill = candidate.locator as SkillRegistration - return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER }) + return Promise.resolve(candidate.locator as SkillDefinition) }, } -function runtimeCandidate(skill: SkillRegistration): SkillCandidate { +function runtimeCandidate(skill: SkillDefinition): SkillCandidate { return { name: skill.name, description: skill.description, ...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}, - ...skill.invocation !== undefined ? { invocation: skill.invocation } : {}, + invocation: skill.invocation, source: skill.source, - provider: skill.provider ?? RUNTIME_PROVIDER, + provider: skill.provider, ...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {}, rank: RUNTIME_RANK, locator: skill, @@ -464,7 +473,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary { name, description, ...whenToUse !== undefined ? { whenToUse } : {}, - ...invocation !== undefined ? { invocation } : {}, + invocation, source, provider, ...resourceBase !== undefined ? { resourceBase } : {}, diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 87d708f8ee..77a5db20ce 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -14,6 +14,7 @@ function memorySkill(name: string, description: string, rank: number, body = `${ return { name, description, + invocation: { modelInvocable: true, userInvocable: true }, provider: 'memory', source: 'memory', rank, @@ -57,6 +58,7 @@ describe('SkillService registry', () => { return [{ name: 'shadowed', description: 'Higher priority', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'override', source: 'override', rank: 5, @@ -82,6 +84,7 @@ describe('SkillService registry', () => { return [{ name: 'same-rank-skill', description: 'Same rank', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'same-rank', source: 'same-rank', rank: 10, @@ -136,9 +139,11 @@ describe('SkillService registry', () => { const listed = await ctx.skills.list() expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only']) + expect(listed.find(skill => skill.name === 'both')?.invocation).toEqual({ modelInvocable: true, userInvocable: true }) expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only']) expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only']) expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' }) + expect((await ctx.skills.get('both'))?.invocation).toEqual({ modelInvocable: true, userInvocable: true }) }) it('validates parsed candidate fields', async () => { @@ -224,6 +229,7 @@ describe('SkillService registry', () => { const candidate: SkillCandidate = { name: 'skill-a', description: 'Skill A', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'contextual', source: 'test', rank: 1, @@ -258,6 +264,7 @@ describe('SkillService registry', () => { return [{ name: 'cached-skill', description: 'Cached skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'cached', source: 'test', rank: 1, @@ -295,6 +302,7 @@ describe('SkillService registry', () => { resolve({ name: 'held-skill', description: 'Held skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'held', source: 'test', content: 'Held body.', @@ -307,6 +315,7 @@ describe('SkillService registry', () => { return [{ name: 'held-skill', description: 'Held skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'held', source: 'test', rank: 1, @@ -481,6 +490,7 @@ describe('SkillService registry', () => { list: () => Promise.resolve([{ name: skillName, description: 'Candidate', + invocation: { modelInvocable: true, userInvocable: true }, provider: providerName, source: 'test', rank: 1, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 3027ace30c..b5e632a5bd 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -47,7 +47,7 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { SkillService, SkillSummary } from '@deepseek-ai/dsh-skill' +import type { SkillService } from '@deepseek-ai/dsh-skill' // Type import declaration-merges the `userInteraction` service onto `Context`; // the ask-user-question queue is registered by ./chat/questions. import type {} from '@deepseek-ai/dsh-user-interaction' @@ -170,10 +170,6 @@ export type { TuiViewport, } from './extension/types.ts' -function isSkillUserInvocable(skill: Pick): boolean { - return skill.invocation?.userInvocable !== false -} - declare module 'cordis' { interface Context { /** Terminal-only interaction service, available only while a TUI is mounted. */ @@ -1076,7 +1072,7 @@ export function createTuiChat( const loadSkillCommands = (service: SkillService): void => { service.list({ cwd, signal: skillAbort.signal }).then( (summaries) => { - const invocable = summaries.filter(isSkillUserInvocable) + const invocable = summaries.filter(skill => skill.invocation.userInvocable) if (disposed || invocable.length === 0) 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 @@ -1277,7 +1273,7 @@ export function createTuiChat( appendNotice(`Unknown skill: ${name}`, 'warning') return } - if (!isSkillUserInvocable(summary)) { + if (!summary.invocation.userInvocable) { appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') return } @@ -1288,7 +1284,7 @@ export function createTuiChat( appendNotice(`Unknown skill: ${name}`, 'warning') return } - if (!isSkillUserInvocable(skill)) { + if (!skill.invocation.userInvocable) { appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') return } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8777922025..22a35a50ac 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4014,7 +4014,13 @@ describe('skill slash command', () => { listCalls += 1 if (listCalls === 1) return Promise.resolve([]) if (listCalls === 2) { - return Promise.resolve([{ name: 'demo-skill', description: 'demo', source: 'runtime', provider: 'runtime' }]) + return Promise.resolve([{ + name: 'demo-skill', + description: 'demo', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }]) } return new Promise((resolve) => { resolvePendingList = resolve }) }, @@ -4031,8 +4037,21 @@ describe('skill slash command', () => { await tick() await dispose(result) - resolvePendingList?.([{ name: 'other-skill', description: 'late', source: 'runtime', provider: 'runtime' }]) - pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) + resolvePendingList?.([{ + name: 'other-skill', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }]) + pendingGet[0]?.resolve({ + name: 'demo-skill', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + content: 'late body', + }) await tick() expect(result.agent.sent).toEqual([]) expect(result.terminal.output).not.toContain('late body') @@ -4043,6 +4062,7 @@ describe('renderSkillInvocation', () => { const skill: SkillDefinition = { name: 'demo-skill', description: 'Demo skill', + invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime', content: 'Body text.',