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: @` — 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 `\nUse the ${mcpName} MCP tools to: ${instruction}${context}\n`; } /** * Markdown from `vendor//.claude/skills//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 `` 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 `\n${bundled}\n`; } const s = getSkill(name); if (!s) return null; const { body } = parseSkillFrontmatter(s.content); return `\n${body}\n`; } 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 `\n${entries.join('\n')}\n`; return `\nActive MCP servers with workflow docs — read the skill resource URI for full tool reference and usage patterns:\n${entries.join('\n')}\n`; } 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 `\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`; } /* ── 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[] { 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; }