/** * Skills loader. * * Skills are filesystem-resident markdown files. The convention is: * * / * paperclip-forecast/SKILL.md * paperclip-routines/SKILL.md * /SKILL.md * * Each SKILL.md starts with a YAML frontmatter: * * --- * name: Paperclip Forecasting * description: How to run and interpret a forecast. * --- * # body in markdown... * * The model receives the parsed body as part of the system prompt. */ import fs from "node:fs"; import path from "node:path"; import type { SkillDoc } from "./types.js"; export function loadSkills(workspacePath: string | undefined, explicitDir?: string): SkillDoc[] { const dirs: string[] = []; if (explicitDir && explicitDir.trim().length > 0) { dirs.push(explicitDir); } if (workspacePath) { dirs.push(path.join(workspacePath, "skills")); dirs.push(path.join(workspacePath, ".paperclip", "skills")); } // Fall back to the adapter's bundled default skills. dirs.push(path.join(import.meta.dirname, "..", "skills")); const out: SkillDoc[] = []; for (const dir of dirs) { try { if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillFile = path.join(dir, entry.name, "SKILL.md"); if (!fs.existsSync(skillFile)) continue; const raw = fs.readFileSync(skillFile, "utf8"); const parsed = parseFrontmatter(raw); if (parsed) { out.push({ name: parsed.name || entry.name, description: parsed.description || "", body: parsed.body, path: skillFile, }); } } } catch { // Best effort — never let a bad skills dir kill the heartbeat. } } return out; } interface Frontmatter { name?: string; description?: string; body: string; } function parseFrontmatter(raw: string): Frontmatter | null { if (!raw.startsWith("---")) { return { body: raw }; } const end = raw.indexOf("\n---", 3); if (end < 0) { return { body: raw }; } const fm = raw.slice(3, end).trim(); const body = raw.slice(end + 4).trim(); const out: Frontmatter = { body }; for (const line of fm.split(/\r?\n/)) { const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!m) continue; const [, key, valueRaw] = m; const value = valueRaw.replace(/^["']|["']$/g, "").trim(); if (key === "name") out.name = value; if (key === "description") out.description = value; } return out; }