src/server/skills.ts
Rama main · réplica de lectura del repositorio original.
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync } from 'node:fs';
import path from 'node:path';
import { DATA_DIR, dataPath, APP_ROOT } from './paths.ts';
import { getBuiltinSkillNames } from './seed.ts';
import { dataSync } from './data-sync.ts';
import { injectFile } from './file-inject.ts';
import { getGlobalMcpConfig } from './mcp.ts';
const SKILLS_DIR = dataPath('skills');
const DEFAULTS_PATH = dataPath('skills-defaults.json');
function ensureDir() {
mkdirSync(SKILLS_DIR, { recursive: true });
}
export interface SkillMeta {
description?: string;
model?: string;
allowedTools?: string[];
argumentHint?: string;
/** Per-skill turn budget. Gap-fills `directives.turns` for the turn that invokes the skill —
* inline `[turns:N]` and a session-pinned value both still win. Capped at MAX_SKILL_TURNS. */
turns?: number;
triggers?: string[];
expires?: string;
origin?: string;
reviewed?: boolean;
/** `managed-by: <module>@<version>` — set on skills rendered by a data-plane module. */
managedBy?: string;
}
/** Ceiling on a skill's self-declared turn budget. A skill file is data — it is edited without a
* deploy and shipped by rsync — so an unbounded number there is an unattended spend hazard. 300 is
* one step above the 250 the video-ad pipeline needs in practice, so no real skill is clipped,
* while a typo'd `turns: 30000` costs a bounded worst case instead of an open-ended one. A human
* typing `[turns:N]` inline is NOT capped — that is a deliberate, attended choice. */
export const MAX_SKILL_TURNS = 300;
/** Largest turn budget declared by any of the named skills, clamped to MAX_SKILL_TURNS. */
export function resolveSkillTurns(names: (string | undefined)[]): number | undefined {
let best = 0;
for (const name of names) {
if (!name) continue;
const t = getSkill(name)?.meta.turns;
if (t && t > best) best = t;
}
return best ? Math.min(best, MAX_SKILL_TURNS) : undefined;
}
export function isExpired(meta: SkillMeta): boolean {
if (!meta.expires) return false;
return new Date(meta.expires).getTime() < Date.now();
}
export interface Skill {
name: string;
content: string;
builtin: boolean;
meta: SkillMeta;
}
export function parseSkillFrontmatter(content: string): { meta: SkillMeta; body: string } {
if (!content.startsWith('---')) return { meta: {}, body: content };
const end = content.indexOf('---', 3);
if (end < 0) return { meta: {}, body: content };
const yaml = content.slice(3, end).trim();
const body = content.slice(end + 3).trim();
const meta: SkillMeta = {};
const lines = yaml.split('\n');
let currentKey = '';
for (const line of lines) {
const listMatch = line.match(/^\s+-\s+"?([^"]+)"?\s*$/);
if (listMatch && currentKey === 'triggers') {
(meta.triggers ??= []).push(listMatch[1]);
continue;
}
const [k, ...rest] = line.split(':');
const key = k.trim();
const val = rest.join(':').trim();
currentKey = key;
if (key === 'description') meta.description = val;
if (key === 'model') meta.model = val;
if (key === 'allowed-tools') meta.allowedTools = val.split(',').map(s => s.trim());
if (key === 'argument-hint') meta.argumentHint = val;
if ((key === 'turns' || key === 'max-turns') && /^\d+$/.test(val)) meta.turns = parseInt(val, 10);
if (key === 'triggers' && val) {
try { meta.triggers = JSON.parse(val); } catch {
meta.triggers = val.split(',').map(s => s.trim());
}
}
if (key === 'expires') meta.expires = val;
if (key === 'origin') meta.origin = val;
if (key === 'managed-by') meta.managedBy = val;
if (key === 'reviewed') meta.reviewed = val === 'true' ? true : val === 'false' ? false : undefined;
}
return { meta, body };
}
export function isBuiltin(name: string): boolean {
return getBuiltinSkillNames().includes(name);
}
export function listSkills(): string[] {
ensureDir();
return readdirSync(SKILLS_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => f.slice(0, -3));
}
export function listMcpCommands(): string[] {
const skillNames = new Set(listSkills());
return Object.keys(getGlobalMcpConfig()).filter(name => !skillNames.has(name));
}
export function getMcpCommandPrompt(mcpName: string, args: string): string {
const bundled = resolveMcpBundledSkillContent(mcpName);
if (bundled) {
const { body } = parseSkillFrontmatter(bundled);
return formatMcpCommandBlock(mcpName, body, args);
}
return formatMcpCommandBlock(mcpName, '', args);
}
function formatMcpCommandBlock(mcpName: string, skillBody: string, args: string): string {
const instruction = args || 'Show available tools and what you can do.';
const context = skillBody ? `\n\nReference:\n${skillBody}` : '';
return `<command name="${mcpName}">\nUse the ${mcpName} MCP tools to: ${instruction}${context}\n</command>`;
}
/**
* Markdown from `vendor/<serverName>/.claude/skills/<serverName>/SKILL.md` — same file the MCP exposes as skill://serverName/workflow.
*/
export function resolveMcpBundledSkillContent(serverName: string): string | null {
const file = path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
if (!existsSync(file)) return null;
return readFileSync(file, 'utf-8');
}
/**
* One `<skill>` block; `mcp-resource-uri` only when vendor SKILL.md exists (same bytes as MCP resources/read).
*/
export function resolvedSkillInjectionBlock(name: string): string | null {
const bundled = resolveMcpBundledSkillContent(name);
if (bundled !== null) {
return `<skill name="${name}" mcp-resource-uri="skill://${name}/workflow">\n${bundled}\n</skill>`;
}
const s = getSkill(name);
if (!s) return null;
const { body } = parseSkillFrontmatter(s.content);
return `<skill name="${name}">\n${body}\n</skill>`;
}
function mcpSkillFilePath(serverName: string): string {
return path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
}
/**
* Build a compact block for all active MCP skills.
* @param mcpNames — active MCP server names
* @param maxChars — 0 = hint-only (name + resource URI), >0 = injectFile with that limit
*/
export function buildMcpSkillHintsBlock(mcpNames: string[], maxChars = 0): string {
const entries: string[] = [];
for (const name of mcpNames) {
const file = mcpSkillFilePath(name);
if (!existsSync(file)) continue;
if (maxChars > 0) {
const block = injectFile(file, { label: 'mcp-skill', maxChars });
if (block) entries.push(block);
} else {
let content = readFileSync(file, 'utf-8');
if (content.startsWith('---')) {
const endIdx = content.indexOf('---', 3);
if (endIdx > 0) content = content.slice(endIdx + 3);
}
const desc = content.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim() ?? name;
entries.push(`- **${name}**: \`skill://${name}/workflow\` — ${desc}`);
}
}
if (entries.length === 0) return '';
if (maxChars > 0) return `<mcp-skills>\n${entries.join('\n')}\n</mcp-skills>`;
return `<mcp-skills>\nActive MCP servers with workflow docs — read the skill resource URI for full tool reference and usage patterns:\n${entries.join('\n')}\n</mcp-skills>`;
}
export function getSkill(name: string): Skill | null {
const file = path.join(SKILLS_DIR, `${name}.md`);
if (!existsSync(file)) return null;
const raw = readFileSync(file, 'utf-8');
const { meta } = parseSkillFrontmatter(raw);
return { name, content: raw, builtin: isBuiltin(name), meta };
}
export function saveSkill(name: string, content: string): void {
if (isBuiltin(name)) throw new Error(`Cannot modify built-in skill "${name}"`);
ensureDir();
writeFileSync(path.join(SKILLS_DIR, `${name}.md`), content);
dataSync.trackWrite(`skills/${name}.md`);
}
export function deleteSkill(name: string): void {
if (isBuiltin(name)) throw new Error(`Cannot delete built-in skill "${name}"`);
const file = path.join(SKILLS_DIR, `${name}.md`);
if (existsSync(file)) unlinkSync(file);
dataSync.trackWrite(`skills/${name}.md`);
}
export function duplicateSkill(source: string, newName: string): Skill {
const original = getSkill(source);
if (!original) throw new Error(`Skill "${source}" not found`);
if (existsSync(path.join(SKILLS_DIR, `${newName}.md`))) throw new Error(`Skill "${newName}" already exists`);
ensureDir();
writeFileSync(path.join(SKILLS_DIR, `${newName}.md`), original.content);
dataSync.trackWrite(`skills/${newName}.md`);
return { name: newName, content: original.content, builtin: false, meta: original.meta };
}
export function renameSkill(oldName: string, newName: string): void {
if (isBuiltin(oldName)) throw new Error(`Cannot rename built-in skill "${oldName}"`);
const oldFile = path.join(SKILLS_DIR, `${oldName}.md`);
const newFile = path.join(SKILLS_DIR, `${newName}.md`);
if (!existsSync(oldFile)) throw new Error(`Skill "${oldName}" not found`);
if (existsSync(newFile)) throw new Error(`Skill "${newName}" already exists`);
renameSync(oldFile, newFile);
dataSync.trackWrite(`skills/${oldName}.md`);
dataSync.trackWrite(`skills/${newName}.md`);
// Update defaults list if the old name was a default
const defs = getDefaultSkills();
const idx = defs.findIndex(e => parseDefaultEntry(e).name === oldName);
if (idx !== -1) {
const prev = defs[idx];
if (typeof prev === 'string') {
defs[idx] = newName;
} else {
defs[idx] = { ...prev, name: newName };
}
setDefaultSkills(defs);
}
}
// Default skills — injected into every conversation
export function getDefaultSkills(): DefaultSkillEntry[] {
if (!existsSync(DEFAULTS_PATH)) return [];
try { return JSON.parse(readFileSync(DEFAULTS_PATH, 'utf-8')); } catch { return []; }
}
export function getDefaultSkillNames(): string[] {
return getDefaultSkills().map(e => parseDefaultEntry(e).name);
}
export function setDefaultSkills(names: DefaultSkillEntry[]): void {
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(DEFAULTS_PATH, JSON.stringify(names, null, 2));
dataSync.trackWrite('skills-defaults.json');
}
/** `string` = full inject, `{ name, capped: true }` = capped at default, `{ name, capped: 600 }` = custom cap */
export type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
function parseDefaultEntry(entry: DefaultSkillEntry): { name: string; capChars: number } {
if (typeof entry === 'string') return { name: entry, capChars: 0 };
const c = entry.capped;
if (c === true) return { name: entry.name, capChars: 600 };
if (typeof c === 'number' && c > 0) return { name: entry.name, capChars: c };
return { name: entry.name, capChars: 0 };
}
export function resolveDefaultSkillsContent(): string {
const defaults = getDefaultSkills();
if (defaults.length === 0) return '';
const blocks: string[] = [];
for (const entry of defaults) {
const { name, capChars } = parseDefaultEntry(entry);
const skill = getSkill(name);
if (skill && isExpired(skill.meta)) continue;
if (capChars > 0) {
const file = path.join(SKILLS_DIR, `${name}.md`);
const block = injectFile(file, { label: 'skill', maxChars: capChars });
if (block) blocks.push(block);
} else {
const block = resolvedSkillInjectionBlock(name);
if (block) blocks.push(block);
}
}
return blocks.join('\n');
}
export function expandMentionedSkills(text: string): string {
const mentions = [...new Set(Array.from(text.matchAll(/@([\w-]+)/g), (m) => m[1]))];
if (mentions.length === 0) return text;
const blocks = mentions
.map((name) => {
const file = path.join(SKILLS_DIR, `${name}.md`);
if (existsSync(file)) return injectFile(file, { label: 'skill', maxChars: 600 });
const vendorFile = mcpSkillFilePath(name);
if (existsSync(vendorFile)) return injectFile(vendorFile, { label: 'skill', maxChars: 600 });
return null;
})
.filter((s): s is string => !!s);
if (blocks.length === 0) return text;
return `${blocks.join('\n')}\n\n${text}`;
}
/**
* Compact index of ALL available skills — name, description, triggers.
* Injected into every session so the agent knows what's available.
*/
export function buildSkillIndexBlock(): string {
const names = listSkills();
if (names.length === 0) return '';
const defaultNames = new Set(getDefaultSkillNames());
const lines: string[] = [];
for (const name of names) {
const skill = getSkill(name);
if (!skill) continue;
const { meta } = parseSkillFrontmatter(skill.content);
if (isExpired(meta)) continue;
const desc = meta.description || '';
const triggers = meta.triggers?.length ? ` [triggers: ${meta.triggers.join(', ')}]` : '';
const expiry = meta.expires ? ` [expires: ${meta.expires}]` : '';
const tag = defaultNames.has(name) ? ' (default)' : '';
lines.push(`- **${name}**${tag}: ${desc}${triggers}${expiry}`);
}
// ABSOLUTE: the agent's cwd is the app dir, not DATA_DIR, so a relative "data/skills/" (or a skill's own
// `scripts/…`) resolved nowhere and agents fell back to `find /` (3-7 min per call).
return `<available-skills>\nSkills live in ${SKILLS_DIR}/ — skill X is ${SKILLS_DIR}/X.md, and its bundled files (scripts/, reference/) are under ${SKILLS_DIR}/X/. A relative path in a skill lives under the data dir ${DATA_DIR}/: look in the skill's folder ${SKILLS_DIR}/X/, then ${DATA_DIR}/workspace/, then ${DATA_DIR}/ (skills/… is relative to ${DATA_DIR}/). If you must search, search inside ${DATA_DIR} only — never the whole disk. Use Read to load full skill content when needed.\n${lines.join('\n')}\n</available-skills>`;
}
/* ── Trigger matching ─────────────────────────────────────────────────────────
* Triggers are authored as natural phrases ("make a video ad"), but real briefs
* insert words into them ("make a NEW video ad variant"). A plain substring test
* missed those, and a missed trigger silently costs more than the skill text —
* it also drops the skill's `turns` budget (see resolveSkillTurns).
*
* So: match on WORD TOKENS, not raw characters.
* - Short triggers (< MIN_TOKENS_FOR_GAPS) must still match as a contiguous run.
* This is strictly NARROWER than `includes` — "cost per" no longer fires on
* "cost performance", "ad" no longer fires on "adding".
* - Longer triggers (3+ tokens) tolerate a few inserted words, bounded hard so
* the phrase cannot smear across a whole message.
* A trailing plural on either side is tolerated at every position ("video ads").
*/
/** Words, keeping decimal numbers whole so "wan 2.2" is [wan, 2.2] and not [wan, 2, 2]. */
const TRIGGER_TOKEN_RE = /[a-z0-9]+(?:\.[0-9]+)*/g;
/** Triggers with fewer tokens than this must match contiguously — too short to be safely loosened. */
const MIN_TOKENS_FOR_GAPS = 3;
/** Words that may be inserted across the WHOLE trigger phrase. This one bound is what stops a
* phrase smearing over a long message: "make a video ad" reaches "make a NEW video ad" and
* "make me a new video ad", but not "make something. later, a video. then an ad.". */
const MAX_INSERTED = 3;
/** Articles the trigger's AUTHOR typed that the asker may not ("make SOME video ads"). Skipping
* one costs an insertion, so it is not free. */
const TRIGGER_FILLER = new Set(['a', 'an', 'the']);
function tokenizeTrigger(s: string): string[] {
return s.toLowerCase().match(TRIGGER_TOKEN_RE) ?? [];
}
/** Token equality with a tolerated trailing plural on either side ("ad" ~ "ads", "match" ~ "matches"). */
function tokenEq(a: string, b: string): boolean {
if (a === b) return true;
const [long, short] = a.length > b.length ? [a, b] : [b, a];
return long === `${short}s` || long === `${short}es`;
}
function matchTokens(hay: string[], needle: string[]): boolean {
if (!needle.length || needle.length > hay.length) return false;
for (let start = 0; start + needle.length <= hay.length; start++) {
if (!tokenEq(hay[start], needle[0])) continue;
if (needle.length < MIN_TOKENS_FOR_GAPS) {
// Too short to loosen. Contiguous whole words only — strictly NARROWER than `includes`,
// which fired "ad set" on "ad settings" and "cost per" on "cost performance".
if (needle.every((t, k) => tokenEq(hay[start + k], t))) return true;
continue;
}
// Leftmost-greedy subsequence, then bound the SPAN it consumed.
let i = start + 1, n = 1, inserted = 0;
while (n < needle.length && i < hay.length && inserted <= MAX_INSERTED) {
if (tokenEq(hay[i], needle[n])) { n++; i++; continue; }
i++; inserted++;
}
if (n === needle.length && inserted <= MAX_INSERTED) return true;
}
return false;
}
/**
* True when `trigger` occurs in `text` as an in-order run of whole words spanning at most
* MAX_INSERTED extra words. Exported for tests — a trigger layer is easy to widen by accident.
*
* Two passes rather than one clever one: the trigger as authored, then the trigger with its
* articles dropped ("make a video ad" -> make/video/ad, so "make SOME video ads" lands). A single
* pass that skipped articles inline had to guess, greedily and wrongly, whether the article ahead
* in the message was the one the trigger meant.
*/
export function triggerMatches(text: string, trigger: string): boolean {
const hay = tokenizeTrigger(text);
const needle = tokenizeTrigger(trigger);
if (matchTokens(hay, needle)) return true;
const stripped = needle.filter(t => !TRIGGER_FILLER.has(t));
return stripped.length !== needle.length && stripped.length >= MIN_TOKENS_FOR_GAPS
&& matchTokens(hay, stripped);
}
/**
* Match message text against skill triggers. Returns matched skill names.
* Skips skills already in the defaults list (they're already injected).
*/
export function matchTriggeredSkillNames(message: string, context?: Record<string, string>): string[] {
const ctxPrefix = context
? Object.entries(context).map(([k, v]) => `[${k}:${v}]`).join(' ')
: '';
const triggerInput = ctxPrefix ? `${ctxPrefix} ${message}` : message;
const lower = triggerInput.toLowerCase();
const defaultNames = new Set(getDefaultSkillNames());
const matched: string[] = [];
for (const name of listSkills()) {
if (defaultNames.has(name)) continue;
const skill = getSkill(name);
if (!skill) continue;
const { meta } = parseSkillFrontmatter(skill.content);
if (isExpired(meta)) continue;
if (!meta.triggers?.length) continue;
if (meta.origin === 'auto' && meta.reviewed === false) continue;
const hit = meta.triggers.some(t => triggerMatches(lower, t));
if (hit) {
console.log(`[skills] Trigger matched: ${name}${ctxPrefix ? ` (context: ${ctxPrefix})` : ''}`);
matched.push(name);
}
}
return matched;
}
/** Build injection blocks for a list of skill names (deleted/unresolvable skills and defaults — already injected — are skipped). */
export function skillInjectionBlocks(names: string[]): string {
const defaultNames = new Set(getDefaultSkillNames());
return names
.filter((name) => !defaultNames.has(name))
.map((name) => resolvedSkillInjectionBlock(name))
.filter((b): b is string => !!b)
.join('\n');
}
/** Lint skills for index visibility — returns warnings for skills missing description frontmatter. */
export function lintSkills(): string[] {
const warnings: string[] = [];
for (const name of listSkills()) {
const skill = getSkill(name);
if (!skill) continue;
const { meta } = parseSkillFrontmatter(skill.content);
if (!meta.description) warnings.push(`${name}: missing description frontmatter — invisible in the skill index`);
}
return warnings;
}
export function purgeExpiredSkills(): string[] {
const purged: string[] = [];
for (const name of listSkills()) {
if (isBuiltin(name)) continue;
const skill = getSkill(name);
if (!skill) continue;
if (isExpired(skill.meta)) {
console.log(`[skills] Purging expired skill: ${name} (expired ${skill.meta.expires})`);
unlinkSync(path.join(SKILLS_DIR, `${name}.md`));
purged.push(name);
}
}
if (purged.length) {
const defaults = getDefaultSkills();
const filtered = defaults.filter(e => !purged.includes(parseDefaultEntry(e).name));
if (filtered.length < defaults.length) setDefaultSkills(filtered);
}
return purged;
}