@graphorin/skills 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * `SkillRegistry` - registry over loaded skills.
3
+ *
4
+ * The registry is the surface the agent runtime (Phase 12) and the
5
+ * standalone server (Phase 14) consume. It exposes:
6
+ *
7
+ * - `getMetadata()` - every skill's Tier-1 metadata, used by the
8
+ * ContextEngine to assemble the system prompt's skill metadata
9
+ * block (Phase 10d).
10
+ * - `activate(triggers)` / `getActivationRequest(triggers)` -
11
+ * match a list of trigger strings (slash commands and / or model-
12
+ * emitted skill names) and return the corresponding
13
+ * {@link ActivatedSkill} records.
14
+ * - `getSkill(name)` - direct lookup.
15
+ * - `tools()` - flat list of declared tool entries; the runtime
16
+ * resolves the actual `Tool[]` through the `@graphorin/tools`
17
+ * registry.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import type { ResolvedTool } from '@graphorin/core';
23
+ import { parseSlashCommand } from '../activation/index.js';
24
+ import { SkillNameCollisionError, SlashCommandParseError } from '../errors/index.js';
25
+
26
+ export type { StampedSkillTool } from './bridge.js';
27
+ export { stampSkillTool, stampSkillToolFromMetadata } from './bridge.js';
28
+
29
+ import type {
30
+ ActivatedSkill,
31
+ InlineSkillTool,
32
+ Skill,
33
+ SkillMetadata,
34
+ SkillResource,
35
+ SkillToolDeclaration,
36
+ } from '../types/index.js';
37
+
38
+ /**
39
+ * Stamping seam injected by the agent runtime (Phase 12). It turns a skill's
40
+ * pre-built `Tool` into a fully resolved `ResolvedTool` (trust class + sandbox
41
+ * tier + source). The skills package keeps no hard dependency on
42
+ * `@graphorin/tools`; when no stamper is configured, `activate()` surfaces no
43
+ * tools (the runtime resolves them itself).
44
+ */
45
+ export type SkillToolStamper = (tool: InlineSkillTool, metadata: SkillMetadata) => ResolvedTool;
46
+
47
+ /** Options accepted by {@link createSkillRegistry}. */
48
+ export interface SkillRegistryOptions {
49
+ /**
50
+ * Default activation behaviour. When `'metadata-only'` (default),
51
+ * `activate(...)` returns the parsed activation request without
52
+ * invoking `Skill.body()`; callers (the agent runtime) then invoke
53
+ * the body resolver themselves so the runtime can attach a span.
54
+ * When `'eager'`, the registry resolves the body before returning,
55
+ * suitable for tests.
56
+ */
57
+ readonly activationStrategy?: 'metadata-only' | 'eager';
58
+ /**
59
+ * Optional stamping function (RP-11). When supplied, `activate()` runs each
60
+ * skill's pre-built `Tool[]` through it and surfaces the results on
61
+ * {@link ActivatedSkill.tools}. Without it, `activate()` surfaces no tools -
62
+ * the agent runtime resolves and stamps them itself.
63
+ */
64
+ readonly stampTool?: SkillToolStamper;
65
+ }
66
+
67
+ /** Public registry surface. */
68
+ export interface SkillRegistry {
69
+ register(skill: Skill): void;
70
+ /**
71
+ * Upsert a skill by name (RP-11). Unlike {@link SkillRegistry.register},
72
+ * `replace` overwrites an existing registration instead of throwing on a
73
+ * name collision - the upgrade path for hot-reloading a re-loaded skill.
74
+ */
75
+ replace(skill: Skill): void;
76
+ unregister(name: string): boolean;
77
+ getSkill(name: string): Skill | undefined;
78
+ has(name: string): boolean;
79
+ list(): ReadonlyArray<Skill>;
80
+ getMetadata(): ReadonlyArray<SkillMetadata>;
81
+ /**
82
+ * Skills surfaced into the system prompt for auto-activation.
83
+ * Skills with `disable-model-invocation: true` are excluded.
84
+ */
85
+ getAutoActivationMetadata(): ReadonlyArray<SkillMetadata>;
86
+ /**
87
+ * Render the auto-activation metadata as a string suitable for the
88
+ * system prompt. The format is bytes-stable and consumed verbatim
89
+ * by the ContextEngine layered template (Phase 10d). Skills with
90
+ * `disable-model-invocation: true` are excluded.
91
+ */
92
+ getMetadataBlock(): string;
93
+ /**
94
+ * Resolve a single trigger (model-emitted skill name OR the raw
95
+ * `/skill:<name>` slash-command body) into an {@link ActivationRequest}.
96
+ * Returns `null` when no skill matches and the trigger looked like a
97
+ * slash command - callers that want a strict mode should call
98
+ * {@link parseActivationTrigger} themselves.
99
+ */
100
+ resolveTrigger(trigger: string): ActivationRequest | null;
101
+ activate(
102
+ triggers: ReadonlyArray<string>,
103
+ signal?: AbortSignal,
104
+ ): Promise<ReadonlyArray<ActivatedSkill>>;
105
+ /**
106
+ * Best-effort match: returns every skill whose name OR description
107
+ * contains all of the supplied trigger tokens (case-insensitive).
108
+ * The agent runtime uses this when the model emits a trigger phrase
109
+ * that does not directly map to a skill name.
110
+ */
111
+ search(triggers: ReadonlyArray<string>): ReadonlyArray<Skill>;
112
+ /**
113
+ * Flat, deduplicated list of every pre-built tool shipped by the
114
+ * registered skills. The first registration wins on a `tool.name`
115
+ * collision; later collisions surface a one-time WARN through the
116
+ * console so operators can resolve the conflict (Phase 12 will
117
+ * route these through the audit emitter).
118
+ */
119
+ tools(): ReadonlyArray<InlineSkillTool>;
120
+ toolDeclarations(): ReadonlyArray<RegisteredToolDeclaration>;
121
+ size(): number;
122
+ clear(): void;
123
+ }
124
+
125
+ /** Activation request produced by {@link SkillRegistry.resolveTrigger}. */
126
+ export interface ActivationRequest {
127
+ readonly skill: Skill;
128
+ readonly activationKind: ActivatedSkill['activationKind'];
129
+ readonly args?: string;
130
+ }
131
+
132
+ /**
133
+ * Tool-declaration record exposed by {@link SkillRegistry.toolDeclarations}.
134
+ * Adds the owning skill's name and trust level so downstream
135
+ * registrations into `@graphorin/tools` can stamp the source.
136
+ *
137
+ * @stable
138
+ */
139
+ export interface RegisteredToolDeclaration extends SkillToolDeclaration {
140
+ readonly skillName: string;
141
+ readonly trustLevel: SkillMetadata['graphorinTrustLevel'];
142
+ }
143
+
144
+ /**
145
+ * Build a fresh, empty registry. Multiple registries can co-exist
146
+ * within a single process; the framework defaults to a single shared
147
+ * instance per agent instance.
148
+ *
149
+ * @stable
150
+ */
151
+ export function createSkillRegistry(options: SkillRegistryOptions = {}): SkillRegistry {
152
+ const skillsByName = new Map<string, Skill>();
153
+ const strategy = options.activationStrategy ?? 'metadata-only';
154
+
155
+ function register(skill: Skill): void {
156
+ if (skillsByName.has(skill.metadata.name)) {
157
+ throw new SkillNameCollisionError(skill.metadata.name);
158
+ }
159
+ skillsByName.set(skill.metadata.name, skill);
160
+ }
161
+
162
+ function replace(skill: Skill): void {
163
+ skillsByName.set(skill.metadata.name, skill);
164
+ }
165
+
166
+ function unregister(name: string): boolean {
167
+ return skillsByName.delete(name);
168
+ }
169
+
170
+ function getSkill(name: string): Skill | undefined {
171
+ return skillsByName.get(name);
172
+ }
173
+
174
+ function has(name: string): boolean {
175
+ return skillsByName.has(name);
176
+ }
177
+
178
+ function list(): ReadonlyArray<Skill> {
179
+ return Object.freeze([...skillsByName.values()]);
180
+ }
181
+
182
+ function getMetadata(): ReadonlyArray<SkillMetadata> {
183
+ return Object.freeze([...skillsByName.values()].map((skill) => skill.metadata));
184
+ }
185
+
186
+ function getAutoActivationMetadata(): ReadonlyArray<SkillMetadata> {
187
+ return Object.freeze(
188
+ [...skillsByName.values()]
189
+ .map((skill) => skill.metadata)
190
+ .filter((metadata) => !metadata.disableModelInvocation),
191
+ );
192
+ }
193
+
194
+ function getMetadataBlock(): string {
195
+ const lines: string[] = [];
196
+ const auto = getAutoActivationMetadata();
197
+ if (auto.length === 0) return '';
198
+ lines.push('# Available skills');
199
+ lines.push('');
200
+ for (const meta of auto) {
201
+ lines.push(`## ${meta.name}`);
202
+ lines.push('');
203
+ const description = meta.description.replace(/\s+/gu, ' ').trim();
204
+ if (description.length > 0) lines.push(description);
205
+ const cues: string[] = [];
206
+ if (meta.allowedTools !== undefined && meta.allowedTools.length > 0) {
207
+ cues.push(`Allowed tools: ${[...meta.allowedTools].join(', ')}`);
208
+ }
209
+ if (meta.graphorinSensitivity !== undefined) {
210
+ cues.push(`Sensitivity: ${meta.graphorinSensitivity}`);
211
+ }
212
+ if (cues.length > 0) {
213
+ lines.push('');
214
+ for (const cue of cues) lines.push(`- ${cue}`);
215
+ }
216
+ lines.push('');
217
+ }
218
+ return lines.join('\n').replace(/\n+$/u, '\n');
219
+ }
220
+
221
+ function search(triggers: ReadonlyArray<string>): ReadonlyArray<Skill> {
222
+ const tokens = [...triggers]
223
+ .flatMap((trigger) => trigger.toLowerCase().split(/\s+/u))
224
+ .filter((tok) => tok.length > 0);
225
+ if (tokens.length === 0) return Object.freeze([]);
226
+ const matches: Skill[] = [];
227
+ const seen = new Set<string>();
228
+ for (const skill of skillsByName.values()) {
229
+ const haystack = `${skill.metadata.name}\n${skill.metadata.description}`.toLowerCase();
230
+ const isMatch = tokens.every((tok) => haystack.includes(tok));
231
+ if (isMatch && !seen.has(skill.metadata.name)) {
232
+ seen.add(skill.metadata.name);
233
+ matches.push(skill);
234
+ }
235
+ }
236
+ return Object.freeze(matches);
237
+ }
238
+
239
+ function tools(): ReadonlyArray<InlineSkillTool> {
240
+ const seen = new Set<string>();
241
+ const out: InlineSkillTool[] = [];
242
+ const collisions = new Set<string>();
243
+ for (const skill of skillsByName.values()) {
244
+ for (const tool of skill.tools()) {
245
+ if (seen.has(tool.name)) {
246
+ if (!collisions.has(tool.name)) {
247
+ collisions.add(tool.name);
248
+ // eslint-disable-next-line no-console
249
+ console.warn(
250
+ `[graphorin/skills] Duplicate tool name '${tool.name}' across skills; first registration wins.`,
251
+ );
252
+ }
253
+ continue;
254
+ }
255
+ seen.add(tool.name);
256
+ out.push(tool);
257
+ }
258
+ }
259
+ return Object.freeze(out);
260
+ }
261
+
262
+ function resolveTrigger(trigger: string): ActivationRequest | null {
263
+ const parsed = parseActivationTrigger(trigger);
264
+ const skill = skillsByName.get(parsed.name);
265
+ if (skill === undefined) return null;
266
+ if (parsed.activationKind === 'auto' && skill.metadata.disableModelInvocation) {
267
+ // Auto-activation refused - the skill opted out.
268
+ return null;
269
+ }
270
+ const request: Mutable<ActivationRequest> = {
271
+ skill,
272
+ activationKind: parsed.activationKind,
273
+ };
274
+ if (parsed.args !== undefined && parsed.args.length > 0) request.args = parsed.args;
275
+ return Object.freeze(request);
276
+ }
277
+
278
+ async function activate(
279
+ triggers: ReadonlyArray<string>,
280
+ signal?: AbortSignal,
281
+ ): Promise<ReadonlyArray<ActivatedSkill>> {
282
+ const out: ActivatedSkill[] = [];
283
+ const seen = new Set<string>();
284
+ for (const trigger of triggers) {
285
+ const request = resolveTrigger(trigger);
286
+ if (request === null) continue;
287
+ if (seen.has(request.skill.metadata.name)) continue;
288
+ seen.add(request.skill.metadata.name);
289
+ const body = strategy === 'eager' ? await request.skill.body(signal) : '';
290
+ const resources: ReadonlyArray<SkillResource> =
291
+ strategy === 'eager' ? await request.skill.resources(signal) : Object.freeze([]);
292
+ // Pre-built tools (inline source) are surfaced via `Skill.tools()`.
293
+ // When the caller wired a `stampTool` function (the agent runtime does),
294
+ // each tool is stamped into a `ResolvedTool` and surfaced here; without
295
+ // it the registry exposes no tools (the runtime resolves them itself).
296
+ const stampedTools: ReadonlyArray<ResolvedTool> =
297
+ options.stampTool !== undefined
298
+ ? Object.freeze(
299
+ request.skill
300
+ .tools()
301
+ .map((tool) =>
302
+ (options.stampTool as SkillToolStamper)(tool, request.skill.metadata),
303
+ ),
304
+ )
305
+ : Object.freeze([]);
306
+ out.push(
307
+ Object.freeze({
308
+ skill: request.skill,
309
+ body,
310
+ resources,
311
+ tools: stampedTools,
312
+ activationKind: request.activationKind,
313
+ activatedAt: Date.now(),
314
+ }),
315
+ );
316
+ }
317
+ return Object.freeze(out);
318
+ }
319
+
320
+ function toolDeclarations(): ReadonlyArray<RegisteredToolDeclaration> {
321
+ const out: RegisteredToolDeclaration[] = [];
322
+ for (const skill of skillsByName.values()) {
323
+ for (const decl of skill.toolDeclarations()) {
324
+ out.push(
325
+ Object.freeze({
326
+ ...decl,
327
+ skillName: skill.metadata.name,
328
+ trustLevel: skill.metadata.graphorinTrustLevel,
329
+ }),
330
+ );
331
+ }
332
+ }
333
+ return Object.freeze(out);
334
+ }
335
+
336
+ function size(): number {
337
+ return skillsByName.size;
338
+ }
339
+
340
+ function clear(): void {
341
+ skillsByName.clear();
342
+ }
343
+
344
+ return Object.freeze({
345
+ register,
346
+ replace,
347
+ unregister,
348
+ getSkill,
349
+ has,
350
+ list,
351
+ getMetadata,
352
+ getAutoActivationMetadata,
353
+ getMetadataBlock,
354
+ resolveTrigger,
355
+ activate,
356
+ search,
357
+ tools,
358
+ toolDeclarations,
359
+ size,
360
+ clear,
361
+ } satisfies SkillRegistry);
362
+ }
363
+
364
+ /**
365
+ * Parsed activation trigger. The registry uses this to discriminate
366
+ * slash-command activations (which override
367
+ * `disable-model-invocation: true`) from model-emitted auto
368
+ * activations (which honour it).
369
+ *
370
+ * @stable
371
+ */
372
+ export interface ParsedActivationTrigger {
373
+ readonly name: string;
374
+ readonly activationKind: ActivatedSkill['activationKind'];
375
+ readonly args?: string;
376
+ }
377
+
378
+ /**
379
+ * Parse a single activation trigger. Slash-command bodies
380
+ * (`/skill:<name>`) are routed through the slash parser; bare names
381
+ * are treated as auto-activation requests emitted by the model.
382
+ *
383
+ * Throws {@link SlashCommandParseError} when the body looks like a
384
+ * slash command but does not match the grammar (so the caller can
385
+ * surface the error to the user).
386
+ *
387
+ * @stable
388
+ */
389
+ export function parseActivationTrigger(raw: string): ParsedActivationTrigger {
390
+ const trimmed = raw.trim();
391
+ if (trimmed.startsWith('/skill:') || /^\s*\/skill:/u.test(raw)) {
392
+ const parsed = parseSlashCommand(raw);
393
+ return Object.freeze({
394
+ name: parsed.name,
395
+ activationKind: 'slash-command' as const,
396
+ ...(parsed.args.length > 0 ? { args: parsed.args } : {}),
397
+ });
398
+ }
399
+ if (trimmed.length === 0) {
400
+ throw new SlashCommandParseError(raw, 'activation trigger must be non-empty.');
401
+ }
402
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(trimmed)) {
403
+ throw new SlashCommandParseError(
404
+ raw,
405
+ 'auto-activation trigger must match /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.',
406
+ );
407
+ }
408
+ return Object.freeze({
409
+ name: trimmed,
410
+ activationKind: 'auto' as const,
411
+ });
412
+ }
413
+
414
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Bundled snapshot loader for the `SKILL.md` packaging-format
3
+ * specification.
4
+ *
5
+ * The framework ships an offline copy of the upstream specification
6
+ * so the loader can decide which frontmatter fields are recognised,
7
+ * which `graphorin-*` extensions deprecate (or co-exist with) an
8
+ * upstream field, and whether a skill author's
9
+ * `graphorin-anthropic-spec` hint refers to a snapshot newer or older
10
+ * than the bundled one. The snapshot is checked-in to the repository;
11
+ * `pnpm run check-anthropic-spec` diffs it against an upstream snapshot
12
+ * the maintainer supplies via `--upstream` (there is no scheduled CI
13
+ * job and no auto-refresh - the release `mvp-readiness` gate runs the
14
+ * helper in no-upstream skip mode, which only confirms the bundled
15
+ * snapshot parses).
16
+ *
17
+ * Neither the loader nor the helper fetches the upstream specification;
18
+ * the upstream snapshot is fetched manually. The snapshot lookup is
19
+ * deterministic and side-effect free at runtime.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ /** Stability classification of a known upstream field. */
29
+ export type FieldStability = 'stable' | 'standardized' | 'experimental';
30
+
31
+ /** Migration policy applied to a `graphorin-*` field that maps to an upstream field. */
32
+ export type GraphorinFieldPolicy = 'deprecate-graphorin-prefix' | 'co-exist' | 'graphorin-only';
33
+
34
+ /** Single entry of the upstream-known fields map. */
35
+ export interface KnownFieldEntry {
36
+ readonly since: string;
37
+ readonly required: boolean;
38
+ readonly type: string;
39
+ readonly stability: FieldStability;
40
+ }
41
+
42
+ /** Single entry of the `graphorin-*` mapping map. */
43
+ export interface GraphorinMappingEntry {
44
+ readonly anthropicEquivalent: string | null;
45
+ readonly policy: GraphorinFieldPolicy;
46
+ readonly since?: string;
47
+ readonly rationale?: string;
48
+ readonly deprecateAt?: string;
49
+ readonly removeAt?: string;
50
+ }
51
+
52
+ /** Top-level shape of the bundled snapshot. */
53
+ export interface SpecSnapshot {
54
+ readonly snapshotDate: string;
55
+ readonly specSource: string;
56
+ readonly specCommit: string | null;
57
+ readonly rationale?: string;
58
+ readonly knownFields: Readonly<Record<string, KnownFieldEntry>>;
59
+ readonly graphorinMapping: Readonly<Record<string, GraphorinMappingEntry>>;
60
+ }
61
+
62
+ let cached: SpecSnapshot | null = null;
63
+ let overrideSnapshot: SpecSnapshot | null = null;
64
+
65
+ /**
66
+ * Override the bundled snapshot. Used by tests that exercise the
67
+ * "newer / older spec snapshot" branches of the validator.
68
+ *
69
+ * @experimental
70
+ */
71
+ export function _setSpecSnapshotForTesting(snapshot: SpecSnapshot | null): void {
72
+ overrideSnapshot = snapshot;
73
+ }
74
+
75
+ /**
76
+ * Return the currently active snapshot. Loads the bundled JSON file
77
+ * on first call, then caches the parsed object.
78
+ *
79
+ * @stable
80
+ */
81
+ export function getSpecSnapshot(): SpecSnapshot {
82
+ if (overrideSnapshot !== null) return overrideSnapshot;
83
+ if (cached !== null) return cached;
84
+ const __filename = fileURLToPath(import.meta.url);
85
+ const __dirname = dirname(__filename);
86
+ const candidates = [
87
+ // Source: packages/skills/src/spec/index.ts → ../../anthropic-spec-snapshot.json
88
+ join(__dirname, '..', '..', 'anthropic-spec-snapshot.json'),
89
+ // Built ESM: packages/skills/dist/spec/index.js → ../../anthropic-spec-snapshot.json
90
+ join(__dirname, '..', '..', 'anthropic-spec-snapshot.json'),
91
+ ];
92
+ let lastError: unknown;
93
+ for (const candidate of candidates) {
94
+ try {
95
+ const raw = readFileSync(candidate, 'utf8');
96
+ const parsed = JSON.parse(raw) as SpecSnapshot;
97
+ cached = parsed;
98
+ return parsed;
99
+ } catch (err) {
100
+ lastError = err;
101
+ }
102
+ }
103
+ throw new Error(
104
+ `Failed to load bundled spec snapshot. Tried: ${candidates.join(', ')}. ` +
105
+ `Cause: ${(lastError as Error).message}`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Resolve a known-field entry by name. Returns `undefined` if the
111
+ * field is not part of the upstream specification.
112
+ *
113
+ * @stable
114
+ */
115
+ export function getKnownField(field: string): KnownFieldEntry | undefined {
116
+ return getSpecSnapshot().knownFields[field];
117
+ }
118
+
119
+ /**
120
+ * Resolve the mapping entry for a `graphorin-*` field. Returns
121
+ * `undefined` if the field is not known to the snapshot.
122
+ *
123
+ * @stable
124
+ */
125
+ export function getGraphorinMapping(field: string): GraphorinMappingEntry | undefined {
126
+ return getSpecSnapshot().graphorinMapping[field];
127
+ }
128
+
129
+ /**
130
+ * Compare an author's `graphorin-anthropic-spec` value against the
131
+ * bundled snapshot date. Returns:
132
+ *
133
+ * - `'same'` - the author targeted the same snapshot.
134
+ * - `'older'` - the author targeted an older snapshot.
135
+ * - `'newer'` - the author targeted a newer snapshot.
136
+ * - `'unparseable'` - the author's value could not be interpreted as
137
+ * an ISO-8601 date.
138
+ *
139
+ * @stable
140
+ */
141
+ export function compareAuthorSpecHint(
142
+ authorValue: string,
143
+ ): 'same' | 'older' | 'newer' | 'unparseable' {
144
+ const snapshot = getSpecSnapshot();
145
+ const author = parseDate(authorValue);
146
+ const local = parseDate(snapshot.snapshotDate);
147
+ if (author === null || local === null) return 'unparseable';
148
+ if (author.getTime() === local.getTime()) return 'same';
149
+ return author.getTime() > local.getTime() ? 'newer' : 'older';
150
+ }
151
+
152
+ function parseDate(value: string): Date | null {
153
+ const trimmed = value.trim();
154
+ if (!/^\d{4}-\d{2}-\d{2}/u.test(trimmed)) return null;
155
+ const date = new Date(trimmed);
156
+ if (Number.isNaN(date.getTime())) return null;
157
+ return date;
158
+ }