From 1fa844d5e878b015b1716990272cf7c1f1d90d12 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 26 Jun 2026 02:11:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai-skills):=20add=20@gemstack/ai-skills=20?= =?UTF-8?q?=E2=80=94=20portable=20capability=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second package of the AI family (#8). Loads SKILL.md skills (instructions + tools + resources) and composes them onto an @gemstack/ai-sdk Agent. - manifest: SKILL.md YAML frontmatter + markdown body (matches boost/skills + Anthropic Agent Skills shape), zod-validated. - loader: loadSkill/loadSkills — instructions, co-located tool() exports, resources/. - registry: SkillRegistry discovers skills by cheap frontmatter and loads the full body + tools on demand (progressive disclosure). - compose: composeInstructions/Tools/Middleware merge skills into an agent; the agent's own declarations stay authoritative (own tools win collisions, skill tools namespaced as backstop). SkillfulAgent base wraps this declaratively. - trust boundary: no in-process sandbox; discovery runs no skill code, loadTools:false surfaces without executing, tool exec stays on the agent's approval/middleware path. One-directional (ai-skills -> ai-sdk). 33 tests incl. an end-to-end run proving ai-sdk delivers the composed system prompt + skill tools to the provider. Closes #8 --- .changeset/ai-skills-initial.md | 14 +++ packages/ai-skills/README.md | 116 ++++++++++++++++++ packages/ai-skills/package.json | 58 +++++++++ packages/ai-skills/src/compose.test.ts | 109 ++++++++++++++++ packages/ai-skills/src/compose.ts | 92 ++++++++++++++ packages/ai-skills/src/index.ts | 30 +++++ packages/ai-skills/src/loader.test.ts | 92 ++++++++++++++ packages/ai-skills/src/loader.ts | 114 +++++++++++++++++ packages/ai-skills/src/manifest.test.ts | 74 +++++++++++ packages/ai-skills/src/manifest.ts | 68 ++++++++++ packages/ai-skills/src/registry.test.ts | 78 ++++++++++++ packages/ai-skills/src/registry.ts | 90 ++++++++++++++ packages/ai-skills/src/skillful-agent.test.ts | 80 ++++++++++++ packages/ai-skills/src/skillful-agent.ts | 65 ++++++++++ packages/ai-skills/src/types.ts | 77 ++++++++++++ packages/ai-skills/tsconfig.build.json | 6 + packages/ai-skills/tsconfig.json | 5 + packages/ai-skills/tsconfig.test.json | 5 + pnpm-lock.yaml | 26 ++++ 19 files changed, 1199 insertions(+) create mode 100644 .changeset/ai-skills-initial.md create mode 100644 packages/ai-skills/README.md create mode 100644 packages/ai-skills/package.json create mode 100644 packages/ai-skills/src/compose.test.ts create mode 100644 packages/ai-skills/src/compose.ts create mode 100644 packages/ai-skills/src/index.ts create mode 100644 packages/ai-skills/src/loader.test.ts create mode 100644 packages/ai-skills/src/loader.ts create mode 100644 packages/ai-skills/src/manifest.test.ts create mode 100644 packages/ai-skills/src/manifest.ts create mode 100644 packages/ai-skills/src/registry.test.ts create mode 100644 packages/ai-skills/src/registry.ts create mode 100644 packages/ai-skills/src/skillful-agent.test.ts create mode 100644 packages/ai-skills/src/skillful-agent.ts create mode 100644 packages/ai-skills/src/types.ts create mode 100644 packages/ai-skills/tsconfig.build.json create mode 100644 packages/ai-skills/tsconfig.json create mode 100644 packages/ai-skills/tsconfig.test.json diff --git a/.changeset/ai-skills-initial.md b/.changeset/ai-skills-initial.md new file mode 100644 index 0000000..df4834e --- /dev/null +++ b/.changeset/ai-skills-initial.md @@ -0,0 +1,14 @@ +--- +"@gemstack/ai-skills": minor +--- + +Initial release. Portable capability bundles for `@gemstack/ai-sdk` agents — load `SKILL.md` skills (instructions + tools + resources) and compose them onto an `Agent`: + +- `parseSkillManifest` — parse `SKILL.md` YAML frontmatter + markdown body (matches the `boost/skills` / Anthropic Agent Skills shape). +- `loadSkill` / `loadSkills` — load a skill directory: instructions, co-located `tool()` exports, and `resources/`. +- `SkillRegistry` — discover skills by their cheap frontmatter and load the full body + tools on demand (progressive disclosure). +- `composeInstructions` / `composeTools` / `composeMiddleware` — merge skills into an agent; the agent's own declarations stay authoritative (own tools win name collisions, skill tools are namespaced as a backstop). +- `SkillfulAgent` — an `Agent` base that composes `skills()` declaratively alongside `baseInstructions()` / `baseTools()`. +- `surface` — inspect a skill's instructions/tools/resources before composing it. + +Explicit trust boundary (no in-process sandbox): discovery reads only frontmatter, `loadTools: false` loads without running the tools module, and skill tools flow through the agent's existing approval/middleware path. Depends on `@gemstack/ai-sdk`. diff --git a/packages/ai-skills/README.md b/packages/ai-skills/README.md new file mode 100644 index 0000000..f4d2856 --- /dev/null +++ b/packages/ai-skills/README.md @@ -0,0 +1,116 @@ +# @gemstack/ai-skills + +Portable capability bundles for [`@gemstack/ai-sdk`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-sdk) agents. A **skill** is a shippable folder — instructions + tools + resources — that you compose onto an `Agent` on demand. This mirrors the [Anthropic Agent Skills](https://www.anthropic.com/news/agent-skills) shape: a skill authored for Claude loads here, and a gemstack skill ships as a plain folder. + +``` +my-skill/ + SKILL.md # YAML frontmatter (name, description, trigger, ...) + markdown instructions + tools.ts # optional: exports ai-sdk tool() objects + resources/ # optional: reference files +``` + +## Installation + +```bash +pnpm add @gemstack/ai-skills @gemstack/ai-sdk +``` + +## The skill manifest + +`SKILL.md` is markdown with a YAML frontmatter block — the same convention `@gemstack/ai-sdk` ships in `boost/skills`: + +```markdown +--- +name: refunds +description: Issue and look up customer refunds +trigger: handling a refund request or refund status question +metadata: + author: acme +--- + +# Refunds + +When a customer asks for a refund, look up the order first, then issue the +refund with the `issue_refund` tool. Never refund more than the order total. +``` + +A co-located `tools.ts` exports the skill's tools as plain `@gemstack/ai-sdk` `tool()` objects — one tool API across the framework: + +```ts +import { toolDefinition } from '@gemstack/ai-sdk' +import { z } from 'zod' + +export const issueRefund = toolDefinition({ + name: 'issue_refund', + description: 'Issue a refund for an order', + inputSchema: z.object({ orderId: z.string(), amount: z.number() }), +}).server(async ({ orderId, amount }) => { + return await refunds.create(orderId, amount) +}) +``` + +## Composing skills onto an agent + +The ergonomic path is `SkillfulAgent`. You declare your base identity and own tools, and list the skills — skills augment, your own declarations win: + +```ts +import { loadSkill, SkillfulAgent } from '@gemstack/ai-skills' + +const refunds = await loadSkill('./skills/refunds') + +class SupportAgent extends SkillfulAgent { + baseInstructions() { return 'You are a friendly support agent.' } + skills() { return [refunds] } + baseTools() { return [escalateTool] } // wins over a same-named skill tool +} + +const reply = await new SupportAgent().prompt('I want a refund for order #123') +``` + +> Override the `base*` hooks, **not** `instructions()` / `tools()` / `middleware()` — those are sealed finals that merge your declarations with the skills. + +### Low-level composition + +If you can't extend `SkillfulAgent` (e.g. you use the anonymous `agent()` factory, or already extend another base), the same merge is available as plain functions: + +```ts +import { Agent } from '@gemstack/ai-sdk' +import { composeInstructions, composeTools } from '@gemstack/ai-skills' + +const skills = [refunds] + +class SupportAgent extends Agent { + instructions() { return composeInstructions('You are a support agent.', skills) } + tools() { return composeTools([escalateTool], skills) } +} +``` + +`SkillfulAgent` is sugar over these. + +## Discovery (progressive disclosure) + +`SkillRegistry` indexes skills by their cheap frontmatter and loads a skill's full body + tools only when you ask for it — so you can index hundreds of skills and pay for only the ones you compose: + +```ts +import { SkillRegistry } from '@gemstack/ai-skills' + +const registry = new SkillRegistry() +await registry.discover('./skills') // reads frontmatter only, runs no skill code +registry.list() // [{ manifest, dir }, ...] + +const refunds = await registry.load('refunds') // now imports tools.ts +``` + +## Trust model + +A skill is code you install or author, like a Vite or ESLint plugin: **loading it runs its code** (the tools module). There is no in-process sandbox — Node's `vm` is not a security boundary. The package keeps the boundary honest instead of pretending to enforce it: + +- **No auto-loading of untrusted directories.** You pass explicit paths to `loadSkill` / `discover`; nothing is scanned implicitly. +- **Surface before compose.** `discover()` reads only frontmatter (no code runs). `loadSkill(dir, { loadTools: false })` loads instructions + resources without importing the tools module. `surface(skill)` reports a skill's instructions size, tool names, and resources so you can inspect before attaching. +- **The risky moment stays gated.** Skill tools are ordinary `ai-sdk` tools, so tool execution still flows through the agent's existing approval / middleware flow. + +If you need real isolation, run the app under OS/container isolation. Only load skills from sources you trust. + +## License + +MIT diff --git a/packages/ai-skills/package.json b/packages/ai-skills/package.json new file mode 100644 index 0000000..52361f5 --- /dev/null +++ b/packages/ai-skills/package.json @@ -0,0 +1,58 @@ +{ + "name": "@gemstack/ai-skills", + "version": "0.0.0", + "description": "Portable capability bundles for @gemstack/ai-sdk agents: load SKILL.md skills (instructions + tools + resources) and compose them onto an Agent.", + "keywords": [ + "ai", + "agent", + "agents", + "skills", + "agent-skills", + "capabilities", + "tools", + "skill-md", + "gemstack" + ], + "license": "MIT", + "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/ai-skills#readme", + "bugs": { + "url": "https://github.com/gemstack-land/gemstack/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/gemstack-land/gemstack", + "directory": "packages/ai-skills" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist" + }, + "dependencies": { + "@gemstack/ai-sdk": "workspace:^", + "yaml": "^2.5.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "author": "Suleiman Shahbari" +} diff --git a/packages/ai-skills/src/compose.test.ts b/packages/ai-skills/src/compose.test.ts new file mode 100644 index 0000000..108fdb7 --- /dev/null +++ b/packages/ai-skills/src/compose.test.ts @@ -0,0 +1,109 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { toolDefinition } from '@gemstack/ai-sdk' +import type { AiMiddleware } from '@gemstack/ai-sdk' +import { z } from 'zod' +import { composeInstructions, composeTools, composeMiddleware, surface } from './compose.js' +import type { LoadedSkill } from './types.js' + +function tool(name: string, description = name) { + return toolDefinition({ + name, + description, + inputSchema: z.object({ x: z.string() }), + }).server(async () => `${name}-result`) +} + +function skill(name: string, instructions: string, tools: ReturnType[] = [], extra: Partial = {}): LoadedSkill { + return { + manifest: { name, description: `${name} skill` }, + instructions, + tools, + resources: [], + ...extra, + } +} + +describe('composeInstructions', () => { + it('puts the agent base first and appends each skill under a header', () => { + const out = composeInstructions('You are an agent.', [ + skill('alpha', 'Do alpha things.'), + skill('beta', 'Do beta things.'), + ]) + assert.ok(out.startsWith('You are an agent.')) + assert.ok(out.indexOf('# Skill: alpha') < out.indexOf('# Skill: beta')) + assert.ok(out.includes('Do alpha things.')) + assert.ok(out.includes('Do beta things.')) + }) + + it('omits skills with an empty instructions body', () => { + const out = composeInstructions('Base.', [skill('toolsonly', ' ')]) + assert.equal(out, 'Base.') + }) +}) + +describe('composeTools', () => { + it('keeps the agent own tools first and unchanged', () => { + const own = [tool('escalate')] + const out = composeTools(own, [skill('s', '', [tool('lookup')])]) + assert.deepEqual(out.map(t => t.definition.name), ['escalate', 'lookup']) + }) + + it('namespaces a colliding skill tool instead of dropping it; the own tool wins', () => { + const own = [tool('search', 'agent search')] + const out = composeTools(own, [skill('docs', '', [tool('search', 'skill search')])]) + assert.equal(out.length, 2) + // own tool retains its name + description (authoritative) + const ownTool = out.find(t => t.definition.name === 'search')! + assert.equal(ownTool.definition.description, 'agent search') + // skill tool survives, namespaced + const renamed = out.find(t => t.definition.name === 'docs__search')! + assert.equal(renamed.definition.description, 'skill search') + }) + + it('namespaces collisions between two skills (second skill yields)', () => { + const out = composeTools([], [ + skill('a', '', [tool('run')]), + skill('b', '', [tool('run')]), + ]) + assert.deepEqual(out.map(t => t.definition.name).sort(), ['b__run', 'run']) + }) + + it('preserves a renamed tool execute fn', async () => { + const out = composeTools([tool('dup')], [skill('s', '', [tool('dup')])]) + const renamed = out.find(t => t.definition.name === 's__dup')! + const result = await renamed.execute!({ x: '' }, undefined as never) + assert.equal(result, 'dup-result') + }) +}) + +describe('composeMiddleware', () => { + it('runs agent middleware before skill middleware', () => { + const mwOwn: AiMiddleware = { name: 'own' } + const mwSkill: AiMiddleware = { name: 'skill' } + const out = composeMiddleware([mwOwn], [skill('s', '', [], { middleware: [mwSkill] })]) + assert.deepEqual(out.map(m => m.name), ['own', 'skill']) + }) + + it('returns a copy when no skill contributes middleware', () => { + const own = [{ name: 'own' } as AiMiddleware] + const out = composeMiddleware(own, [skill('s', 'x')]) + assert.deepEqual(out.map(m => m.name), ['own']) + assert.notEqual(out, own) + }) +}) + +describe('surface', () => { + it('summarizes a skill without composing it', () => { + const s = skill('refunds', 'Refund instructions.', [tool('issue_refund')], { + manifest: { name: 'refunds', description: 'Refunds', trigger: 'a refund request' }, + resources: [{ name: 'policy.md', path: '/x/policy.md' }], + }) + const summary = surface(s) + assert.equal(summary.name, 'refunds') + assert.equal(summary.trigger, 'a refund request') + assert.deepEqual(summary.toolNames, ['issue_refund']) + assert.deepEqual(summary.resourceNames, ['policy.md']) + assert.equal(summary.instructionChars, 'Refund instructions.'.length) + }) +}) diff --git a/packages/ai-skills/src/compose.ts b/packages/ai-skills/src/compose.ts new file mode 100644 index 0000000..506ddb2 --- /dev/null +++ b/packages/ai-skills/src/compose.ts @@ -0,0 +1,92 @@ +import type { AnyTool, AiMiddleware } from '@gemstack/ai-sdk' +import type { LoadedSkill, SkillSurface } from './types.js' + +/** + * Compose an agent's base identity with the instructions from its skills. + * + * The agent's own `base` comes first and is authoritative — it is the identity + * the rest layers under. Each skill's body follows in declaration order under a + * `# Skill: ` header so the model can tell capabilities apart. Skills with + * an empty body contribute tools/resources only and add nothing here. + */ +export function composeInstructions(base: string, skills: LoadedSkill[]): string { + const blocks = skills + .filter(s => s.instructions.trim().length > 0) + .map(s => `# Skill: ${s.manifest.name}\n\n${s.instructions.trim()}`) + return [base.trim(), ...blocks].filter(s => s.length > 0).join('\n\n') +} + +/** + * Union an agent's own tools with the tools from its skills. + * + * Precedence is unambiguous: the agent's own tools come first and win every + * name collision. A skill tool whose name is already taken (by an own tool or an + * earlier skill) is kept but **namespaced** as `__` so nothing is + * silently dropped — the loader's namespacing is the backstop the agent's + * authority rests on. + */ +export function composeTools(own: AnyTool[], skills: LoadedSkill[]): AnyTool[] { + const out: AnyTool[] = [...own] + const used = new Set(own.map(t => t.definition.name)) + + for (const skill of skills) { + for (const tool of skill.tools) { + const name = tool.definition.name + if (!used.has(name)) { + used.add(name) + out.push(tool) + continue + } + // Collision — namespace it rather than drop it. + let candidate = `${sanitize(skill.manifest.name)}__${name}` + let n = 2 + while (used.has(candidate)) candidate = `${sanitize(skill.manifest.name)}__${name}__${n++}` + used.add(candidate) + out.push(renameTool(tool, candidate)) + } + } + return out +} + +/** + * Append the middleware contributed by skills to the agent's own middleware. + * Agent middleware runs first. Most skills contribute none. + */ +export function composeMiddleware(own: AiMiddleware[], skills: LoadedSkill[]): AiMiddleware[] { + const skillMw = skills.flatMap(s => s.middleware ?? []) + return skillMw.length > 0 ? [...own, ...skillMw] : [...own] +} + +/** + * Summarize what a skill would add to an agent — instructions size, tool names, + * resource names — without composing it. Use this to report a skill's surface + * before attaching it (the "surface-before-compose" half of the trust model). + */ +export function surface(skill: LoadedSkill): SkillSurface { + const s: SkillSurface = { + name: skill.manifest.name, + description: skill.manifest.description, + instructionChars: skill.instructions.length, + toolNames: skill.tools.map(t => t.definition.name), + resourceNames: skill.resources.map(r => r.name), + } + if (skill.manifest.trigger !== undefined) s.trigger = skill.manifest.trigger + return s +} + +/** Surface a list of skills. */ +export function surfaceAll(skills: LoadedSkill[]): SkillSurface[] { + return skills.map(surface) +} + +// ─── Internals ─────────────────────────────────────────────────── + +/** Constrain a name to the `[a-zA-Z0-9_-]` set providers accept for tool names. */ +function sanitize(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +/** Clone a tool with a new advertised name, preserving execute + modelOutput. */ +function renameTool(tool: AnyTool, name: string): AnyTool { + return { ...tool, definition: { ...tool.definition, name } } +} diff --git a/packages/ai-skills/src/index.ts b/packages/ai-skills/src/index.ts new file mode 100644 index 0000000..11dd938 --- /dev/null +++ b/packages/ai-skills/src/index.ts @@ -0,0 +1,30 @@ +/** + * `@gemstack/ai-skills` — portable capability bundles for `@gemstack/ai-sdk` + * agents. Load `SKILL.md` skills (instructions + tools + resources) and compose + * them onto an `Agent`. + * + * - {@link parseSkillManifest} — parse a `SKILL.md` into manifest + body + * - {@link loadSkill} / {@link loadSkills} — load skills from disk + * - {@link SkillRegistry} — discover skills by cheap frontmatter, load on demand + * - {@link composeInstructions} / {@link composeTools} / {@link composeMiddleware} — merge skills into an agent + * - {@link SkillfulAgent} — an `Agent` base that composes `skills()` declaratively + * - {@link surface} — inspect what a skill adds before composing it + */ +export { parseSkillManifest, SkillManifestError } from './manifest.js' +export { loadSkill, loadSkills, type LoadSkillOptions } from './loader.js' +export { SkillRegistry, type SkillIndexEntry } from './registry.js' +export { + composeInstructions, + composeTools, + composeMiddleware, + surface, + surfaceAll, +} from './compose.js' +export { SkillfulAgent } from './skillful-agent.js' +export type { + SkillManifest, + ParsedSkill, + LoadedSkill, + SkillResource, + SkillSurface, +} from './types.js' diff --git a/packages/ai-skills/src/loader.test.ts b/packages/ai-skills/src/loader.test.ts new file mode 100644 index 0000000..c677fa8 --- /dev/null +++ b/packages/ai-skills/src/loader.test.ts @@ -0,0 +1,92 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadSkill, loadSkills } from './loader.js' +import { SkillManifestError } from './manifest.js' + +let root: string + +const SKILL_MD = `--- +name: refunds +description: Issue refunds +trigger: a refund request +--- + +# Refunds + +Look up the order, then issue the refund.` + +// Plain-JS tools module (no imports) shaped like ai-sdk Tools. +const TOOLS_MJS = `export const issueRefund = { + definition: { name: 'issue_refund', description: 'Issue a refund', inputSchema: {} }, + execute: async () => ({ refunded: true }), +} +export default [issueRefund] +` + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'ai-skills-')) + + // Full skill: SKILL.md + tools.mjs + a resource file + const full = join(root, 'refunds') + await mkdir(join(full, 'resources'), { recursive: true }) + await writeFile(join(full, 'SKILL.md'), SKILL_MD) + await writeFile(join(full, 'tools.mjs'), TOOLS_MJS) + await writeFile(join(full, 'resources', 'policy.md'), '# Refund policy') + + // Body-only skill: SKILL.md, no tools, no resources + const bare = join(root, 'greeting') + await mkdir(bare, { recursive: true }) + await writeFile(join(bare, 'SKILL.md'), `---\nname: greeting\ndescription: Greet\n---\nSay hello.`) +}) + +after(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('loadSkill', () => { + it('loads manifest, instructions, tools, and resources from a directory', async () => { + const skill = await loadSkill(join(root, 'refunds')) + assert.equal(skill.manifest.name, 'refunds') + assert.equal(skill.manifest.trigger, 'a refund request') + assert.ok(skill.instructions.startsWith('# Refunds')) + assert.deepEqual(skill.tools.map(t => t.definition.name), ['issue_refund']) + assert.deepEqual(skill.resources.map(r => r.name), ['policy.md']) + assert.equal(skill.dir, join(root, 'refunds')) + }) + + it('does not double-count a tool exported both named and via default array', async () => { + const skill = await loadSkill(join(root, 'refunds')) + assert.equal(skill.tools.length, 1) + }) + + it('loadTools:false skips the tools module (surface-before-compose)', async () => { + const skill = await loadSkill(join(root, 'refunds'), { loadTools: false }) + assert.equal(skill.tools.length, 0) + assert.ok(skill.instructions.length > 0) // still gets instructions + assert.deepEqual(skill.resources.map(r => r.name), ['policy.md']) + }) + + it('handles a skill with no tools or resources', async () => { + const skill = await loadSkill(join(root, 'greeting')) + assert.deepEqual(skill.tools, []) + assert.deepEqual(skill.resources, []) + assert.equal(skill.instructions, 'Say hello.') + }) + + it('throws a clear error when SKILL.md is missing', async () => { + await assert.rejects( + () => loadSkill(join(root, 'does-not-exist')), + (e: unknown) => e instanceof SkillManifestError && /no SKILL\.md/.test((e as Error).message), + ) + }) +}) + +describe('loadSkills', () => { + it('loads several skills preserving order', async () => { + const skills = await loadSkills([join(root, 'greeting'), join(root, 'refunds')]) + assert.deepEqual(skills.map(s => s.manifest.name), ['greeting', 'refunds']) + }) +}) diff --git a/packages/ai-skills/src/loader.ts b/packages/ai-skills/src/loader.ts new file mode 100644 index 0000000..3b87fb0 --- /dev/null +++ b/packages/ai-skills/src/loader.ts @@ -0,0 +1,114 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { join, basename } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { AnyTool } from '@gemstack/ai-sdk' +import { parseSkillManifest, SkillManifestError } from './manifest.js' +import type { LoadedSkill, SkillResource } from './types.js' + +/** Candidate filenames for a skill's co-located tools module, in priority order. */ +const TOOLS_FILES = ['tools.js', 'tools.mjs', 'tools.cjs'] as const + +export interface LoadSkillOptions { + /** + * Import the skill's co-located tools module and merge its `tool()` exports. + * Defaults to `true`. Set `false` to load a skill's instructions + resources + * *without* executing its tools module — useful for surface-before-compose + * inspection of a skill you do not yet trust. + */ + loadTools?: boolean + /** Override the tools module filename (relative to the skill dir). */ + toolsFile?: string +} + +/** + * Load a single skill from a directory containing a `SKILL.md`. + * + * Loading is an explicit trust action: with `loadTools` on (the default) this + * imports the skill's tools module, which runs its top-level code. Only load + * skills from sources you trust. See the package README for the trust model. + */ +export async function loadSkill(dir: string, opts: LoadSkillOptions = {}): Promise { + const skillPath = join(dir, 'SKILL.md') + + let markdown: string + try { + markdown = await readFile(skillPath, 'utf8') + } catch { + throw new SkillManifestError(`no SKILL.md found in ${dir}`, dir) + } + + const { manifest, instructions } = parseSkillManifest(markdown, skillPath) + + const tools = opts.loadTools === false ? [] : await loadSkillTools(dir, opts.toolsFile) + const resources = await loadSkillResources(dir) + + return { manifest, instructions, tools, resources, dir } +} + +/** + * Load several skills, preserving order. Rejects if any single skill fails to + * load (use {@link loadSkill} in a `Promise.allSettled` if you want partial + * tolerance). + */ +export async function loadSkills(dirs: string[], opts: LoadSkillOptions = {}): Promise { + return Promise.all(dirs.map(dir => loadSkill(dir, opts))) +} + +// ─── Internals ─────────────────────────────────────────────────── + +async function loadSkillTools(dir: string, toolsFile?: string): Promise { + const candidates = toolsFile ? [toolsFile] : TOOLS_FILES + for (const file of candidates) { + const path = join(dir, file) + if (!(await fileExists(path))) continue + const mod = await import(pathToFileURL(path).href) as Record + return collectTools(mod) + } + return [] +} + +/** + * Pull `tool()` objects out of a module's exports. A value is treated as a tool + * when it is shaped like `ai-sdk`'s `Tool` (an object carrying a + * `definition.name`). Arrays of tools are flattened, so a module can + * `export default [toolA, toolB]` or export them individually. + */ +function collectTools(mod: Record): AnyTool[] { + const out: AnyTool[] = [] + const seen = new Set() + for (const value of Object.values(mod)) { + for (const candidate of Array.isArray(value) ? value : [value]) { + if (isTool(candidate) && !seen.has(candidate)) { + seen.add(candidate) + out.push(candidate) + } + } + } + return out +} + +function isTool(value: unknown): value is AnyTool { + if (typeof value !== 'object' || value === null) return false + const def = (value as { definition?: unknown }).definition + return typeof def === 'object' && def !== null + && typeof (def as { name?: unknown }).name === 'string' +} + +async function loadSkillResources(dir: string): Promise { + const resourceDir = join(dir, 'resources') + if (!(await isDirectory(resourceDir))) return [] + + const entries = await readdir(resourceDir, { withFileTypes: true }) + return entries + .filter(e => e.isFile()) + .map(e => ({ name: basename(e.name), path: join(resourceDir, e.name) })) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +async function fileExists(path: string): Promise { + try { return (await stat(path)).isFile() } catch { return false } +} + +async function isDirectory(path: string): Promise { + try { return (await stat(path)).isDirectory() } catch { return false } +} diff --git a/packages/ai-skills/src/manifest.test.ts b/packages/ai-skills/src/manifest.test.ts new file mode 100644 index 0000000..cb7588c --- /dev/null +++ b/packages/ai-skills/src/manifest.test.ts @@ -0,0 +1,74 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parseSkillManifest, SkillManifestError } from './manifest.js' + +const VALID = `--- +name: refunds +description: Issue and look up customer refunds +license: MIT +appliesTo: + - acme-app +trigger: handling a refund request +metadata: + author: acme +--- + +# Refunds + +Look up the order first, then issue the refund.` + +describe('parseSkillManifest', () => { + it('parses frontmatter into a validated manifest + trimmed body', () => { + const { manifest, instructions } = parseSkillManifest(VALID) + assert.equal(manifest.name, 'refunds') + assert.equal(manifest.description, 'Issue and look up customer refunds') + assert.equal(manifest.license, 'MIT') + assert.deepEqual(manifest.appliesTo, ['acme-app']) + assert.equal(manifest.trigger, 'handling a refund request') + assert.deepEqual(manifest.metadata, { author: 'acme' }) + assert.ok(instructions.startsWith('# Refunds')) + assert.ok(!instructions.startsWith('\n')) + }) + + it('throws when the frontmatter fence is missing', () => { + assert.throws( + () => parseSkillManifest('# Just markdown, no frontmatter'), + (e: unknown) => e instanceof SkillManifestError && /missing a YAML frontmatter/.test((e as Error).message), + ) + }) + + it('throws with field detail when a required field is absent', () => { + const md = `---\ndescription: no name here\n---\nbody` + assert.throws( + () => parseSkillManifest(md), + (e: unknown) => e instanceof SkillManifestError && /name/.test((e as Error).message), + ) + }) + + it('throws on invalid YAML frontmatter', () => { + const md = `---\nname: "unterminated\n---\nbody` + assert.throws(() => parseSkillManifest(md), SkillManifestError) + }) + + it('tolerates and drops unknown frontmatter keys', () => { + const md = `---\nname: x\ndescription: y\nfutureField: ignored\n---\nbody` + const { manifest } = parseSkillManifest(md) + assert.equal(manifest.name, 'x') + assert.ok(!('futureField' in manifest)) + }) + + it('handles a body-less skill (frontmatter only)', () => { + const md = `---\nname: x\ndescription: y\n---\n` + const { instructions } = parseSkillManifest(md) + assert.equal(instructions, '') + }) + + it('carries the source label into errors', () => { + try { + parseSkillManifest('no frontmatter', '/path/to/SKILL.md') + assert.fail('should have thrown') + } catch (e) { + assert.equal((e as SkillManifestError).source, '/path/to/SKILL.md') + } + }) +}) diff --git a/packages/ai-skills/src/manifest.ts b/packages/ai-skills/src/manifest.ts new file mode 100644 index 0000000..4358c06 --- /dev/null +++ b/packages/ai-skills/src/manifest.ts @@ -0,0 +1,68 @@ +import { parse as parseYaml } from 'yaml' +import { z } from 'zod' +import type { ParsedSkill, SkillManifest } from './types.js' + +/** + * Zod schema for `SKILL.md` frontmatter. Unknown keys are allowed and dropped + * (forward-compat with richer Anthropic-style manifests); `metadata` is the + * escape hatch for author-defined fields that should survive. + */ +const manifestSchema = z.object({ + name: z.string().min(1, 'skill name is required'), + description: z.string().min(1, 'skill description is required'), + license: z.string().optional(), + appliesTo: z.array(z.string()).optional(), + trigger: z.string().optional(), + skip: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +/** Raised when a `SKILL.md` is missing frontmatter or fails validation. */ +export class SkillManifestError extends Error { + constructor(message: string, readonly source?: string) { + super(message) + this.name = 'SkillManifestError' + } +} + +/** + * Parse a `SKILL.md` document into a validated {@link SkillManifest} and its + * markdown instructions body. + * + * @param markdown - the full `SKILL.md` file contents + * @param source - optional label (file path) used in error messages + */ +export function parseSkillManifest(markdown: string, source?: string): ParsedSkill { + const match = FRONTMATTER.exec(markdown) + if (!match) { + throw new SkillManifestError( + `SKILL.md is missing a YAML frontmatter block (expected a leading "---" fence)`, + source, + ) + } + + const [, frontmatter, body] = match + + let raw: unknown + try { + raw = parseYaml(frontmatter ?? '') + } catch (err) { + throw new SkillManifestError( + `SKILL.md frontmatter is not valid YAML: ${(err as Error).message}`, + source, + ) + } + + const parsed = manifestSchema.safeParse(raw) + if (!parsed.success) { + const issues = parsed.error.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ') + throw new SkillManifestError(`SKILL.md frontmatter is invalid: ${issues}`, source) + } + + return { + manifest: parsed.data as SkillManifest, + instructions: (body ?? '').trim(), + } +} diff --git a/packages/ai-skills/src/registry.test.ts b/packages/ai-skills/src/registry.test.ts new file mode 100644 index 0000000..fd7a6f6 --- /dev/null +++ b/packages/ai-skills/src/registry.test.ts @@ -0,0 +1,78 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SkillRegistry } from './registry.js' + +let root: string + +async function writeSkill(dir: string, name: string, withTools = false): Promise { + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name} desc\n---\n${name} body`) + if (withTools) { + await writeFile( + join(dir, 'tools.mjs'), + `export const t = { definition: { name: '${name}_tool', description: 'x', inputSchema: {} }, execute: async () => 'ok' }`, + ) + } +} + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'ai-skills-reg-')) + await writeSkill(join(root, 'alpha'), 'alpha', true) + await writeSkill(join(root, 'beta'), 'beta') + // a non-skill directory (no SKILL.md) that discovery must ignore + await mkdir(join(root, 'not-a-skill'), { recursive: true }) + await writeFile(join(root, 'not-a-skill', 'readme.txt'), 'nope') +}) + +after(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('SkillRegistry', () => { + it('discovers SKILL.md subdirs by frontmatter and ignores non-skill dirs', async () => { + const registry = new SkillRegistry() + const found = await registry.discover(root) + const names = found.map(e => e.manifest.name).sort() + assert.deepEqual(names, ['alpha', 'beta']) + assert.deepEqual(registry.list().map(e => e.manifest.name).sort(), ['alpha', 'beta']) + assert.equal(registry.get('alpha')?.dir, join(root, 'alpha')) + }) + + it('discover() reads only frontmatter — entries carry a manifest but are not loaded', async () => { + const registry = new SkillRegistry() + const [entry] = await registry.discover(root) + assert.ok(entry?.manifest.name) + assert.ok(!('tools' in entry!)) // index entry has no tools until load() + }) + + it('load() fully loads a discovered skill and caches it', async () => { + const registry = new SkillRegistry() + await registry.discover(root) + const a1 = await registry.load('alpha') + assert.deepEqual(a1.tools.map(t => t.definition.name), ['alpha_tool']) + const a2 = await registry.load('alpha') + assert.equal(a1, a2) // same cached instance + }) + + it('load() throws for an undiscovered name', async () => { + const registry = new SkillRegistry() + await registry.discover(root) + await assert.rejects(() => registry.load('ghost'), /no skill named "ghost"/) + }) + + it('returns an empty index for a missing root', async () => { + const registry = new SkillRegistry() + const found = await registry.discover(join(root, 'nope')) + assert.deepEqual(found, []) + }) + + it('loadAll loads discovered skills by name in order', async () => { + const registry = new SkillRegistry() + await registry.discover(root) + const skills = await registry.loadAll(['beta', 'alpha']) + assert.deepEqual(skills.map(s => s.manifest.name), ['beta', 'alpha']) + }) +}) diff --git a/packages/ai-skills/src/registry.ts b/packages/ai-skills/src/registry.ts new file mode 100644 index 0000000..3691950 --- /dev/null +++ b/packages/ai-skills/src/registry.ts @@ -0,0 +1,90 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { parseSkillManifest } from './manifest.js' +import { loadSkill, type LoadSkillOptions } from './loader.js' +import type { LoadedSkill, SkillManifest } from './types.js' + +/** A discovered-but-not-yet-loaded skill: its manifest + where it lives. */ +export interface SkillIndexEntry { + manifest: SkillManifest + dir: string +} + +/** + * Discovers `SKILL.md` bundles under a set of root directories and loads them + * on demand. Discovery parses only the cheap frontmatter (the manifest), so + * an app can index hundreds of skills and pull a skill's full body + tools + * into memory only when it actually composes it — the progressive-disclosure + * half of the skill model. + * + * Trust boundary: `discover()` only reads frontmatter and never executes skill + * code. Code runs at `load()` time (it imports the tools module), so only call + * `load()` on skills from trusted sources. + */ +export class SkillRegistry { + private readonly entries = new Map() + private readonly loaded = new Map() + + /** + * Scan a directory whose immediate subdirectories each contain a `SKILL.md`, + * indexing each by manifest `name`. Returns the entries found in this scan. + * A later scan with a duplicate name overrides the earlier entry (last wins), + * mirroring how registered/allowlisted sources layer. + */ + async discover(root: string): Promise { + const found: SkillIndexEntry[] = [] + let subdirs: string[] + try { + const dirents = await readdir(root, { withFileTypes: true }) + subdirs = dirents.filter(d => d.isDirectory()).map(d => join(root, d.name)) + } catch { + return found + } + + for (const dir of subdirs) { + const skillPath = join(dir, 'SKILL.md') + if (!(await fileExists(skillPath))) continue + const { manifest } = parseSkillManifest(await readFile(skillPath, 'utf8'), skillPath) + const entry: SkillIndexEntry = { manifest, dir } + this.entries.set(manifest.name, entry) + found.push(entry) + } + return found + } + + /** All indexed (not necessarily loaded) entries, in insertion order. */ + list(): SkillIndexEntry[] { + return [...this.entries.values()] + } + + /** Look up an indexed entry by manifest name. */ + get(name: string): SkillIndexEntry | undefined { + return this.entries.get(name) + } + + /** + * Fully load a discovered skill by name (parses the body, imports its tools + * module, gathers resources). Cached: a second `load()` of the same name + * returns the same instance unless `force` is set. + */ + async load(name: string, opts: LoadSkillOptions & { force?: boolean } = {}): Promise { + const cached = this.loaded.get(name) + if (cached && !opts.force) return cached + + const entry = this.entries.get(name) + if (!entry) throw new Error(`[ai-skills] no skill named "${name}" has been discovered`) + + const skill = await loadSkill(entry.dir, opts) + this.loaded.set(name, skill) + return skill + } + + /** Load several discovered skills by name, preserving the requested order. */ + async loadAll(names: string[], opts: LoadSkillOptions = {}): Promise { + return Promise.all(names.map(name => this.load(name, opts))) + } +} + +async function fileExists(path: string): Promise { + try { return (await stat(path)).isFile() } catch { return false } +} diff --git a/packages/ai-skills/src/skillful-agent.test.ts b/packages/ai-skills/src/skillful-agent.test.ts new file mode 100644 index 0000000..e3a03b9 --- /dev/null +++ b/packages/ai-skills/src/skillful-agent.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { AiFake, toolDefinition, getMessageText } from '@gemstack/ai-sdk' +import { z } from 'zod' +import { SkillfulAgent } from './skillful-agent.js' +import type { LoadedSkill } from './types.js' + +function tool(name: string, description = name) { + return toolDefinition({ name, description, inputSchema: z.object({}) }).server(async () => 'ok') +} + +function skill(name: string, instructions: string, tools: ReturnType[] = []): LoadedSkill { + return { manifest: { name, description: `${name} skill` }, instructions, tools, resources: [] } +} + +const refunds = skill('refunds', 'Always verify the order before refunding.', [tool('issue_refund')]) + +class SupportAgent extends SkillfulAgent { + baseInstructions() { return 'You are a support agent.' } + override skills() { return [refunds] } + override baseTools() { return [tool('escalate')] } +} + +describe('SkillfulAgent composition (sync hooks ai-sdk reads)', () => { + it('instructions() = base identity then each skill', () => { + const out = new SupportAgent().instructions() + assert.ok(out.startsWith('You are a support agent.')) + assert.ok(out.includes('# Skill: refunds')) + assert.ok(out.includes('Always verify the order before refunding.')) + }) + + it('tools() unions own tools with skill tools', () => { + const names = new SupportAgent().tools().map(t => t.definition.name) + assert.deepEqual(names.sort(), ['escalate', 'issue_refund']) + }) + + it('own tool wins a name collision; the skill tool is namespaced', () => { + class Collide extends SkillfulAgent { + baseInstructions() { return 'base' } + override skills() { return [skill('s', '', [tool('escalate', 'from skill')])] } + override baseTools() { return [tool('escalate', 'from agent')] } + } + const tools = new Collide().tools() + assert.equal(tools.find(t => t.definition.name === 'escalate')?.definition.description, 'from agent') + assert.ok(tools.some(t => t.definition.name === 's__escalate')) + }) + + it('defaults to no skills / no tools when only baseInstructions is given', () => { + class Plain extends SkillfulAgent { + baseInstructions() { return 'Just me.' } + } + const a = new Plain() + assert.equal(a.instructions(), 'Just me.') + assert.deepEqual(a.tools(), []) + assert.deepEqual(a.middleware(), []) + }) +}) + +describe('SkillfulAgent through the agent loop', () => { + it('ai-sdk sends the composed system prompt and skill tools to the provider', async () => { + const fake = AiFake.fake() + try { + fake.respondWith('done') + await new SupportAgent().prompt('refund order #1') + + const call = fake.getCalls()[0] + assert.ok(call, 'provider should have been called') + + const systemMsg = call.messages.find(m => m.role === 'system') + const system = systemMsg ? getMessageText(systemMsg.content) : '' + assert.ok(system.includes('You are a support agent.'), 'base identity reached the provider') + assert.ok(system.includes('Always verify the order before refunding.'), 'skill instructions reached the provider') + + const toolNames = (call.tools ?? []).map(t => t.name).sort() + assert.deepEqual(toolNames, ['escalate', 'issue_refund']) + } finally { + fake.restore() + } + }) +}) diff --git a/packages/ai-skills/src/skillful-agent.ts b/packages/ai-skills/src/skillful-agent.ts new file mode 100644 index 0000000..86c6429 --- /dev/null +++ b/packages/ai-skills/src/skillful-agent.ts @@ -0,0 +1,65 @@ +import { Agent } from '@gemstack/ai-sdk' +import type { AnyTool, AiMiddleware, HasTools, HasMiddleware } from '@gemstack/ai-sdk' +import { composeInstructions, composeTools, composeMiddleware } from './compose.js' +import type { LoadedSkill } from './types.js' + +/** + * An {@link Agent} that composes {@link LoadedSkill}s declaratively. + * + * Skills augment the agent; the agent's own declarations stay authoritative. + * You author your base identity and own tools/middleware in `baseInstructions()` + * / `baseTools()` / `baseMiddleware()`, and list the skills to compose in + * `skills()` — mirroring how a plain agent declares `tools()` / `middleware()`. + * + * The `instructions()`, `tools()`, and `middleware()` that `@gemstack/ai-sdk` + * reads are sealed finals here: they merge your `base*` declarations with the + * skills. **Override the `base*` hooks, not these** — overriding `tools()` or + * `instructions()` directly drops the skill composition. + * + * Skills must be loaded before the agent runs (loading is async — file IO + + * importing the tools module — while these hooks are synchronous). Load them + * once at module init and return the loaded objects from `skills()`: + * + * ```ts + * const refunds = await loadSkill('./skills/refunds') + * + * class SupportAgent extends SkillfulAgent { + * baseInstructions() { return 'You are a support agent.' } + * skills() { return [refunds] } + * baseTools() { return [escalateTool] } // wins over a same-named skill tool + * } + * ``` + */ +export abstract class SkillfulAgent extends Agent implements HasTools, HasMiddleware { + /** Your agent's base identity. Skill instructions are appended after it. */ + abstract baseInstructions(): string + + /** The skills composed onto this agent. Override to declare them. */ + skills(): LoadedSkill[] { + return [] + } + + /** Your agent's own tools — authoritative on a name collision with a skill tool. */ + baseTools(): AnyTool[] { + return [] + } + + /** Your agent's own middleware. Runs before any skill-contributed middleware. */ + baseMiddleware(): AiMiddleware[] { + return [] + } + + // ─── Sealed finals read by @gemstack/ai-sdk — override base* instead ─── + + instructions(): string { + return composeInstructions(this.baseInstructions(), this.skills()) + } + + tools(): AnyTool[] { + return composeTools(this.baseTools(), this.skills()) + } + + middleware(): AiMiddleware[] { + return composeMiddleware(this.baseMiddleware(), this.skills()) + } +} diff --git a/packages/ai-skills/src/types.ts b/packages/ai-skills/src/types.ts new file mode 100644 index 0000000..8968d0a --- /dev/null +++ b/packages/ai-skills/src/types.ts @@ -0,0 +1,77 @@ +import type { AnyTool, AiMiddleware } from '@gemstack/ai-sdk' + +/** + * The parsed YAML frontmatter of a `SKILL.md` bundle. Mirrors the + * `boost/skills` convention shipped in `@gemstack/ai-sdk` and the Anthropic + * Agent Skills shape, so a skill authored for one loads in the other. + */ +export interface SkillManifest { + /** Unique skill name (kebab-case by convention, e.g. `pdf-forms`). */ + name: string + /** One-line summary — used to decide relevance during discovery. */ + description: string + /** SPDX license id, optional. */ + license?: string + /** + * Hints (package names / globs) the skill applies to. Free-form; the loader + * does not enforce these — they document intent and aid discovery. + */ + appliesTo?: string[] + /** When to load this skill (natural-language cue, for progressive disclosure). */ + trigger?: string + /** When NOT to load it (points at a sibling skill instead). */ + skip?: string + /** Arbitrary author metadata; passed through untouched. */ + metadata?: Record +} + +/** A `SKILL.md` split into its validated manifest and its markdown body. */ +export interface ParsedSkill { + manifest: SkillManifest + /** The markdown instructions body (everything after the frontmatter). */ + instructions: string +} + +/** A non-executable resource file shipped alongside a skill. */ +export interface SkillResource { + /** File name relative to the skill's `resources/` directory. */ + name: string + /** Absolute path on disk. */ + path: string +} + +/** + * A fully loaded, ready-to-compose skill: its manifest, its instructions body, + * the `tool()` objects it contributes, and any resource files. Loading is + * async (file IO + importing the co-located tools module); composition onto an + * agent is synchronous. + */ +export interface LoadedSkill { + manifest: SkillManifest + instructions: string + tools: AnyTool[] + resources: SkillResource[] + /** Absolute path to the skill directory, when loaded from disk. */ + dir?: string + /** Middleware the skill contributes, if any (advanced; usually empty). */ + middleware?: AiMiddleware[] +} + +/** + * A read-only summary of what a skill would add to an agent — surfaced + * *before* composition so a caller can inspect a skill's instructions, tool + * names, and resources without attaching it. Part of the explicit trust + * boundary: loading a skill runs its tools module, so callers should know what + * they are about to compose. + */ +export interface SkillSurface { + name: string + description: string + trigger?: string + /** Character length of the instructions body. */ + instructionChars: number + /** Names of the tools the skill contributes. */ + toolNames: string[] + /** Names of the resource files the skill ships. */ + resourceNames: string[] +} diff --git a/packages/ai-skills/tsconfig.build.json b/packages/ai-skills/tsconfig.build.json new file mode 100644 index 0000000..e578064 --- /dev/null +++ b/packages/ai-skills/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/ai-skills/tsconfig.json b/packages/ai-skills/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/ai-skills/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/ai-skills/tsconfig.test.json b/packages/ai-skills/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/packages/ai-skills/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 940de42..1eb0ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,25 @@ importers: specifier: '>=4.70.0' version: 6.44.0(ws@8.21.0)(zod@4.4.3) + packages/ai-skills: + dependencies: + '@gemstack/ai-sdk': + specifier: workspace:^ + version: link:../ai-sdk + yaml: + specifier: ^2.5.0 + version: 2.9.0 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages: '@anthropic-ai/sdk@0.105.0': @@ -1333,6 +1352,11 @@ packages: utf-8-validate: optional: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -2868,6 +2892,8 @@ snapshots: ws@8.21.0: optional: true + yaml@2.9.0: {} + yargs-parser@20.2.9: optional: true