feat(skill): add invocation controls
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
|
||||
README.zh.md: 368171a43ef3a449049542cd227459f82ec43086
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
|
||||
README.zh.md: d7cfd96b8de49be7875e37dba4f06cd4bf3aa407
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backed,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 出生的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
skill(技能)引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backed,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 出生的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
|
||||
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
|
||||
@@ -684,7 +684,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||
jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */',
|
||||
jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
@@ -2201,6 +2201,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SkillDefinition',
|
||||
declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillInvocationPolicy',
|
||||
declaration: 'export interface SkillInvocationPolicy {\n readonly disableModelInvocation?: boolean;\n readonly userInvocable?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillLookupOptions',
|
||||
declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}',
|
||||
@@ -2223,7 +2227,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SkillSummary',
|
||||
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
|
||||
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation?: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpillLocator',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: d6db9a9541b0727b61dbe501f7234564ffef139e
|
||||
README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d
|
||||
README.md: 20c6e73628db160811f016a7c6df526edb172def
|
||||
README.zh.md: 131ead31de3c54b558ab7c07eb8cc6974c0b1484
|
||||
|
||||
@@ -20,7 +20,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
@@ -29,9 +30,8 @@ import type {
|
||||
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
// Type-only edge: resolve `ctx.get('commands')` and the `commands/change` event.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
@@ -1082,7 +1082,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
try {
|
||||
const skills = await skillRegistry.list({ cwd })
|
||||
const skills = (await skillRegistry.list({ cwd }))
|
||||
.filter(skill => isModelInvocable(skill) && isUserInvocable(skill))
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
|
||||
@@ -20,6 +20,6 @@ export interface SkillEntry {
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
export interface SkillsApi {
|
||||
/** Lists model-invocable skills for the addressed session's project root. */
|
||||
/** Lists skills usable by the browser's user-selected model-reference path. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
|
||||
@@ -175,10 +175,27 @@ describe('skill.list', () => {
|
||||
name: 'probe',
|
||||
list: (options) => {
|
||||
seenCwds.push(options.cwd)
|
||||
return Promise.resolve([{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
}])
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'user-only', description: 'User-only',
|
||||
invocation: { disableModelInvocation: true },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'model-only', description: 'Model-only',
|
||||
invocation: { userInvocable: false },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'trusted-only', description: 'Trusted-only',
|
||||
invocation: { disableModelInvocation: true, userInvocable: false },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
])
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c488fdc4b1d97b5aa1113e41a470484063526ded
|
||||
README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
|
||||
README.md: 56e19088e075f0ce9ad76eebdd23054e538174e4
|
||||
README.zh.md: 38caf2716240700aab4220783068651c9dd851f7
|
||||
|
||||
@@ -36,7 +36,9 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
|
||||
|
||||
## Skill Format
|
||||
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
|
||||
|
||||
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Omitted fields preserve both forms of invocation. The camel-case spellings `disableModelInvocation` and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
|
||||
## Skill 格式
|
||||
|
||||
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。
|
||||
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。
|
||||
|
||||
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和加载器中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。省略字段时保留两种调用方式。系统会拒绝驼峰形式的 `disableModelInvocation` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isSkillName,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type SkillInvocationPolicy,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillSource,
|
||||
@@ -74,7 +75,7 @@ interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
invocation?: SkillInvocationPolicy
|
||||
metadata?: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
@@ -136,7 +137,7 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
|
||||
source: candidate.source,
|
||||
provider: this.name,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
@@ -184,7 +185,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
|
||||
provider: 'local',
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
@@ -263,11 +264,18 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
|
||||
return undefined
|
||||
}
|
||||
let invocation
|
||||
try {
|
||||
invocation = parseInvocationPolicy(parsed.data)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...invocation === undefined ? {} : { invocation },
|
||||
...optionalMetadata(parsed.data),
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
@@ -420,9 +428,43 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
|
||||
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
|
||||
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy | undefined {
|
||||
rejectLegacyInvocationKey(data, 'disableModelInvocation', '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 {
|
||||
...disableModelInvocation === undefined ? {} : { disableModelInvocation },
|
||||
...userInvocable === undefined ? {} : { userInvocable },
|
||||
}
|
||||
}
|
||||
|
||||
function rejectLegacyInvocationKey(data: Record<string, unknown>, legacy: string, canonical: string): void {
|
||||
if (Object.hasOwn(data, legacy)) {
|
||||
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
|
||||
if (!Object.hasOwn(data, key)) return undefined
|
||||
const value = data[key]
|
||||
return typeof value === 'boolean' ? { [key]: value } : {}
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 1 || value === '1') return true
|
||||
if (value === 0 || value === '0') return false
|
||||
if (typeof value === 'string') {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case 'on':
|
||||
return true
|
||||
case 'false':
|
||||
case 'no':
|
||||
case 'off':
|
||||
return false
|
||||
}
|
||||
}
|
||||
throw new TypeError(`frontmatter field "${key}" must be a boolean`)
|
||||
}
|
||||
|
||||
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('LocalSkillProvider', () => {
|
||||
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
|
||||
})
|
||||
|
||||
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
|
||||
it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => {
|
||||
const home = await tempDir('skill-flat')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
|
||||
@@ -209,7 +209,8 @@ describe('LocalSkillProvider', () => {
|
||||
'name: rich-skill',
|
||||
'description: rich description',
|
||||
'whenToUse: For richer local parsing',
|
||||
'disableModelInvocation: false',
|
||||
'disable-model-invocation: off',
|
||||
'user-invocable: YES',
|
||||
'metadata:',
|
||||
' owner: tests',
|
||||
'---',
|
||||
@@ -225,8 +226,10 @@ describe('LocalSkillProvider', () => {
|
||||
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
|
||||
await writeFile(join(root, 'notes.txt'), 'ignored')
|
||||
await mkdir(join(root, 'not-a-skill'), { recursive: true })
|
||||
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
|
||||
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
|
||||
await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.')
|
||||
await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n')
|
||||
await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.')
|
||||
await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
const listedBeforeDelete = await ctx.skills.list()
|
||||
@@ -234,17 +237,88 @@ describe('LocalSkillProvider', () => {
|
||||
if (flatSummary === undefined) throw new Error('expected flat-skill')
|
||||
await writeFile(join(root, 'flat-skill.md'), '')
|
||||
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
|
||||
'flat-skill',
|
||||
'model-only-skill',
|
||||
'no-trailing-body',
|
||||
'rich-skill',
|
||||
'user-only-skill',
|
||||
])
|
||||
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
|
||||
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
|
||||
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
|
||||
invocation: { disableModelInvocation: true },
|
||||
content: 'User-only.',
|
||||
})
|
||||
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
|
||||
invocation: { userInvocable: false },
|
||||
content: 'Model-only.',
|
||||
})
|
||||
expect(await ctx.skills.get('rich-skill')).toMatchObject({
|
||||
whenToUse: 'For richer local parsing',
|
||||
disableModelInvocation: false,
|
||||
invocation: { disableModelInvocation: false, userInvocable: true },
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts the documented boolean spellings for invocation frontmatter', async () => {
|
||||
const home = await tempDir('skill-invocation-booleans')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
const truthy = ['true', 'TRUE', 'yes', 'ON', '1', '"1"']
|
||||
const falsy = ['false', 'FALSE', 'no', 'OFF', '0', '"0"']
|
||||
for (const [index, value] of truthy.entries()) {
|
||||
await writeFile(join(root, `truthy-${index}.md`), [
|
||||
'---',
|
||||
`name: truthy-${index}`,
|
||||
`description: Truthy ${index}`,
|
||||
`disable-model-invocation: ${value}`,
|
||||
'---',
|
||||
'',
|
||||
'Truthy.',
|
||||
].join('\n'))
|
||||
}
|
||||
for (const [index, value] of falsy.entries()) {
|
||||
await writeFile(join(root, `falsy-${index}.md`), [
|
||||
'---',
|
||||
`name: falsy-${index}`,
|
||||
`description: Falsy ${index}`,
|
||||
`user-invocable: ${value}`,
|
||||
'---',
|
||||
'',
|
||||
'Falsy.',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
for (const [index] of truthy.entries()) {
|
||||
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ disableModelInvocation: true })
|
||||
}
|
||||
for (const [index] of falsy.entries()) {
|
||||
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ userInvocable: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => {
|
||||
const home = await tempDir('skill-invalid-invocation')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'good-skill', 'Good skill')
|
||||
const invalid = [
|
||||
['legacy-model', 'disableModelInvocation: true'],
|
||||
['legacy-user', 'userInvocable: false'],
|
||||
['bad-string', 'disable-model-invocation: maybe'],
|
||||
['bad-value', 'user-invocable: null'],
|
||||
] as const
|
||||
for (const [name, field] of invalid) {
|
||||
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
|
||||
}
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
})
|
||||
|
||||
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
|
||||
const home = await tempDir('skill-frontmatter-crlf')
|
||||
const root = join(home, '.dsh/skills')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 639616d0b75f960e9ccd48546d44db841372bbe2
|
||||
README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
|
||||
README.md: 30e48b57cd33c50013f857c61e63cba74fd2cd62
|
||||
README.zh.md: 08b4a97a921565b5d69960e6f69d6ee6dd194986
|
||||
|
||||
@@ -11,8 +11,8 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
|
||||
- `ctx.skills.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.
|
||||
|
||||
### Config
|
||||
@@ -21,6 +21,19 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|---|---|---|
|
||||
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. |
|
||||
|
||||
### Invocation policy
|
||||
|
||||
`SkillSummary.invocation` is a typed policy object with optional `disableModelInvocation` and `userInvocable` booleans. Missing values preserve 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.
|
||||
|
||||
| Policy | Model | User |
|
||||
|---|---|---|
|
||||
| neither field, or `false` / `true` | included | included |
|
||||
| `userInvocable: false` | included | excluded |
|
||||
| `disableModelInvocation: true` | excluded | included |
|
||||
| both restrictive values | excluded | excluded |
|
||||
|
||||
`isModelInvocable(skill)` returns false only for `disableModelInvocation: true`; `isUserInvocable(skill)` returns false only for `userInvocable: false`. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
|
||||
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
|
||||
### 配置
|
||||
@@ -21,6 +21,19 @@
|
||||
|---|---|---|
|
||||
| `collectCacheMaxEntries` | `128` | 内存中保留的最大已完成 cwd/提供方目录数。 |
|
||||
|
||||
### 调用策略
|
||||
|
||||
`SkillSummary.invocation` 是类型化策略对象,其中包含可选的布尔字段 `disableModelInvocation` 和 `userInvocable`。字段缺失时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
|
||||
|
||||
| 策略 | 模型 | 用户 |
|
||||
|---|---|---|
|
||||
| 两个字段均未设置,或分别为 `false` / `true` | 包含 | 包含 |
|
||||
| `userInvocable: false` | 包含 | 排除 |
|
||||
| `disableModelInvocation: true` | 排除 | 包含 |
|
||||
| 两个限制值均已设置 | 排除 | 排除 |
|
||||
|
||||
`isModelInvocable(skill)` 仅在 `disableModelInvocation: true` 时返回 false;`isUserInvocable(skill)` 仅在 `userInvocable: false` 时返回 false。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
|
||||
|
||||
## 提供方契约
|
||||
|
||||
提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
|
||||
@@ -36,16 +36,24 @@ export type SkillResourceBase =
|
||||
| { readonly kind: 'url'; readonly url: string }
|
||||
| { readonly kind: 'opaque'; readonly description: string }
|
||||
|
||||
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
|
||||
export interface SkillSummary {
|
||||
/** Kebab-case identifier used with the `skill` tool. */
|
||||
readonly name: string
|
||||
/** Short routing description shown to the model. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance shown to the model. */
|
||||
readonly whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
/** Invocation controls shared by skill discovery consumers. */
|
||||
export interface SkillInvocationPolicy {
|
||||
/** Whether model-facing catalogs and loaders exclude this skill. */
|
||||
readonly disableModelInvocation?: boolean
|
||||
/** Whether human-facing command catalogs and loaders include this skill. */
|
||||
readonly userInvocable?: boolean
|
||||
}
|
||||
|
||||
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
|
||||
export interface SkillSummary {
|
||||
/** Kebab-case identifier used to address the skill. */
|
||||
readonly name: string
|
||||
/** Short routing description shown by discovery consumers. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** Optional model and user invocation controls. */
|
||||
readonly invocation?: SkillInvocationPolicy
|
||||
/** Discovery source that produced this winning skill. */
|
||||
readonly source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
@@ -87,6 +95,24 @@ export interface SkillLookupOptions {
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a skill may be advertised to and loaded by a model.
|
||||
* @param skill - skill metadata carrying optional invocation controls.
|
||||
* @returns `false` only when model invocation is explicitly disabled.
|
||||
*/
|
||||
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
|
||||
return skill.invocation?.disableModelInvocation !== true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a skill may be advertised to and loaded by a human-facing command.
|
||||
* @param skill - skill metadata carrying optional invocation controls.
|
||||
* @returns `false` only when user invocation is explicitly disabled.
|
||||
*/
|
||||
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
|
||||
return skill.invocation?.userInvocable !== false
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
export interface SkillProvider {
|
||||
/** Unique provider name in the `ctx.skills` registry. */
|
||||
@@ -221,16 +247,16 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace. Lookup options and
|
||||
* provider candidates are readonly same-process values borrowed throughout
|
||||
* discovery.
|
||||
* List invocation-neutral skill summaries for a workspace. Consumers apply
|
||||
* model or user invocation policy at their operational boundary. Lookup
|
||||
* options and provider candidates are readonly same-process values borrowed
|
||||
* throughout discovery.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
* @returns all sorted winning summaries.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary)
|
||||
}
|
||||
@@ -359,7 +385,7 @@ function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
|
||||
...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
|
||||
...skill.invocation !== undefined ? { invocation: skill.invocation } : {},
|
||||
source: skill.source,
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
|
||||
@@ -383,9 +409,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
|
||||
if (candidate.description.length === 0) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
|
||||
}
|
||||
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
|
||||
}
|
||||
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`)
|
||||
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
|
||||
}
|
||||
@@ -409,6 +433,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
|
||||
function validateRuntimeSkill(skill: SkillRegistration): void {
|
||||
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
|
||||
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
|
||||
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`)
|
||||
}
|
||||
|
||||
/** Validate a definition loaded from a provider-controlled parser or remote source. */
|
||||
@@ -416,7 +441,7 @@ function validateDefinition(skill: SkillDefinition): void {
|
||||
const name = skill.name
|
||||
const description = skill.description
|
||||
const whenToUse = skill.whenToUse
|
||||
const disableModelInvocation = skill.disableModelInvocation
|
||||
const invocation = skill.invocation
|
||||
const source = skill.source
|
||||
const provider = skill.provider
|
||||
const content = skill.content
|
||||
@@ -425,9 +450,7 @@ function validateDefinition(skill: SkillDefinition): void {
|
||||
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
|
||||
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
|
||||
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
|
||||
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
|
||||
}
|
||||
validateInvocation(invocation, `loaded skill "${name}"`)
|
||||
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
|
||||
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
|
||||
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
|
||||
@@ -436,18 +459,32 @@ function validateDefinition(skill: SkillDefinition): void {
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
|
||||
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
...invocation !== undefined ? { invocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function validateInvocation(invocation: unknown, subject: string): void {
|
||||
if (invocation === undefined) return
|
||||
if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) {
|
||||
throw new TypeError(`${subject} with a non-object invocation policy`)
|
||||
}
|
||||
const policy = invocation as Record<string, unknown>
|
||||
if (policy.disableModelInvocation !== undefined && typeof policy.disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`${subject} with a non-boolean invocation.disableModelInvocation`)
|
||||
}
|
||||
if (policy.userInvocable !== undefined && typeof policy.userInvocable !== 'boolean') {
|
||||
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)
|
||||
}
|
||||
}
|
||||
|
||||
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return compareCodePoints(left.name, right.name)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
import SkillService, {
|
||||
isModelInvocable,
|
||||
isUserInvocable,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
@@ -107,6 +114,32 @@ describe('SkillService registry', () => {
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
})
|
||||
|
||||
it('returns an invocation-neutral catalog and resolves model and user policy independently', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const registrations = [
|
||||
{ name: 'both', invocation: undefined },
|
||||
{ name: 'model-only', invocation: { userInvocable: false } },
|
||||
{ name: 'user-only', invocation: { disableModelInvocation: true } },
|
||||
{ name: 'trusted-only', invocation: { disableModelInvocation: true, userInvocable: false } },
|
||||
] as const
|
||||
for (const registration of registrations) {
|
||||
ctx.skills.register({
|
||||
name: registration.name,
|
||||
description: registration.name,
|
||||
source: 'runtime',
|
||||
...registration.invocation === undefined ? {} : { invocation: registration.invocation },
|
||||
content: `${registration.name} body.`,
|
||||
})
|
||||
}
|
||||
|
||||
const listed = await ctx.skills.list()
|
||||
expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only'])
|
||||
expect(listed.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.' })
|
||||
})
|
||||
|
||||
it('validates parsed candidate fields', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
@@ -117,7 +150,7 @@ describe('SkillService registry', () => {
|
||||
...memorySkill('bad-candidate', 'placeholder', 1),
|
||||
provider: 'bad-candidate',
|
||||
description: badDescription as unknown as string,
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
invocation: { disableModelInvocation: 'false' as unknown as boolean },
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
@@ -130,11 +163,11 @@ describe('SkillService registry', () => {
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-boolean', 'Bad boolean', 1),
|
||||
provider: 'bad-boolean',
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
invocation: { disableModelInvocation: 'false' as unknown as boolean },
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
|
||||
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.disableModelInvocation')
|
||||
})
|
||||
|
||||
it('rejects non-array provider results and every malformed candidate scalar', async () => {
|
||||
@@ -163,7 +196,7 @@ describe('SkillService registry', () => {
|
||||
name: `candidate-${index}`,
|
||||
description: 'Candidate',
|
||||
whenToUse: 'Use this candidate.',
|
||||
disableModelInvocation: false,
|
||||
invocation: { disableModelInvocation: false, userInvocable: true },
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
@@ -320,11 +353,12 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const locator = { id: 'provider-owned' }
|
||||
const invocation = { disableModelInvocation: false, userInvocable: true }
|
||||
const candidate: SkillCandidate = {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
invocation,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
@@ -337,7 +371,7 @@ describe('SkillService registry', () => {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
invocation,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'definition resources' },
|
||||
@@ -366,6 +400,7 @@ describe('SkillService registry', () => {
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
})])
|
||||
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
|
||||
expect(listed[0]?.invocation).toBe(invocation)
|
||||
expect(listCalls).toBe(1)
|
||||
|
||||
const loaded = await ctx.skills.get('stable-skill')
|
||||
@@ -379,11 +414,12 @@ describe('SkillService registry', () => {
|
||||
await ctx.plugin(SkillService)
|
||||
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
|
||||
const metadata = { owner: 'runtime' }
|
||||
const invocation = { disableModelInvocation: false, userInvocable: true }
|
||||
const registration = {
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
whenToUse: 'When runtime data is needed.',
|
||||
disableModelInvocation: false,
|
||||
invocation,
|
||||
source: 'runtime',
|
||||
resourceBase,
|
||||
metadata,
|
||||
@@ -399,6 +435,7 @@ describe('SkillService registry', () => {
|
||||
const listed = await ctx.skills.list()
|
||||
const loaded = await ctx.skills.get('runtime-skill')
|
||||
expect(listed[0]?.resourceBase).toBe(resourceBase)
|
||||
expect(listed[0]?.invocation).toBe(invocation)
|
||||
expect(loaded?.resourceBase).toBe(resourceBase)
|
||||
expect(loaded?.metadata).toBe(metadata)
|
||||
expect(loaded?.provider).toBe('runtime')
|
||||
@@ -410,7 +447,15 @@ describe('SkillService registry', () => {
|
||||
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
|
||||
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
|
||||
{ patch: { description: '' }, expected: 'requires a description' },
|
||||
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
|
||||
{ patch: { invocation: null as never }, expected: 'non-object invocation policy' },
|
||||
{
|
||||
patch: { invocation: { disableModelInvocation: 'false' as unknown as boolean } },
|
||||
expected: 'invocation.disableModelInvocation',
|
||||
},
|
||||
{
|
||||
patch: { invocation: { userInvocable: 'true' as unknown as boolean } },
|
||||
expected: 'invocation.userInvocable',
|
||||
},
|
||||
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
|
||||
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
|
||||
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
|
||||
@@ -436,7 +481,7 @@ describe('SkillService registry', () => {
|
||||
name: skillName,
|
||||
description: 'Definition',
|
||||
whenToUse: 'Use this definition.',
|
||||
disableModelInvocation: false,
|
||||
invocation: { disableModelInvocation: false, userInvocable: true },
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
content: 'Definition body.',
|
||||
@@ -668,6 +713,13 @@ describe('SkillService registry', () => {
|
||||
await ctx.plugin(SkillService)
|
||||
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
|
||||
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'bad-invocation',
|
||||
description: 'Bad invocation',
|
||||
source: 'runtime',
|
||||
invocation: [] as never,
|
||||
content: 'bad',
|
||||
})).toThrow('non-object invocation policy')
|
||||
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 6b0af04a15bfda985be3e04868f18b9af702eae7
|
||||
README.zh.md: 4ca0ccd7734848d5774e92910e916bdf71c88c13
|
||||
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
|
||||
README.md: 37cda665b74e186d26129c74401b4d8b24c9b7c3
|
||||
README.zh.md: 849e44513c69ed11c311a1437c8a9a4af03a03f0
|
||||
|
||||
@@ -22,7 +22,7 @@ Execution uses the calling agent's `session.header.cwd` so workspace-sensitive p
|
||||
|
||||
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.
|
||||
|
||||
An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results.
|
||||
An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.disableModelInvocation` is `true` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
|
||||
|
||||
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和产物按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。
|
||||
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.disableModelInvocation` 为 `true` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
|
||||
|
||||
该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import {
|
||||
isModelInvocable,
|
||||
isSkillName,
|
||||
type SkillDefinition,
|
||||
type SkillSummary,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
@@ -91,7 +96,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (!skill) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
if (!isModelInvocable(skill)) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return {
|
||||
@@ -123,7 +128,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
catalogLoaded.add(agent.session)
|
||||
return
|
||||
}
|
||||
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
|
||||
const skills = (await ctx.skills.list({ cwd: agent.session.header.cwd, signal }))
|
||||
.filter(isModelInvocable)
|
||||
if (skills.length > 0) {
|
||||
const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
agent.inject({ content: catalog.content, source: { kind: 'plugin', plugin: 'dsh-tool-skill' } })
|
||||
|
||||
@@ -143,6 +143,20 @@ describe('dsh-tool-skill', () => {
|
||||
provider: 'runtime',
|
||||
content: 'A body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill.',
|
||||
invocation: { userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill.',
|
||||
invocation: { disableModelInvocation: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only body.',
|
||||
})
|
||||
ctx.on('agent/step', (agent) => {
|
||||
agent.inject({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } })
|
||||
})
|
||||
@@ -160,6 +174,7 @@ describe('dsh-tool-skill', () => {
|
||||
'',
|
||||
'<available_skills>',
|
||||
'- `a-skill`: Use {{placeholder}} <safely> & carefully.',
|
||||
'- `model-only-skill`: Model-only skill.',
|
||||
'- `z-skill`: Long description Long description Long descript...',
|
||||
'</available_skills>',
|
||||
'',
|
||||
@@ -175,12 +190,20 @@ describe('dsh-tool-skill', () => {
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
expect(rendered).not.toContain('Secret body')
|
||||
expect(rendered).not.toContain('user-only-skill')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('does not inject a catalog when no skills are available', async () => {
|
||||
it('does not inject a catalog when no model-invocable skills are available', async () => {
|
||||
const home = await tempDir('tool-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill',
|
||||
invocation: { disableModelInvocation: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only body.',
|
||||
})
|
||||
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
})
|
||||
@@ -333,16 +356,25 @@ describe('dsh-tool-skill', () => {
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only instructions.',
|
||||
})
|
||||
|
||||
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
expect(modelOnly.isError).toBe(false)
|
||||
const unknownBlock = unknown.content[0]
|
||||
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 528daef773635451ceb198ab3231c2dd87cb9413
|
||||
README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2
|
||||
README.md: 76d7f09d8b69123705f0d30c203b01989afe605c
|
||||
README.zh.md: 61fe34fc09ffea6d81442bcd23c99a340c1e0ac0
|
||||
|
||||
@@ -26,7 +26,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
@@ -130,7 +130,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `userInvocable`, while `disableModelInvocation` does not restrict this surface. A user-disabled skill produces a warning and no user turn.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。
|
||||
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
|
||||
@@ -130,7 +130,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `userInvocable`,`disableModelInvocation` 不限制这个接口。用户禁用的 skill 会产生一条警告,不会产生用户轮次。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ 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 } from '@deepseek-ai/dsh-skill'
|
||||
import {
|
||||
isUserInvocable,
|
||||
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'
|
||||
@@ -969,9 +972,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
// Skill listing is async while `createTuiChat` is synchronous, so the
|
||||
// completions rebuild once the catalog resolves. Disabled-for-model skills
|
||||
// are absent from `list()`, so they never appear as completions; a user can
|
||||
// still invoke one by typing its exact name.
|
||||
// completions rebuild once the invocation-neutral catalog resolves.
|
||||
let skillCommands: SlashCommand[] = []
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
const base = new CombinedAutocompleteProvider(
|
||||
@@ -999,13 +1000,14 @@ export function createTuiChat(
|
||||
const loadSkillCommands = (service: SkillService): void => {
|
||||
service.list({ cwd, signal: skillAbort.signal }).then(
|
||||
(summaries) => {
|
||||
if (disposed || summaries.length === 0) return
|
||||
const invocable = summaries.filter(isUserInvocable)
|
||||
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
|
||||
// instructions placeholder. `SkillSource` is open-ended; every
|
||||
// non-project source (user, custom, bundled, runtime, …) collapses
|
||||
// to `(user)`.
|
||||
skillCommands = summaries.map(skill => ({
|
||||
skillCommands = invocable.map(skill => ({
|
||||
name: `skill:${skill.name}`,
|
||||
description: skill.description,
|
||||
argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)',
|
||||
@@ -1211,6 +1213,10 @@ export function createTuiChat(
|
||||
appendNotice(`Unknown skill: ${name}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!isUserInvocable(skill)) {
|
||||
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
|
||||
return
|
||||
}
|
||||
deliver(renderSkillInvocation(skill, instructions))
|
||||
},
|
||||
(error: unknown) => {
|
||||
|
||||
@@ -3542,10 +3542,30 @@ describe('skill slash command', () => {
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
|
||||
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
|
||||
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
|
||||
skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill',
|
||||
invocation: { disableModelInvocation: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'trusted-only-skill',
|
||||
description: 'Trusted-only skill',
|
||||
invocation: { disableModelInvocation: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Trusted-only instructions body.',
|
||||
})
|
||||
}
|
||||
|
||||
it('labels slash completions by scope and hides model-disabled skills', async () => {
|
||||
it('labels slash completions by scope and applies user invocation policy', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill')
|
||||
await tick()
|
||||
@@ -3553,8 +3573,10 @@ describe('skill slash command', () => {
|
||||
expect(result.terminal.output).toContain('(user)')
|
||||
expect(result.terminal.output).toContain('project-skill')
|
||||
expect(result.terminal.output).toContain('(project)')
|
||||
expect(result.terminal.output).toContain('user-only-skill')
|
||||
expect(result.terminal.output).not.toContain('[instructions]')
|
||||
expect(result.terminal.output).not.toContain('hidden-skill')
|
||||
expect(result.terminal.output).not.toContain('model-only-skill')
|
||||
expect(result.terminal.output).not.toContain('trusted-only-skill')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -3573,12 +3595,26 @@ describe('skill slash command', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('invokes a model-disabled skill by its exact name', async () => {
|
||||
it('invokes a user-only skill by its exact name', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:hidden-skill')
|
||||
result.terminal.send('/skill:user-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="user-only-skill">\nUser-only instructions body.\n</skill>' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rejects exact invocation of skills disabled for users', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:model-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:trusted-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.')
|
||||
expect(result.terminal.output).toContain('Skill "trusted-only-skill" is not available for user invocation.')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user