@tanstack/ai-skills
Version:
Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.
427 lines (401 loc) • 14.7 kB
text/typescript
import { existsSync, watch } from 'node:fs'
import { basename, dirname } from 'node:path'
import { firstSentence } from '@tanstack/ai'
import { defineCommand, definePlugin } from '@tanstack/ai-harness'
import { renderCatalog } from './catalog'
import { estimateTokens, withSkills } from './middleware'
import { skillDirectory } from './node'
import type { FSWatcher } from 'node:fs'
import type { AnyChatMiddleware } from '@tanstack/ai'
import type {
HostPluginContext,
PluginSetupContext,
} from '@tanstack/ai-harness'
import type { SkillsOptions } from './middleware'
import type { ModelFamily, SkillMetadata, SkillSource } from './types'
export interface SkillsPluginOptions extends SkillsOptions {
/**
* The skill folders. Each folder holds skill folders with a `SKILL.md`.
* When two folders have a skill with the same name, the first folder wins.
* A folder that does not exist yet is used once it exists.
*/
dirs: ReadonlyArray<string>
/**
* Names that a UI handles itself, for example its own `/help`. A skill
* with one of these names gets the command `/skill:<name>`.
*/
reserved?: ReadonlyArray<string>
}
/** A skill, and the folder it comes from. */
interface FoundSkill {
skill: SkillMetadata
dir: string
}
/** The `code` of a Node.js error, for example `ENOENT`. */
function codeOf(error: unknown) {
const hasCode = error instanceof Error && 'code' in error
return hasCode ? error.code : undefined
}
/** The catalog with only the first sentence of each description. */
function shortCatalog(listed: Array<SkillMetadata>, family: ModelFamily) {
return renderCatalog(
listed.map((skill) => ({
...skill,
description: firstSentence(skill.description),
})),
family,
)
}
/** The text of the turn that `/<name> [task]` starts. */
function skillPrompt(name: string, input: unknown) {
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: ReadonlyArray<string>,
{ resources, logger }: HostPluginContext,
) {
/** The watcher of each folder, and the path it watches. */
const watchers = await resources.acquire(
() => new Map<string, { watcher: FSWatcher; path: string }>(),
(open) => {
for (const { watcher } of open.values()) watcher.close()
open.clear()
},
)
/** The folders that cannot be watched. They were warned about once. */
const failed = new Set<string>()
/** The sessions to tell when a folder changed. */
const listeners = new Set<() => void>()
// A save often fires several watch events: wait until they stop.
let timer: ReturnType<typeof setTimeout> | undefined
const soon = () => {
clearTimeout(timer)
timer = setTimeout(() => {
for (const listener of listeners) listener()
}, 200)
timer.unref?.()
}
resources.signal.addEventListener('abort', () => clearTimeout(timer))
const drop = (dir: string) => {
watchers.get(dir)?.watcher.close()
watchers.delete(dir)
}
/** Watch `path` for the folder `dir`. Throws when `watch` throws. */
const start = (dir: string, path: string, recursive: boolean) => {
const watcher = watch(
path,
{ recursive, persistent: false },
(event, name) => {
// The watched folder itself went away (a delete or a move). Its
// watcher is dead, so the next sync watches it again.
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: string, path: string) => {
const isOwn = path === dir
try {
return start(dir, path, isOwn)
} catch (error) {
// ponytail: Linux has no recursive watch before Node 19.1. Then only
// the folder is watched: an added or removed skill shows at once, an
// edited SKILL.md at the next turn.
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
// Some watchers stop with no event when their folder goes away, so
// check that the folder is still there.
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) {
// The folder went away since the check. The next sync tries again.
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 = new Map<string, FoundSkill>()
for (const dir of dirs) {
// A folder that throws, at once or later, gives no skills.
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: string) => {
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: SkillSource = {
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: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
/** The part of {@link skills} that each session sets up. */
async function sessionPart(
ctx: PluginSetupContext,
folders: Awaited<ReturnType<typeof sharedFolders>>,
options: SkillsPluginOptions,
) {
const { dirs, reserved = [], ...skillsOptions } = options
// `skills` is this plugin's own command, so a skill named `skills` gets
// the prefix too.
const taken = new Set(['skills', ...reserved])
// The skill commands of this session: command name to skill name and the
// description the command has.
const mine = new Map<string, { name: string; description: string }>()
// Skills called with their command stay in the model's list.
const called = new Set<string>()
let found = new Map<string, FoundSkill>()
const closed = () => ctx.resources.signal.aborted
const skillCommand = (name: string, description: string) =>
defineCommand({
description: `Skill: ${firstSentence(description)}`,
input: {
type: 'string',
description: 'What the skill should do. Optional.',
},
// The command's own session, so the turn runs as the user who ran the
// command.
run: (input: unknown, command) => {
called.add(name)
void 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 = new Map<string, { name: string; description: string }>()
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) {
const isSame = mine.get(command)?.description === entry.description
if (isSame) continue
try {
ctx.commands.set(command, skillCommand(entry.name, entry.description))
mine.set(command, entry)
} catch {
// Another plugin took `skill:<name>` too. The skill stays in the
// model's list, with no command.
}
}
}
// Updates run one after the other.
let updating = Promise.resolve()
const sync = () => {
updating = updating.then(update).catch(() => {})
return updating
}
await ctx.resources.acquire(
() => folders.onChange(() => void sync()),
(stop) => stop(),
)
// Commands of the plugins after this one exist only once all are set up,
// so the first sync waits for that.
void ctx.commands.ready.then(sync)
const hasOwnCatalog =
skillsOptions.renderCatalog !== undefined ||
skillsOptions.instructionTemplate !== undefined
// The catalog that `withSkills` renders, so the count matches it.
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: Array<SkillMetadata>, limit: number) => {
const ordered = [
...listed.filter((skill) => called.has(skill.name)),
...listed.filter((skill) => !called.has(skill.name)),
]
const kept: Array<SkillMetadata> = []
for (const skill of ordered) {
const tooLong =
estimateTokens(render([...kept, skill], 'anthropic')) > limit
if (tooLong) break
kept.push(skill)
}
return kept
}
// The sync runs first, so the commands match the folders at the start of
// each turn.
const middleware: Array<AnyChatMiddleware> = [
{ name: 'tanstack/skills:sync', setup: () => sync() },
withSkills(folders.source, {
...skillsOptions,
...(hasOwnCatalog ? {} : { renderCatalog: shortCatalog }),
onLimitExceeded: skillsOptions.onLimitExceeded ?? keepEarlier,
}),
]
return {
middleware,
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]),
)
const lines = [...found.values()].map(({ skill, dir }) => {
const command = commandOf.get(skill.name)
const name = command ? `/${command}` : skill.name
const note =
command && command !== skill.name
? ` (/${skill.name} is taken)`
: ''
return `${name} ${dir}${note}`
})
return lines.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],
* })
* ```
*/
export function skills(options: SkillsPluginOptions) {
return definePlugin({
name: 'tanstack/skills',
// The folder watchers are shared by the sessions of the host. The
// commands and the model's skill list are per session.
lifetime: 'host',
setup: async (host) => {
const folders = await sharedFolders(options.dirs, host)
return {
session: (ctx) => sessionPart(ctx, folders, options),
}
},
})
}