1
/**2
* Agent skill provider registry.3
*4
* This package owns the Service Definition role of the skill capability seam.5
* Concrete6
* providers such as `@deepseek-ai/dsh-skill-filesystem` decide where skills come7
* from; this service only merges provider catalogs, resolves the winning skill8
* for a name, and exposes the winning summaries and definitions to consumers.9
*10
* @module @deepseek-ai/dsh-skill11
*/13
import { Context, Service } from '@deepseek-ai/cordis'14
import type {} from '@deepseek-ai/dsh-llm'15
import { assertNever } from '@deepseek-ai/dsh-util-values'16
import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'17
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'18
import z from '@deepseek-ai/schemastery'19
import type Schema from '@deepseek-ai/schemastery'21
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/22
const DEFAULT_COLLECT_CACHE_ENTRIES = 12823
const MAX_COLLECT_ATTEMPTS = 224
const RUNTIME_PROVIDER = 'runtime'25
const RUNTIME_RANK = 25027
/** Standard precedence rank for packaged skill providers and local bundled roots. */28
export const BUNDLED_SKILL_RANK = 60030
/**31
* Return whether a string is a valid kebab-case skill name.32
* @param name - candidate skill name to validate.33
* @returns whether the name matches the public skill-name grammar.34
*/35
export function isSkillName(name: string): boolean {36
return SKILL_NAME.test(name)37
}39
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */40
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})42
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */43
export type SkillResourceBase =44
| { readonly kind: 'directory'; readonly path: string }45
| { readonly kind: 'url'; readonly url: string }46
| { readonly kind: 'opaque'; readonly description: string }48
/** Invocation controls shared by skill discovery consumers. */49
export interface SkillInvocationPolicy {50
/** Whether model-facing catalogs and loaders include this skill. */51
readonly modelInvocable: boolean52
/** Whether human-facing command catalogs and loaders include this skill. */53
readonly userInvocable: boolean54
}56
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */57
export interface SkillSummary {58
/** Absolute instruction file path when supplied by the provider; absent for virtual skills. */59
readonly path?: string60
/** Kebab-case identifier used to address the skill. */61
readonly name: string62
/** Short routing description shown by discovery consumers. */63
readonly description: string64
/** Optional extra routing guidance. */65
readonly whenToUse?: string66
/** Resolved model and user invocation controls. */67
readonly invocation: SkillInvocationPolicy68
/** Discovery source that produced this winning skill. */69
readonly source: SkillSource70
/** Provider that owns this skill body. */71
readonly provider: string72
/** Provider-specific base for relative resources. */73
readonly resourceBase?: SkillResourceBase74
}76
/** Provider catalog entry used by the registry to merge and later load skills. */77
export interface SkillCandidate extends SkillSummary {78
/** Lower ranks win duplicate skill names before provider registration order is considered. */79
readonly rank: number80
/** Opaque provider-owned handle passed back to `provider.get()`. */81
readonly locator: unknown82
/** Parsed optional metadata object from provider-specific skill frontmatter. */83
readonly metadata?: Readonly<Record<string, unknown>>84
}86
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */87
export interface SkillDefinition extends SkillSummary {88
/** Markdown instruction body after any provider-specific metadata removal. */89
readonly content: string90
/** Parsed optional metadata object from frontmatter. */91
readonly metadata?: Readonly<Record<string, unknown>>92
}94
/** Runtime skill contribution accepted by `ctx.skills.register()`. */95
export type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {96
/** Invocation controls; omission permits both model and user surfaces. */97
readonly invocation?: SkillInvocationPolicy98
/** Provider label; omission uses the registry-owned runtime provider. */99
readonly provider?: string100
}102
/** Caller context used for cwd-sensitive and abortable provider work. */103
export interface SkillLookupOptions {104
/** Workspace selector for the current lookup. */105
readonly cwd?: string | undefined106
/** Abort discovery or loading work for the current caller. */107
readonly signal?: AbortSignal | undefined108
}110
/**111
* Registry read options: provider lookup context plus the viewing scope.112
* The registry consumes `scope` to select layers; providers receive the same113
* borrowed options object and read only their {@link SkillLookupOptions}114
* contract from it.115
*/116
export interface SkillViewOptions extends SkillLookupOptions {117
/** Viewing scope (the calling agent); omitted reads the global layer alone. */118
readonly scope?: ScopeKey | undefined119
}121
/**122
* Return whether a skill may be advertised to and loaded by a model.123
* @param skill - skill metadata carrying resolved invocation controls.124
* @returns whether the policy permits model invocation.125
*/126
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {127
return skill.invocation.modelInvocable128
}130
/**131
* Return whether a skill may be advertised to and loaded by a human-facing command.132
* @param skill - skill metadata carrying resolved invocation controls.133
* @returns whether the policy permits user invocation.134
*/135
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {136
return skill.invocation.userInvocable137
}139
/**140
* Durable source for the context message a user-explicit skill invocation141
* injects: the user's own words ride a plain user message, and the rendered142
* skill body follows as injected `instructions`-form context carrying this143
* source, so transcript consumers present the injection from metadata144
* instead of re-parsing the model-facing text.145
*/146
export interface SkillInvocationSource {147
readonly kind: 'skill-invocation'148
/** Invoked skill name, validated user-invocable at the injecting boundary. */149
readonly name: string150
/** Injected skill bodies are instructions for the model to follow. */151
readonly form: 'instructions'152
}154
declare module '@deepseek-ai/dsh-llm' {155
interface MessageSourceMap {156
/** A user-explicit skill invocation injected by the host. */157
'skill-invocation': SkillInvocationSource158
}159
}161
/**162
* Render one loaded skill for the model. The output is shared verbatim by the163
* `skill` tool result and the user-explicit invocation injection, so the model164
* sees one canonical `<skill_content>` shape on both paths. The name rides an165
* escaped attribute; the body is embedded verbatim (skills are trusted local166
* content, and user-supplied invocation text stays outside this wrapper).167
* @param skill - name, provider, optional resource base, and body to render.168
* @returns the complete model-facing `<skill_content>` block.169
*/170
export function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {171
const resourceHint = renderResourceHint(skill)172
return [173
`<skill_content name="${escapeAttr(skill.name)}">`,174
'<skill_resources>',175
...resourceHint,176
'</skill_resources>',177
'',178
'<skill_instructions>',179
skill.content,180
'</skill_instructions>',181
'</skill_content>',182
].join('\n')183
}185
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {186
const base = skill.resourceBase187
if (base === undefined) {188
return [189
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,190
'Load referenced resources only as needed.',191
]192
}193
switch (base.kind) {194
case 'directory':195
return [196
`Base directory for this skill: ${escapeText(base.path)}`,197
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',198
]199
case 'url':200
return [201
`Base URL for this skill: ${escapeText(base.url)}`,202
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',203
]204
case 'opaque':205
return [206
`Resources for this skill: ${escapeText(base.description)}`,207
'Load referenced resources only as needed.',208
]209
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */210
default:211
return assertNever(base, 'SkillResourceBase.kind')212
/* v8 ignore stop */213
}214
}216
function escapeAttr(value: string): string {217
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')218
}220
/**221
* Escape model-facing prose embedded inside skill markup so provider-supplied222
* text cannot open or close framing tags.223
* @param value - raw prose to embed.224
* @returns the escaped text.225
*/226
export function escapeText(value: string): string {227
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')228
}230
/** One catalog observation plus whether discovery completed within a stable catalog revision. */231
export interface SkillCatalogSnapshot {232
/** Sorted invocation-neutral summaries collected in this observation. */233
readonly skills: SkillSummary[]234
/** Whether every registered provider completed without a concurrent catalog revision. */235
readonly complete: boolean236
}238
/** Provider candidates plus whether the current discovery is authoritative. */239
export interface SkillProviderObservation {240
/** Candidates available from the current provider discovery. */241
readonly candidates: readonly SkillCandidate[]242
/** Whether discovery completed and these candidates may be cached. */243
readonly complete: boolean244
}246
/** Provider interface for one source of skills, such as local directories or a remote registry. */247
export interface SkillProvider {248
/** Unique provider name in the `ctx.skills` registry. */249
readonly name: string250
/**251
* List available skill candidates for the current lookup context. Provider252
* plugins register synchronously during `apply()`; remote initialization,253
* authentication, and discovery are awaited inside this method. Implementations254
* should settle promptly when `options.signal` aborts.255
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.256
* @returns provider candidates as a complete-array shorthand, or an explicit257
* observation when usable candidates came from incomplete discovery.258
*/259
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>260
/**261
* Load a complete skill body for a previously listed candidate.262
* @param candidate - the winning candidate originally returned by this provider.263
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.264
* @returns the full skill body, or `undefined` if it is no longer loadable.265
*/266
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>267
}269
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */270
export interface SkillProviderControl {271
/** Aborts if registration fails or when the exact provider registration is disposed. */272
readonly signal: AbortSignal273
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */274
readonly invalidate: () => void275
}277
/** Skill registry configuration. */278
export interface Config {279
/** Maximum number of completed cwd/provider catalogs kept in memory. */280
readonly collectCacheMaxEntries?: number281
}283
declare module '@deepseek-ai/cordis' {284
interface Context {285
skills: SkillRegistry286
}288
interface Events {289
/**290
* A skill provider, runtime contribution, or provider-backed catalog may291
* have changed. This is an unfiltered invalidation notification; consumers292
* refetch the catalog for their own lookup options. Listener failures are293
* contained and cannot veto the registry mutation.294
* @mode emit295
*/296
'skills/change'(): void297
}298
}300
interface IndexedCandidate {301
candidate: SkillCandidate302
provider: SkillProvider303
providerOrder: number304
localOrder: number305
/** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */306
layer: SkillLayer307
}309
/** One provider registration retained by its layer. */310
interface RegisteredProvider {311
provider: SkillProvider312
/** Service-wide monotonic registration order, the within-layer rank tiebreak. */313
order: number314
}316
interface LayerCollectResult {317
entries: IndexedCandidate[]318
cacheable: boolean319
}321
interface CollectResult {322
entries: Map<string, IndexedCandidate>323
cacheable: boolean324
}326
/** One scope's complete skill-registry contribution. */327
class SkillLayer implements ScopeLayer {328
/** Providers registered through contexts carrying this scope, insertion-ordered. */329
readonly providers: NamedEntries<RegisteredProvider>330
/** Runtime skills registered through contexts carrying this scope. */331
readonly runtime = new Map<string, SkillDefinition>()333
constructor(scope: ScopeKey | undefined) {334
this.providers = new NamedEntries(name => new Error(scope === undefined335
? `a skill provider named "${name}" is already registered`336
: `a skill provider named "${name}" is already registered in this scope`))337
}339
/** Whether every contribution table in this aggregate layer is empty. */340
isEmpty(): boolean {341
return this.providers.isEmpty() && this.runtime.size === 0342
}343
}345
/**346
* Layered registry of skill providers, the host+per-scope shape the tools347
* registry established. A registration files into the layer of its calling348
* context's scope ({@link scopeOf}): host rows and repository plugins land in349
* the global layer, while a plugin mounted by an agent preset's standing350
* composition lands in that preset's layer. A read merges the global layer351
* with the viewing scope's chain — the nearest layer's entry wins a duplicate352
* name outright, and the rank order decides duplicates only within one layer.353
* It exposes sorted invocation-neutral summaries and loads full skill bodies354
* on demand.355
*/356
export class SkillRegistry extends Service {357
static Config: Schema<Config> = z.object({358
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),359
})361
private readonly collectCacheMaxEntries: number362
private readonly layers = new ScopedLayers<SkillLayer>(363
scope => new SkillLayer(scope),364
() => { this.invalidateCache() },365
)366
private readonly collectCache = new Map<string, Map<string, IndexedCandidate>>()367
private revision = 0368
private nextProviderOrder = 0369
/** Stable identities for cache keys; scope keys are opaque identity-compared objects. */370
private readonly scopeIds = new WeakMap<ScopeKey, number>()371
private nextScopeId = 1373
constructor(ctx: Context, config: Config = {}) {374
super(ctx, 'skills')375
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES376
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)377
}379
/**380
* Register a borrowed same-process provider synchronously during plugin381
* apply, into the calling context's layer: a scoped context (an agent382
* preset's standing mount) registers for that scope alone, an unscoped383
* context registers globally. Duplicate names within one layer and reserved384
* names throw; remote initialization belongs in `list()`. Fiber disposal385
* unregisters the provider and invalidates catalog caches.386
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.387
* @returns the exact Cordis effect disposer that unregisters this provider;388
* composite effects may yield it directly to preserve teardown ordering.389
*/390
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {391
const lifecycle = new AbortController()392
let registration: { layer: SkillLayer; name: string } | undefined393
let provider: SkillProvider394
const control: SkillProviderControl = {395
signal: lifecycle.signal,396
invalidate: () => {397
const active = registration398
if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) {399
this.invalidateCache()400
}401
},402
}403
try {404
provider = create(control)405
const name = provider.name406
if (name === RUNTIME_PROVIDER) {407
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)408
}409
const order = this.nextProviderOrder410
this.nextProviderOrder += 1411
return this.layers.effect(412
this.ctx,413
(layer) => {414
const undo = layer.providers.insert(name, { provider, order })415
registration = { layer, name }416
return () => {417
registration = undefined418
undo()419
lifecycle.abort(new Error(`skill provider "${name}" disposed`))420
}421
},422
{ label: 'skills.registerProvider()' },423
)424
} catch (error) {425
lifecycle.abort(error)426
throw error427
}428
}430
/**431
* Register a borrowed readonly runtime skill into the calling context's432
* layer. Project entries outrank runtime entries, which outrank user433
* entries, within one layer. Same-name runtime entries in one layer are434
* first-wins; a duplicate logs a warning and receives a no-op disposer so435
* it cannot remove the winner.436
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.437
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.438
*/439
register(skill: SkillRegistration): () => void {440
validateRuntimeSkill(skill)441
const scope = scopeOf(this.ctx)442
const existingLayer = scope === undefined ? this.layers.global : this.layers.peek(scope)443
if (existingLayer !== undefined && existingLayer.runtime.has(skill.name)) {444
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)445
return () => {}446
}447
const definition: SkillDefinition = {448
...skill,449
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },450
provider: skill.provider ?? RUNTIME_PROVIDER,451
}452
return this.layers.effect(453
this.ctx,454
(layer) => {455
layer.runtime.set(definition.name, definition)456
return () => { layer.runtime.delete(definition.name) }457
},458
{ label: 'skills.register()' },459
)460
}462
/**463
* List invocation-neutral skill summaries for a workspace. Consumers apply464
* model or user invocation policy at their operational boundary. Lookup465
* options and provider candidates are readonly same-process values borrowed466
* throughout discovery.467
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.468
* @returns all sorted winning summaries.469
*/470
async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> {471
return (await this.snapshot(options)).skills472
}474
/**475
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.476
* Incomplete observations are never cached, allowing consumers to retain last-good state and477
* retry on their next request boundary.478
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.479
* @returns sorted summaries plus discovery-completeness state.480
*/481
async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> {482
const collected = await this.collect(options)483
return {484
skills: [...collected.entries.values()]485
.map(entry => toSummary(entry.candidate))486
.sort(compareSkillSummary),487
complete: collected.cacheable,488
}489
}491
/**492
* Load and validate the winning candidate, passing its opaque discovery locator back to the493
* provider. Cancellation is rechecked after selection, including cache hits, and raced against494
* loading so an uncooperative provider cannot hang the caller.495
* @param name - kebab-case skill name.496
* @param options - view options; `scope` selects the viewing agent's layers,497
* `cwd` selects workspace-sensitive skills, and `signal` cancels work.498
* @returns the full skill, including body content, or `undefined`.499
*/500
async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> {501
if (!isSkillName(name)) return undefined502
const collected = await this.collect(options)503
throwIfAborted(options.signal)504
const match = collected.entries.get(name)505
if (match === undefined) return undefined506
const definition = await waitWithAbort(507
match.provider.get(match.candidate, options),508
options.signal,509
)510
if (definition === undefined) return undefined511
validateDefinition(definition)512
if (definition.name !== match.candidate.name) {513
this.invalidateEntry(match)514
return undefined515
}516
return definition517
}519
private async collect(options: SkillViewOptions): Promise<CollectResult> {520
throwIfAborted(options.signal)521
let attempt = 1522
while (true) {523
const revision = this.revision524
// The chain is part of the key rather than assumed stable: a blank-session525
// recompose re-parents an existing scope without touching this registry,526
// and only a chain-bearing key makes the next read see the new preset.527
const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision)528
const cached = this.collectCache.get(key)529
if (cached !== undefined) return { entries: cached, cacheable: true }531
const result = await this.collectFresh(options)532
throwIfAborted(options.signal)533
if (revision !== this.revision) {534
if (attempt < MAX_COLLECT_ATTEMPTS) {535
attempt += 1536
continue537
}538
return { entries: result.entries, cacheable: false }539
}540
if (result.cacheable) {541
this.collectCache.set(key, result.entries)542
if (this.collectCache.size > this.collectCacheMaxEntries) {543
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>544
this.collectCache.delete(oldest.value)545
}546
}547
return result548
}549
}551
private async collectFresh(options: SkillViewOptions): Promise<CollectResult> {552
// Global first, then existing chain overlays farthest ancestor first and553
// the exact scope last, so the nearest layer's same-name entry replaces554
// the farther ones — the tools registry's shadowing rule. Rank decides555
// duplicates only within one layer.556
const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)]557
const merged = new Map<string, IndexedCandidate>()558
let cacheable = true559
for (const layer of layers) {560
const collected = await this.collectLayer(layer, options)561
if (!collected.cacheable) cacheable = false562
for (const entry of collected.entries) merged.set(entry.candidate.name, entry)563
}564
return { entries: merged, cacheable }565
}567
private async collectLayer(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {568
const collected = await this.listLayerCandidates(layer, options)569
collected.entries.sort(compareIndexedCandidates)570
const seen = new Set<string>()571
const result: IndexedCandidate[] = []572
for (const entry of collected.entries) {573
const skill = entry.candidate574
if (seen.has(skill.name)) {575
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)576
continue577
}578
seen.add(skill.name)579
result.push(entry)580
}581
return { entries: result, cacheable: collected.cacheable }582
}584
private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {585
throwIfAborted(options.signal)586
const candidates: IndexedCandidate[] = []587
let cacheable = true588
let runtimeOrder = 0589
for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {590
candidates.push({591
candidate: runtimeCandidate(skill),592
provider: RUNTIME_SKILL_PROVIDER,593
providerOrder: -1,594
localOrder: runtimeOrder,595
layer,596
})597
runtimeOrder += 1598
}599
for (const { provider, order } of [...layer.providers.values()]) {600
let localOrder = 0601
let output: unknown602
try {603
output = await waitWithAbort(provider.list(options), options.signal)604
} catch (error) {605
if (options.signal?.aborted === true) throw toError(options.signal.reason)606
cacheable = false607
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)608
}609
if (output === undefined) continue610
const observation = normalizeProviderObservation(output, provider.name)611
if (!observation.complete) cacheable = false612
for (const candidate of observation.candidates) {613
validateCandidate(candidate, provider.name)614
candidates.push({ candidate, provider, providerOrder: order, localOrder, layer })615
localOrder += 1616
}617
}618
return { entries: candidates, cacheable }619
}621
private invalidateCache(): void {622
this.revision += 1623
this.collectCache.clear()624
this.notifyChange()625
}627
/** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */628
private invalidateEntry(entry: IndexedCandidate): void {629
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */630
if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache()631
}633
private scopeId(key: ScopeKey): number {634
let id = this.scopeIds.get(key)635
if (id === undefined) {636
id = this.nextScopeId637
this.nextScopeId += 1638
this.scopeIds.set(key, id)639
}640
return id641
}643
private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string {644
return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision })645
}647
/** Notify catalog observers without making their refresh work load-bearing. */648
private notifyChange(): void {649
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {650
try {651
const returned: unknown = callback()652
void Promise.resolve(returned).catch((error: unknown) => {653
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`)654
})655
} catch (error: unknown) {656
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`)657
}658
}659
}660
}662
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {663
if (Array.isArray(output)) {664
return { candidates: output as readonly SkillCandidate[], complete: true }665
}666
if (output === null || typeof output !== 'object') {667
throw invalidProviderObservation(providerName)668
}669
const observation = output as Partial<SkillProviderObservation>670
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {671
throw invalidProviderObservation(providerName)672
}673
return observation as SkillProviderObservation674
}676
function invalidProviderObservation(providerName: string): TypeError {677
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)678
}680
const RUNTIME_SKILL_PROVIDER: SkillProvider = {681
name: RUNTIME_PROVIDER,682
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */683
list() {684
return Promise.resolve([])685
},686
get(candidate) {687
return Promise.resolve(candidate.locator as SkillDefinition)688
},689
}691
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {692
return {693
name: skill.name,694
description: skill.description,695
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},696
invocation: skill.invocation,697
source: skill.source,698
provider: skill.provider,699
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},700
rank: RUNTIME_RANK,701
locator: skill,702
...skill.path !== undefined ? { path: skill.path } : {},703
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},704
}705
}707
function validateCandidate(candidate: SkillCandidate, providerName: string): void {708
if (typeof candidate.name !== 'string') {709
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)710
}711
if (!SKILL_NAME.test(candidate.name)) {712
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)713
}714
if (typeof candidate.description !== 'string') {715
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)716
}717
if (candidate.description.length === 0) {718
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)719
}720
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`)721
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {722
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)723
}724
if (typeof candidate.source !== 'string') {725
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)726
}727
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {728
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)729
}730
if (typeof candidate.provider !== 'string') {731
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)732
}733
if (candidate.provider !== providerName) {734
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)735
}736
if (candidate.path !== undefined && typeof candidate.path !== 'string') {737
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)738
}739
}741
function validateRuntimeSkill(skill: SkillRegistration): void {742
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)743
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)744
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`)745
}747
/** Validate a definition loaded from a provider-controlled parser or remote source. */748
function validateDefinition(skill: SkillDefinition): void {749
const name = skill.name750
const description = skill.description751
const whenToUse = skill.whenToUse752
const invocation = skill.invocation753
const source = skill.source754
const provider = skill.provider755
const content = skill.content756
const path = skill.path757
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')758
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)759
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)760
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)761
validateInvocation(invocation, `loaded skill "${name}"`)762
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)763
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)764
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)765
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)766
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)767
}769
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {770
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill771
return {772
name,773
...skill.path === undefined ? {} : { path: skill.path },774
description,775
...whenToUse !== undefined ? { whenToUse } : {},776
invocation,777
source,778
provider,779
...resourceBase !== undefined ? { resourceBase } : {},780
}781
}783
function validateInvocation(invocation: unknown, subject: string): void {784
if (invocation === undefined) return785
if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) {786
throw new TypeError(`${subject} with a non-object invocation policy`)787
}788
const policy = invocation as Record<string, unknown>789
if (typeof policy.modelInvocable !== 'boolean') {790
throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`)791
}792
if (typeof policy.userInvocable !== 'boolean') {793
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)794
}795
}797
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {798
return compareCodePoints(left.name, right.name)799
}801
function compareCodePoints(left: string, right: string): number {802
if (left < right) return -1803
if (left > right) return 1804
return 0805
}807
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {808
return left.candidate.rank - right.candidate.rank809
|| left.providerOrder - right.providerOrder810
|| left.localOrder - right.localOrder811
}813
function assertPositiveInteger(name: string, value: number, minimum = 1): void {814
if (!Number.isInteger(value) || value < minimum) {815
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)816
}817
}819
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {820
if (signal === undefined) return promise821
throwIfAborted(signal)822
return new Promise<T>((resolve, reject) => {823
const cleanup = (): void => {824
signal.removeEventListener('abort', onAbort)825
}826
const onAbort = (): void => {827
cleanup()828
reject(toError(signal.reason))829
}830
signal.addEventListener('abort', onAbort, { once: true })831
void promise.then(832
(value) => {833
cleanup()834
resolve(value)835
},836
(error: unknown) => {837
cleanup()838
reject(toError(error))839
},840
)841
})842
}844
/** Throw a total Error for an already-aborted lookup. */845
function throwIfAborted(signal: AbortSignal | undefined): void {846
if (signal?.aborted === true) throw toError(signal.reason)847
}849
/** Normalize an arbitrary abort or provider failure without trusting coercion. */850
function toError(error: unknown): Error {851
try {852
if (error instanceof Error) return error853
} catch {854
// A hostile proxy may throw during instanceof; fall through to the total renderer.855
}856
return new Error(errorMessage(error))857
}859
/** Render an arbitrary provider failure without letting coercion escape containment. */860
function errorMessage(error: unknown): string {861
try {862
return String(error)863
} catch {864
return '[unrenderable thrown value]'865
}866
}868
export default SkillRegistry