@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
JavaScript
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