返回源码地图

packages/skill/skill/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Agent skill provider registry.
3 *
4 * This package owns the Service Definition role of the skill capability seam.
5 * Concrete
6 * providers such as `@deepseek-ai/dsh-skill-filesystem` decide where skills come
7 * from; this service only merges provider catalogs, resolves the winning skill
8 * for a name, and exposes the winning summaries and definitions to consumers.
9 *
10 * @module @deepseek-ai/dsh-skill
11 */
12
13import { Context, Service } from '@deepseek-ai/cordis'
14import type {} from '@deepseek-ai/dsh-llm'
15import { assertNever } from '@deepseek-ai/dsh-util-values'
16import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
17import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
18import z from '@deepseek-ai/schemastery'
19import type Schema from '@deepseek-ai/schemastery'
20
21const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
22const DEFAULT_COLLECT_CACHE_ENTRIES = 128
23const MAX_COLLECT_ATTEMPTS = 2
24const RUNTIME_PROVIDER = 'runtime'
25const RUNTIME_RANK = 250
26
27/** Standard precedence rank for packaged skill providers and local bundled roots. */
28export const BUNDLED_SKILL_RANK = 600
29
30/**
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 */
35export function isSkillName(name: string): boolean {
36 return SKILL_NAME.test(name)
37}
38
39/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
40export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
41
42/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
43export type SkillResourceBase =
44 | { readonly kind: 'directory'; readonly path: string }
45 | { readonly kind: 'url'; readonly url: string }
46 | { readonly kind: 'opaque'; readonly description: string }
47
48/** Invocation controls shared by skill discovery consumers. */
49export interface SkillInvocationPolicy {
50 /** Whether model-facing catalogs and loaders include this skill. */
51 readonly modelInvocable: boolean
52 /** Whether human-facing command catalogs and loaders include this skill. */
53 readonly userInvocable: boolean
54}
55
56/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
57export interface SkillSummary {
58 /** Absolute instruction file path when supplied by the provider; absent for virtual skills. */
59 readonly path?: string
60 /** Kebab-case identifier used to address the skill. */
61 readonly name: string
62 /** Short routing description shown by discovery consumers. */
63 readonly description: string
64 /** Optional extra routing guidance. */
65 readonly whenToUse?: string
66 /** Resolved model and user invocation controls. */
67 readonly invocation: SkillInvocationPolicy
68 /** Discovery source that produced this winning skill. */
69 readonly source: SkillSource
70 /** Provider that owns this skill body. */
71 readonly provider: string
72 /** Provider-specific base for relative resources. */
73 readonly resourceBase?: SkillResourceBase
74}
75
76/** Provider catalog entry used by the registry to merge and later load skills. */
77export interface SkillCandidate extends SkillSummary {
78 /** Lower ranks win duplicate skill names before provider registration order is considered. */
79 readonly rank: number
80 /** Opaque provider-owned handle passed back to `provider.get()`. */
81 readonly locator: unknown
82 /** Parsed optional metadata object from provider-specific skill frontmatter. */
83 readonly metadata?: Readonly<Record<string, unknown>>
84}
85
86/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
87export interface SkillDefinition extends SkillSummary {
88 /** Markdown instruction body after any provider-specific metadata removal. */
89 readonly content: string
90 /** Parsed optional metadata object from frontmatter. */
91 readonly metadata?: Readonly<Record<string, unknown>>
92}
93
94/** Runtime skill contribution accepted by `ctx.skills.register()`. */
95export type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
96 /** Invocation controls; omission permits both model and user surfaces. */
97 readonly invocation?: SkillInvocationPolicy
98 /** Provider label; omission uses the registry-owned runtime provider. */
99 readonly provider?: string
100}
101
102/** Caller context used for cwd-sensitive and abortable provider work. */
103export interface SkillLookupOptions {
104 /** Workspace selector for the current lookup. */
105 readonly cwd?: string | undefined
106 /** Abort discovery or loading work for the current caller. */
107 readonly signal?: AbortSignal | undefined
108}
109
110/**
111 * Registry read options: provider lookup context plus the viewing scope.
112 * The registry consumes `scope` to select layers; providers receive the same
113 * borrowed options object and read only their {@link SkillLookupOptions}
114 * contract from it.
115 */
116export interface SkillViewOptions extends SkillLookupOptions {
117 /** Viewing scope (the calling agent); omitted reads the global layer alone. */
118 readonly scope?: ScopeKey | undefined
119}
120
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 */
126export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
127 return skill.invocation.modelInvocable
128}
129
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 */
135export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
136 return skill.invocation.userInvocable
137}
138
139/**
140 * Durable source for the context message a user-explicit skill invocation
141 * injects: the user's own words ride a plain user message, and the rendered
142 * skill body follows as injected `instructions`-form context carrying this
143 * source, so transcript consumers present the injection from metadata
144 * instead of re-parsing the model-facing text.
145 */
146export interface SkillInvocationSource {
147 readonly kind: 'skill-invocation'
148 /** Invoked skill name, validated user-invocable at the injecting boundary. */
149 readonly name: string
150 /** Injected skill bodies are instructions for the model to follow. */
151 readonly form: 'instructions'
152}
153
154declare module '@deepseek-ai/dsh-llm' {
155 interface MessageSourceMap {
156 /** A user-explicit skill invocation injected by the host. */
157 'skill-invocation': SkillInvocationSource
158 }
159}
160
161/**
162 * Render one loaded skill for the model. The output is shared verbatim by the
163 * `skill` tool result and the user-explicit invocation injection, so the model
164 * sees one canonical `<skill_content>` shape on both paths. The name rides an
165 * escaped attribute; the body is embedded verbatim (skills are trusted local
166 * 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 */
170export 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}
184
185function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
186 const base = skill.resourceBase
187 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}
215
216function escapeAttr(value: string): string {
217 return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
218}
219
220/**
221 * Escape model-facing prose embedded inside skill markup so provider-supplied
222 * text cannot open or close framing tags.
223 * @param value - raw prose to embed.
224 * @returns the escaped text.
225 */
226export function escapeText(value: string): string {
227 return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
228}
229
230/** One catalog observation plus whether discovery completed within a stable catalog revision. */
231export 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: boolean
236}
237
238/** Provider candidates plus whether the current discovery is authoritative. */
239export 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: boolean
244}
245
246/** Provider interface for one source of skills, such as local directories or a remote registry. */
247export interface SkillProvider {
248 /** Unique provider name in the `ctx.skills` registry. */
249 readonly name: string
250 /**
251 * List available skill candidates for the current lookup context. Provider
252 * plugins register synchronously during `apply()`; remote initialization,
253 * authentication, and discovery are awaited inside this method. Implementations
254 * 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 explicit
257 * 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}
268
269/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
270export interface SkillProviderControl {
271 /** Aborts if registration fails or when the exact provider registration is disposed. */
272 readonly signal: AbortSignal
273 /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
274 readonly invalidate: () => void
275}
276
277/** Skill registry configuration. */
278export interface Config {
279 /** Maximum number of completed cwd/provider catalogs kept in memory. */
280 readonly collectCacheMaxEntries?: number
281}
282
283declare module '@deepseek-ai/cordis' {
284 interface Context {
285 skills: SkillRegistry
286 }
287
288 interface Events {
289 /**
290 * A skill provider, runtime contribution, or provider-backed catalog may
291 * have changed. This is an unfiltered invalidation notification; consumers
292 * refetch the catalog for their own lookup options. Listener failures are
293 * contained and cannot veto the registry mutation.
294 * @mode emit
295 */
296 'skills/change'(): void
297 }
298}
299
300interface IndexedCandidate {
301 candidate: SkillCandidate
302 provider: SkillProvider
303 providerOrder: number
304 localOrder: number
305 /** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */
306 layer: SkillLayer
307}
308
309/** One provider registration retained by its layer. */
310interface RegisteredProvider {
311 provider: SkillProvider
312 /** Service-wide monotonic registration order, the within-layer rank tiebreak. */
313 order: number
314}
315
316interface LayerCollectResult {
317 entries: IndexedCandidate[]
318 cacheable: boolean
319}
320
321interface CollectResult {
322 entries: Map<string, IndexedCandidate>
323 cacheable: boolean
324}
325
326/** One scope's complete skill-registry contribution. */
327class 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>()
332
333 constructor(scope: ScopeKey | undefined) {
334 this.providers = new NamedEntries(name => new Error(scope === undefined
335 ? `a skill provider named "${name}" is already registered`
336 : `a skill provider named "${name}" is already registered in this scope`))
337 }
338
339 /** Whether every contribution table in this aggregate layer is empty. */
340 isEmpty(): boolean {
341 return this.providers.isEmpty() && this.runtime.size === 0
342 }
343}
344
345/**
346 * Layered registry of skill providers, the host+per-scope shape the tools
347 * registry established. A registration files into the layer of its calling
348 * context's scope ({@link scopeOf}): host rows and repository plugins land in
349 * the global layer, while a plugin mounted by an agent preset's standing
350 * composition lands in that preset's layer. A read merges the global layer
351 * with the viewing scope's chain — the nearest layer's entry wins a duplicate
352 * name outright, and the rank order decides duplicates only within one layer.
353 * It exposes sorted invocation-neutral summaries and loads full skill bodies
354 * on demand.
355 */
356export class SkillRegistry extends Service {
357 static Config: Schema<Config> = z.object({
358 collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
359 })
360
361 private readonly collectCacheMaxEntries: number
362 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 = 0
368 private nextProviderOrder = 0
369 /** Stable identities for cache keys; scope keys are opaque identity-compared objects. */
370 private readonly scopeIds = new WeakMap<ScopeKey, number>()
371 private nextScopeId = 1
372
373 constructor(ctx: Context, config: Config = {}) {
374 super(ctx, 'skills')
375 this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
376 assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
377 }
378
379 /**
380 * Register a borrowed same-process provider synchronously during plugin
381 * apply, into the calling context's layer: a scoped context (an agent
382 * preset's standing mount) registers for that scope alone, an unscoped
383 * context registers globally. Duplicate names within one layer and reserved
384 * names throw; remote initialization belongs in `list()`. Fiber disposal
385 * 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 } | undefined
393 let provider: SkillProvider
394 const control: SkillProviderControl = {
395 signal: lifecycle.signal,
396 invalidate: () => {
397 const active = registration
398 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.name
406 if (name === RUNTIME_PROVIDER) {
407 throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
408 }
409 const order = this.nextProviderOrder
410 this.nextProviderOrder += 1
411 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 = undefined
418 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 error
427 }
428 }
429
430 /**
431 * Register a borrowed readonly runtime skill into the calling context's
432 * layer. Project entries outrank runtime entries, which outrank user
433 * entries, within one layer. Same-name runtime entries in one layer are
434 * first-wins; a duplicate logs a warning and receives a no-op disposer so
435 * 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 }
461
462 /**
463 * List invocation-neutral skill summaries for a workspace. Consumers apply
464 * model or user invocation policy at their operational boundary. Lookup
465 * options and provider candidates are readonly same-process values borrowed
466 * 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)).skills
472 }
473
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 and
477 * 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 }
490
491 /**
492 * Load and validate the winning candidate, passing its opaque discovery locator back to the
493 * provider. Cancellation is rechecked after selection, including cache hits, and raced against
494 * 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 undefined
502 const collected = await this.collect(options)
503 throwIfAborted(options.signal)
504 const match = collected.entries.get(name)
505 if (match === undefined) return undefined
506 const definition = await waitWithAbort(
507 match.provider.get(match.candidate, options),
508 options.signal,
509 )
510 if (definition === undefined) return undefined
511 validateDefinition(definition)
512 if (definition.name !== match.candidate.name) {
513 this.invalidateEntry(match)
514 return undefined
515 }
516 return definition
517 }
518
519 private async collect(options: SkillViewOptions): Promise<CollectResult> {
520 throwIfAborted(options.signal)
521 let attempt = 1
522 while (true) {
523 const revision = this.revision
524 // The chain is part of the key rather than assumed stable: a blank-session
525 // 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 }
530
531 const result = await this.collectFresh(options)
532 throwIfAborted(options.signal)
533 if (revision !== this.revision) {
534 if (attempt < MAX_COLLECT_ATTEMPTS) {
535 attempt += 1
536 continue
537 }
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 result
548 }
549 }
550
551 private async collectFresh(options: SkillViewOptions): Promise<CollectResult> {
552 // Global first, then existing chain overlays farthest ancestor first and
553 // the exact scope last, so the nearest layer's same-name entry replaces
554 // the farther ones — the tools registry's shadowing rule. Rank decides
555 // 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 = true
559 for (const layer of layers) {
560 const collected = await this.collectLayer(layer, options)
561 if (!collected.cacheable) cacheable = false
562 for (const entry of collected.entries) merged.set(entry.candidate.name, entry)
563 }
564 return { entries: merged, cacheable }
565 }
566
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.candidate
574 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 continue
577 }
578 seen.add(skill.name)
579 result.push(entry)
580 }
581 return { entries: result, cacheable: collected.cacheable }
582 }
583
584 private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {
585 throwIfAborted(options.signal)
586 const candidates: IndexedCandidate[] = []
587 let cacheable = true
588 let runtimeOrder = 0
589 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 += 1
598 }
599 for (const { provider, order } of [...layer.providers.values()]) {
600 let localOrder = 0
601 let output: unknown
602 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 = false
607 this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
608 }
609 if (output === undefined) continue
610 const observation = normalizeProviderObservation(output, provider.name)
611 if (!observation.complete) cacheable = false
612 for (const candidate of observation.candidates) {
613 validateCandidate(candidate, provider.name)
614 candidates.push({ candidate, provider, providerOrder: order, localOrder, layer })
615 localOrder += 1
616 }
617 }
618 return { entries: candidates, cacheable }
619 }
620
621 private invalidateCache(): void {
622 this.revision += 1
623 this.collectCache.clear()
624 this.notifyChange()
625 }
626
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 }
632
633 private scopeId(key: ScopeKey): number {
634 let id = this.scopeIds.get(key)
635 if (id === undefined) {
636 id = this.nextScopeId
637 this.nextScopeId += 1
638 this.scopeIds.set(key, id)
639 }
640 return id
641 }
642
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 }
646
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}
661
662function 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 SkillProviderObservation
674}
675
676function invalidProviderObservation(providerName: string): TypeError {
677 return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
678}
679
680const 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}
690
691function 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}
706
707function 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}
740
741function 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}
746
747/** Validate a definition loaded from a provider-controlled parser or remote source. */
748function validateDefinition(skill: SkillDefinition): void {
749 const name = skill.name
750 const description = skill.description
751 const whenToUse = skill.whenToUse
752 const invocation = skill.invocation
753 const source = skill.source
754 const provider = skill.provider
755 const content = skill.content
756 const path = skill.path
757 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}
768
769function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
770 const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill
771 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}
782
783function validateInvocation(invocation: unknown, subject: string): void {
784 if (invocation === undefined) return
785 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}
796
797function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
798 return compareCodePoints(left.name, right.name)
799}
800
801function compareCodePoints(left: string, right: string): number {
802 if (left < right) return -1
803 if (left > right) return 1
804 return 0
805}
806
807function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
808 return left.candidate.rank - right.candidate.rank
809 || left.providerOrder - right.providerOrder
810 || left.localOrder - right.localOrder
811}
812
813function 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}
818
819function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
820 if (signal === undefined) return promise
821 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}
843
844/** Throw a total Error for an already-aborted lookup. */
845function throwIfAborted(signal: AbortSignal | undefined): void {
846 if (signal?.aborted === true) throw toError(signal.reason)
847}
848
849/** Normalize an arbitrary abort or provider failure without trusting coercion. */
850function toError(error: unknown): Error {
851 try {
852 if (error instanceof Error) return error
853 } catch {
854 // A hostile proxy may throw during instanceof; fall through to the total renderer.
855 }
856 return new Error(errorMessage(error))
857}
858
859/** Render an arbitrary provider failure without letting coercion escape containment. */
860function errorMessage(error: unknown): string {
861 try {
862 return String(error)
863 } catch {
864 return '[unrenderable thrown value]'
865 }
866}
867
868export default SkillRegistry