UNPKG

@tanstack/ai-skills

Version:

Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.

296 lines (295 loc) • 10.8 kB
import { renderCatalog } from "./catalog.js"; import { estimateTokens, withSkills } from "./middleware.js"; import { skillDirectory } from "./node/index.js"; import { firstSentence } from "@tanstack/ai"; import { existsSync, watch } from "node:fs"; import { basename, dirname } from "node:path"; import { defineCommand, definePlugin } from "@tanstack/ai-harness"; //#region src/harness.ts /** The `code` of a Node.js error, for example `ENOENT`. */ function codeOf(error) { return error instanceof Error && "code" in error ? error.code : void 0; } /** The catalog with only the first sentence of each description. */ function shortCatalog(listed, family) { return renderCatalog(listed.map((skill) => ({ ...skill, description: firstSentence(skill.description) })), family); } /** The text of the turn that `/<name> [task]` starts. */ function skillPrompt(name, input) { const task = typeof input === "string" ? input.trim() : ""; return task === "" ? `Use the ${name} skill.` : `Use the ${name} skill: ${task}`; } /** * The part of {@link skills} that every session of a harness on a host * shares: the watchers of the folders. A change only tells the sessions to * sync. Each sync reads the skills from disk again. Warnings go to the * logger of the host. */ async function sharedFolders(dirs, { resources, logger }) { /** The watcher of each folder, and the path it watches. */ const watchers = await resources.acquire(() => /* @__PURE__ */ new Map(), (open) => { for (const { watcher } of open.values()) watcher.close(); open.clear(); }); /** The folders that cannot be watched. They were warned about once. */ const failed = /* @__PURE__ */ new Set(); /** The sessions to tell when a folder changed. */ const listeners = /* @__PURE__ */ new Set(); let timer; const soon = () => { clearTimeout(timer); timer = setTimeout(() => { for (const listener of listeners) listener(); }, 200); timer.unref?.(); }; resources.signal.addEventListener("abort", () => clearTimeout(timer)); const drop = (dir) => { watchers.get(dir)?.watcher.close(); watchers.delete(dir); }; /** Watch `path` for the folder `dir`. Throws when `watch` throws. */ const start = (dir, path, recursive) => { const watcher = watch(path, { recursive, persistent: false }, (event, name) => { if (event === "rename" && name === basename(path)) drop(dir); soon(); }); watcher.on("error", () => { drop(dir); soon(); }); watchers.set(dir, { watcher, path }); }; /** Watch the folder `dir` at `path`: the folder itself, or a parent. */ const watchAt = (dir, path) => { const isOwn = path === dir; try { return start(dir, path, isOwn); } catch (error) { if (codeOf(error) !== "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") throw error; logger.warn(`skills: cannot watch the subfolders of ${dir} on this platform. An edited SKILL.md shows at the next turn.`); return start(dir, path, false); } }; /** * Watch each skill folder. While a folder does not exist, watch its * nearest folder that does, so the skills show as soon as you make the * folders, one level at a time. */ const watchFolders = () => { if (resources.signal.aborted) return; for (const dir of dirs) { if (failed.has(dir)) continue; let path = dir; while (!existsSync(path) && dirname(path) !== path) path = dirname(path); if (watchers.get(dir)?.path === path) continue; drop(dir); try { watchAt(dir, path); } catch (error) { if (codeOf(error) === "ENOENT") continue; failed.add(dir); const reason = error instanceof Error ? error.message : String(error); logger.warn(`skills: cannot watch ${dir} (${reason}). Its skills change at the next turn.`); } } }; /** * The skills of `dirs`, by name, in folder order. The first folder wins a * name. A folder that is missing or cannot be read gives no skills, and a * broken `SKILL.md` is left out. */ const read = async () => { watchFolders(); const found = /* @__PURE__ */ new Map(); for (const dir of dirs) { const listed = await Promise.resolve().then(() => skillDirectory(dir, { strict: false }).list()).catch(() => []); for (const skill of listed) if (!found.has(skill.name)) found.set(skill.name, { skill, dir }); } return found; }; /** The source of the folder that has the skill `name` now. */ const folderOf = async (name) => { const entry = (await read()).get(name); if (!entry) throw new Error(`No skill named "${name}" in ${dirs.join(", ")}.`); return skillDirectory(entry.dir, { strict: false }); }; const source = { list: async () => [...(await read()).values()].map((entry) => entry.skill), load: async (name) => (await folderOf(name)).load(name), listResources: async (name) => await (await folderOf(name)).listResources?.(name) ?? [], listScripts: async (name) => await (await folderOf(name)).listScripts?.(name) ?? [], readResource: async (name, path) => { const folder = await folderOf(name); if (!folder.readResource) throw new Error(`Skill "${name}" has no resource files.`); return folder.readResource(name, path); } }; watchFolders(); return { source, read, /** Call `listener` after a folder changed. Returns a function that stops it. */ onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; } }; } /** The part of {@link skills} that each session sets up. */ async function sessionPart(ctx, folders, options) { const { dirs, reserved = [], ...skillsOptions } = options; const taken = /* @__PURE__ */ new Set(["skills", ...reserved]); const mine = /* @__PURE__ */ new Map(); const called = /* @__PURE__ */ new Set(); let found = /* @__PURE__ */ new Map(); const closed = () => ctx.resources.signal.aborted; const skillCommand = (name, description) => defineCommand({ description: `Skill: ${firstSentence(description)}`, input: { type: "string", description: "What the skill should do. Optional." }, run: (input, command) => { called.add(name); command.session.send(skillPrompt(name, input)); } }); /** Make the commands match the skills in the folders now. */ const update = async () => { if (closed()) return; found = await folders.read(); if (closed()) return; const next = /* @__PURE__ */ new Map(); for (const { skill } of found.values()) { const isFree = !taken.has(skill.name) && (mine.has(skill.name) || !ctx.commands.has(skill.name)); next.set(isFree ? skill.name : `skill:${skill.name}`, { name: skill.name, description: skill.description }); } for (const [command, entry] of mine) { if (next.get(command)?.name === entry.name) continue; ctx.commands.delete(command); mine.delete(command); } for (const [command, entry] of next) { if (mine.get(command)?.description === entry.description) continue; try { ctx.commands.set(command, skillCommand(entry.name, entry.description)); mine.set(command, entry); } catch {} } }; let updating = Promise.resolve(); const sync = () => { updating = updating.then(update).catch(() => {}); return updating; }; await ctx.resources.acquire(() => folders.onChange(() => void sync()), (stop) => stop()); ctx.commands.ready.then(sync); const hasOwnCatalog = skillsOptions.renderCatalog !== void 0 || skillsOptions.instructionTemplate !== void 0; const render = skillsOptions.renderCatalog ?? (hasOwnCatalog ? renderCatalog : shortCatalog); /** * Over the token cap: the called skills first, then the folder order, * until the list is full. The XML list of Anthropic models is the longest * form, so the count uses it. */ const keepEarlier = (listed, limit) => { const ordered = [...listed.filter((skill) => called.has(skill.name)), ...listed.filter((skill) => !called.has(skill.name))]; const kept = []; for (const skill of ordered) { if (estimateTokens(render([...kept, skill], "anthropic")) > limit) break; kept.push(skill); } return kept; }; return { middleware: [{ name: "tanstack/skills:sync", setup: () => sync() }, withSkills(folders.source, { ...skillsOptions, ...hasOwnCatalog ? {} : { renderCatalog: shortCatalog }, onLimitExceeded: skillsOptions.onLimitExceeded ?? keepEarlier })], commands: { skills: defineCommand({ description: "List the skills, their folders, and their commands", run: async () => { await sync(); if (found.size === 0) return `No skills yet. Add a folder with a SKILL.md to one of: ${dirs.join(", ")}`; const commandOf = new Map([...mine].map(([command, entry]) => [entry.name, command])); return [...found.values()].map(({ skill, dir }) => { const command = commandOf.get(skill.name); return `${command ? `/${command}` : skill.name} ${dir}${command && command !== skill.name ? ` (/${skill.name} is taken)` : ""}`; }).join("\n"); } }) } }; } /** * A harness plugin that gives the model the skills of `dirs`, and makes each * skill a command: `/<name> [task]` starts a turn that tells the model to use * the skill. A skill whose name another command has is `/skill:<name>`. * `/skills` lists every skill, its folder, and its command. * * The plugin watches each folder once for each host, and every session of * the harness that uses this plugin object shares the watchers. Create it * once, outside the `plugins` factory. It reads the folders again at the start * of each turn. A skill that you add, change, or remove while a session runs * changes the commands at once, with no restart. When a folder cannot be * watched, the plugin warns once, to the host logger * (`createHarnessHost({ logger })`), and its skills change at the next turn. * * The model's skill list has one line for each skill: its name and the first * sentence of its description. When the list is longer than * `maxCatalogTokens`, the skills of the earlier folders stay in it, and a * skill that you called with its command always stays. * * @param options.dirs - The skill folders, the first one wins on a name. * @param options.reserved - Command names a UI handles itself. * * @example * ```ts * import { homedir } from 'node:os' * import { join } from 'node:path' * import { skills } from '@tanstack/ai-skills/harness' * * const agentSkills = skills({ * dirs: ['./.agents/skills', join(homedir(), '.acme', 'skills')], * }) * * defineHarness({ * name: 'acme/agent', * adapter, * plugins: () => [agentSkills], * }) * ``` */ function skills(options) { return definePlugin({ name: "tanstack/skills", lifetime: "host", setup: async (host) => { const folders = await sharedFolders(options.dirs, host); return { session: (ctx) => sessionPart(ctx, folders, options) }; } }); } //#endregion export { skills }; //# sourceMappingURL=harness.js.map