Merge remote-tracking branch 'origin/master' into worktree/pr823-retarget-latest-20260729

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/skills.i18n.yaml
#	docs/core-data-structures/skills.md
#	docs/core-data-structures/skills.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/skill/skill-local/README.i18n.yaml
#	packages/skill/skill/README.i18n.yaml
#	packages/skill/skill/README.md
#	packages/skill/skill/README.zh.md
#	packages/skill/skill/src/index.ts
#	packages/skill/skill/tests/skill.spec.ts
#	packages/skill/tool-skill/README.i18n.yaml
#	packages/skill/tool-skill/src/index.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-29 23:36:49 +08:00
205 changed files with 5429 additions and 840 deletions

View File

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

View File

@@ -10,11 +10,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.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.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Events
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
### Config
| Field | Default | Meaning |
@@ -36,11 +41,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
## Provider Contract
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
Contract violations fail fast. A rejected `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
## Runtime Skills
@@ -48,19 +55,19 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou
## Consumer boundary
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide durable session catalogs and the `skill` tool, so providers remain independent of the model surface.
## Model Experience
Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results.
Indirectly, through `dsh-tool-skill`, which renders provider summaries into durable initial or replacement catalog messages and loaded instructions into retained tool results.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
No direct prompt effect. The named consumer owns the durable initial catalog and append-only replacements after invalidation.
## Known Limitations and Deferred Work
- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload.
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract.
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.

View File

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

View File

@@ -15,6 +15,7 @@ import type Schema from 'schemastery'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
const MAX_COLLECT_ATTEMPTS = 2
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
@@ -118,6 +119,22 @@ export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolea
return skill.invocation.userInvocable
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted invocation-neutral summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed without a concurrent catalog revision. */
readonly complete: boolean
}
/** Provider candidates plus whether the current discovery is authoritative. */
export interface SkillProviderObservation {
/** Candidates available from the current provider discovery. */
readonly candidates: readonly SkillCandidate[]
/** Whether discovery completed and these candidates may be cached. */
readonly complete: boolean
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
@@ -128,9 +145,10 @@ export interface SkillProvider {
* authentication, and discovery are awaited inside this method. Implementations
* should settle promptly when `options.signal` aborts.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
* @returns provider candidates as a complete-array shorthand, or an explicit
* observation when usable candidates came from incomplete discovery.
*/
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
@@ -140,6 +158,14 @@ export interface SkillProvider {
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
export interface SkillProviderControl {
/** Aborts if registration fails or when the exact provider registration is disposed. */
readonly signal: AbortSignal
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
readonly invalidate: () => void
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalogs kept in memory. */
@@ -150,6 +176,17 @@ declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider, runtime contribution, or provider-backed catalog may
* have changed. This is an unfiltered invalidation notification; consumers
* refetch the catalog for their own lookup options. Listener failures are
* contained and cannot veto the registry mutation.
* @mode emit
*/
'skills/change'(): void
}
}
interface IndexedCandidate {
@@ -166,7 +203,7 @@ interface CollectResult {
/**
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted model-visible summaries, and
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
* loads full skill bodies on demand.
*/
export class SkillService extends Service {
@@ -192,32 +229,50 @@ export class SkillService extends Service {
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
* the provider and invalidates catalog caches.
* @param provider - the provider to register by `provider.name`.
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(provider: SkillProvider): () => void {
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
const lifecycle = new AbortController()
let active = false
let provider: SkillProvider
const control: SkillProviderControl = {
signal: lifecycle.signal,
invalidate: () => {
if (active) this.invalidateProvider(provider)
},
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
providers.delete(name)
invalidateCache()
try {
provider = create(control)
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
active = true
providers.set(name, { provider, order })
invalidateCache()
yield () => {
active = false
providers.delete(name)
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
invalidateCache()
}
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
throw error
}
}
/**
@@ -265,10 +320,25 @@ export class SkillService extends Service {
* @returns all sorted winning summaries.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.map(toSummary)
.sort(compareSkillSummary)
return (await this.snapshot(options)).skills
}
/**
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
* Incomplete observations are never cached, allowing consumers to retain last-good state and
* retry on their next request boundary.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries plus discovery-completeness state.
*/
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
const collected = await this.collect(options)
return {
skills: collected.entries
.map(entry => entry.candidate)
.map(toSummary)
.sort(compareSkillSummary),
complete: collected.cacheable,
}
}
/**
@@ -283,7 +353,7 @@ export class SkillService extends Service {
if (!isSkillName(name)) return undefined
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.find(entry => entry.candidate.name === name)
const match = collected.entries.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
const definition = await waitWithAbort(
match.provider.get(match.candidate, options),
@@ -291,21 +361,32 @@ export class SkillService extends Service {
)
if (definition === undefined) return undefined
validateDefinition(definition)
if (definition.name !== match.candidate.name) {
this.invalidateProvider(match.provider)
return undefined
}
return definition
}
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
throwIfAborted(options.signal)
let attempt = 1
while (true) {
const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision
const key = collectCacheKey(options, providerRevision, runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return cached
if (cached !== undefined) return { entries: cached, cacheable: true }
const result = await this.collectFresh(options)
throwIfAborted(options.signal)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
if (attempt < MAX_COLLECT_ATTEMPTS) {
attempt += 1
continue
}
return { entries: result.entries, cacheable: false }
}
if (result.cacheable) {
this.collectCache.set(key, result.entries)
if (this.collectCache.size > this.collectCacheMaxEntries) {
@@ -313,7 +394,7 @@ export class SkillService extends Service {
this.collectCache.delete(oldest.value)
}
}
return result.entries
return result
}
}
@@ -359,11 +440,9 @@ export class SkillService extends Service {
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (output === undefined) continue
if (!Array.isArray(output)) {
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
}
const listed = output as readonly SkillCandidate[]
for (const candidate of listed) {
const observation = normalizeProviderObservation(output, provider.name)
if (!observation.complete) cacheable = false
for (const candidate of observation.candidates) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
@@ -375,7 +454,45 @@ export class SkillService extends Service {
private invalidateCache(): void {
this.providerRevision += 1
this.collectCache.clear()
this.notifyChange()
}
private invalidateProvider(provider: SkillProvider): void {
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
}
/** Notify catalog observers without making their refresh work load-bearing. */
private notifyChange(): void {
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
try {
const returned: unknown = callback()
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`)
}
}
}
}
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
if (Array.isArray(output)) {
return { candidates: output as readonly SkillCandidate[], complete: true }
}
if (output === null || typeof output !== 'object') {
throw invalidProviderObservation(providerName)
}
const observation = output as Partial<SkillProviderObservation>
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
throw invalidProviderObservation(providerName)
}
return observation as SkillProviderObservation
}
function invalidProviderObservation(providerName: string): TypeError {
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
}
const RUNTIME_SKILL_PROVIDER: SkillProvider = {

View File

@@ -8,6 +8,7 @@ import SkillService, {
type SkillInvocationPolicy,
type SkillLookupOptions,
type SkillProvider,
type SkillProviderObservation,
} from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
@@ -43,6 +44,10 @@ class MemoryProvider implements SkillProvider {
}
}
function registerProvider(ctx: Context, provider: SkillProvider): () => void {
return ctx.skills.registerProvider(() => provider)
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
@@ -69,8 +74,8 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
const disposeMemory = ctx.skills.registerProvider(provider)
ctx.skills.registerProvider(overrideProvider)
const disposeMemory = registerProvider(ctx, provider)
registerProvider(ctx, overrideProvider)
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
['a-skill', 'A skill', 'memory'],
@@ -95,24 +100,52 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
ctx.skills.registerProvider(sameRankProvider)
registerProvider(ctx, sameRankProvider)
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
await expect(ctx.plugin({
name: 'duplicate-memory',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(new MemoryProvider([]))
registerProvider(pluginCtx, new MemoryProvider([]))
},
})).rejects.toThrow('already registered')
expect(() => ctx.skills.registerProvider({
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
let rejectedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
rejectedSignal = control.signal
return {
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
}
})).toThrow('reserved')
expect(rejectedSignal?.aborted).toBe(true)
const factoryFailure = new Error('factory failed')
let failedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
failedSignal = control.signal
throw factoryFailure
})).toThrow(factoryFailure)
expect(failedSignal?.reason).toBe(factoryFailure)
const effectContext = new Context()
const effectService = new SkillService(effectContext)
const effectFailure = new Error('effect registration failed')
vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure })
let effectSignal: AbortSignal | undefined
expect(() => effectService.registerProvider((control) => {
effectSignal = control.signal
return {
name: 'effect-provider',
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
}
})).toThrow(effectFailure)
expect(effectSignal?.reason).toBe(effectFailure)
disposeMemory()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
@@ -150,7 +183,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad-candidate',
list: () => Promise.resolve([{
...memorySkill('bad-candidate', 'placeholder', 1),
@@ -164,7 +197,7 @@ describe('SkillService registry', () => {
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
badBoolean.skills.registerProvider({
registerProvider(badBoolean, {
name: 'bad-boolean',
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
@@ -176,15 +209,18 @@ describe('SkillService registry', () => {
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
badList.skills.registerProvider({
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
it('rejects malformed provider results and every malformed candidate scalar', async () => {
const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }]
for (const [index, output] of malformedOutputs.entries()) {
const badList = new Context()
await badList.plugin(SkillService)
registerProvider(badList, {
name: `malformed-list-${index}`,
list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation')
}
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
@@ -210,7 +246,7 @@ describe('SkillService registry', () => {
path: '/skills/candidate/SKILL.md',
...patch,
} as SkillCandidate
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([candidate]),
get: () => Promise.resolve(undefined),
@@ -235,7 +271,7 @@ describe('SkillService registry', () => {
rank: 1,
locator: 'skill-a',
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'contextual',
async list(received) {
listedWith = received
@@ -258,7 +294,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'cached',
async list() {
return [{
@@ -309,7 +345,7 @@ describe('SkillService registry', () => {
})
}
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'held',
async list() {
return [{
@@ -391,7 +427,7 @@ describe('SkillService registry', () => {
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'detached',
async list() {
listCalls += 1
@@ -485,7 +521,7 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const providerName = `definition-provider-${index}`
const skillName = `definition-${index}`
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([{
name: skillName,
@@ -519,7 +555,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad',
async list() {
return [memorySkill('Bad_Name', 'bad', 1)]
@@ -538,7 +574,7 @@ describe('SkillService registry', () => {
for (const candidate of invalidCandidates) {
const invalid = new Context()
await invalid.plugin(SkillService)
invalid.skills.registerProvider({
registerProvider(invalid, {
name: candidate.name,
async list() {
return [candidate]
@@ -556,7 +592,7 @@ describe('SkillService registry', () => {
it('sorts model-visible summaries without locale-sensitive collation', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider(new MemoryProvider([
registerProvider(ctx, new MemoryProvider([
memorySkill('z-skill', 'Z skill', 10),
memorySkill('a-skill', 'A skill', 10),
]))
@@ -581,7 +617,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
ctx.skills.registerProvider(provider)
registerProvider(ctx, provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
provider.replace([memorySkill('second-skill', 'Second', 10)])
@@ -608,7 +644,7 @@ describe('SkillService registry', () => {
let fail = true
let flakyCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'flaky',
async list() {
flakyCalls += 1
@@ -619,7 +655,9 @@ describe('SkillService registry', () => {
return undefined
},
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
const incomplete = await ctx.skills.snapshot()
expect(incomplete.skills.map(skill => skill.name)).toEqual(['second-skill'])
expect(incomplete.complete).toBe(false)
expect(flakyCalls).toBe(1)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(2)
@@ -630,6 +668,231 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3)
})
it('keeps candidates from incomplete provider observations loadable without caching them', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
registerProvider(ctx, {
name: 'incomplete-candidates',
async list() {
listCalls += 1
return {
candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }],
complete: false,
}
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'available-skill' }],
complete: false,
})
expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill'])
expect(listCalls).toBe(3)
})
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
let invalidate = (): void => {}
let signal: AbortSignal | undefined
const dispose = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
signal = control.signal
return provider
})
expect((await ctx.skills.snapshot()).complete).toBe(true)
provider.replace([memorySkill('second-skill', 'Second', 10)])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
invalidate()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
dispose()
expect(signal?.aborted).toBe(true)
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
registerProvider(ctx, replacement)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
invalidate()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
expect(replacement.listCalls).toBe(1)
})
it('emits catalog invalidations for live provider and runtime mutations', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([memorySkill('provider-skill', 'Provider', 10)])
let changes = 0
ctx.on('skills/change', () => { changes += 1 })
let invalidate = (): void => {}
const disposeProvider = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
expect(changes).toBe(1)
invalidate()
expect(changes).toBe(2)
const disposeRuntime = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime',
source: 'runtime',
content: 'Runtime body.',
})
expect(changes).toBe(3)
disposeRuntime()
expect(changes).toBe(4)
disposeProvider()
expect(changes).toBe(5)
invalidate()
expect(changes).toBe(5)
})
it('contains synchronous and asynchronous catalog observer failures', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment
const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected')))
let observed = 0
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
const provider = new MemoryProvider([])
expect(() => registerProvider(ctx, provider)).not.toThrow()
await Promise.resolve()
expect(observed).toBe(1)
expect(warnings).toEqual([
'skills/change listener threw: Error: observer threw',
'skills/change listener rejected: Error: observer rejected',
])
disposeThrowing()
disposeRejecting()
disposeObserver()
})
it('retries an in-flight catalog invalidated by its provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let release: (() => void) | undefined
const started = Promise.withResolvers<undefined>()
const gate = new Promise<void>((resolve) => { release = resolve })
const provider = new MemoryProvider([memorySkill('stale-skill', 'Stale', 10)])
const originalList = provider.list.bind(provider)
provider.list = async (options) => {
if (provider.listCalls === 0) {
provider.listCalls += 1
started.resolve(undefined)
await gate
return [memorySkill('stale-skill', 'Stale', 10)]
}
return await originalList(options)
}
let invalidate = (): void => {}
ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
const pending = ctx.skills.list()
await started.promise
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
invalidate()
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
expect(provider.listCalls).toBe(2)
})
it('bounds repeated in-flight invalidation and leaves the result uncached', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
ctx.skills.registerProvider(control => ({
name: 'self-invalidating',
async list() {
listCalls += 1
control.invalidate()
return [{
...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10),
provider: 'self-invalidating',
}]
},
async get() {
return undefined
},
}))
expect(await ctx.skills.snapshot()).toEqual({
skills: [{
name: 'bounded-skill',
description: 'Attempt 2',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'self-invalidating',
source: 'memory',
}],
complete: false,
})
expect(listCalls).toBe(2)
expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4')
expect(listCalls).toBe(4)
})
it('invalidates a provider whose loaded definition changed identity', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
const provider: SkillProvider = {
name: 'renamed',
async list() {
listCalls += 1
return [{
name: 'old-name',
description: 'Old name',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'renamed',
source: 'test',
rank: 1,
locator: 'old-name',
}]
},
async get(candidate) {
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
},
}
registerProvider(ctx, provider)
expect(await ctx.skills.get('old-name')).toBeUndefined()
await ctx.skills.list()
expect(listCalls).toBe(2)
})
it('returns undefined when a discovered candidate disappears before loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
registerProvider(ctx, {
name: 'vanished-body',
async list() {
return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }]
},
async get() {
return undefined
},
})
await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined()
})
it('contains a provider rejection whose string coercion throws', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
@@ -640,7 +903,7 @@ describe('SkillService registry', () => {
throw new Error('provider failure coercion failed')
},
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
@@ -665,7 +928,7 @@ describe('SkillService registry', () => {
let release: (() => void) | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const dispose = ctx.skills.registerProvider({
const dispose = registerProvider(ctx, {
name: 'delayed',
async list() {
markStarted?.()
@@ -695,7 +958,7 @@ describe('SkillService registry', () => {
const held = new Promise<SkillCandidate[]>((resolve) => {
release = () => { resolve([]) }
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'uncooperative',
list(options) {
seenSignal = options.signal