// skilled-labour — replace pi's verbose block with a compact // skills section + a single load_skill tool. import { existsSync, readFileSync } from "node:fs"; import { resolve, sep } from "node:path"; import { Type } from "typebox"; import type { ExtensionAPI, Skill } from "@earendil-works/pi-coding-agent"; // --- helpers --------------------------------------------------------------- // Strip the entire block pi injects via formatSkillsForPrompt (core/skills.js). // Anchor on the unique intro line; non-greedy through the first // . No-op if pi emitted no block (zero visible skills). const SKILL_BLOCK_RE = /\n*The following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>\n*/; function stripSkillBlock(systemPrompt: string): string { return systemPrompt.replace(SKILL_BLOCK_RE, "\n"); } function stripSkillFrontmatter(s: string): string { return s.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim(); } function escapeXml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } // Match `./`-prefixed relative paths (`/` separators, no spaces). The segment // charset includes `.` and the match is greedy, so a token like // `./scripts/tool.sh.bak` is captured as one whole candidate — existence then // fails and it's skipped, giving correct "don't replace a prefix of a longer // path" behavior for free (no separate trailing-boundary logic). const RELPATH_RE = /\.\/[\w.\-]+(?:\/[\w.\-]+)*/g; /* * When reading in SKILL.md, we expand any paths prefixed with `./` (local) * to their full, absolute paths - provided the file exists inside the skill * directory. * * Skills should use '/' as their path separator, though the paths printed * use the platform-dependent path separator. * * This makes it easier for the model to load supporting files. * (Note, not applied to references in supporting files themselves...) */ function absolutize(body: string, baseDir: string): string { const seen = new Map(); // match -> resolved abs path, or null const toAbs = (rel: string) => resolve(baseDir, rel).split(sep).join("/"); const exists = (rel: string) => existsSync(resolve(baseDir, rel)); return body.replace(RELPATH_RE, (m) => { if (seen.has(m)) return seen.get(m) ?? m; let res: string | null = null; if (exists(m)) res = toAbs(m); else if (m.endsWith(".") && exists(m.slice(0, -1))) res = toAbs(m.slice(0, -1)); seen.set(m, res); return res ?? m; }); } function buildSkillsSection(skills: Skill[]): string { const visible = skills.filter((s) => !s.disableModelInvocation); if (visible.length === 0) return ""; const lines = ["", ""]; for (const s of visible) { const desc = s.description.replace(/\s+/g, " ").trim(); lines.push( ` ${escapeXml(desc)}`, ); } lines.push(""); lines.push( "Call the load_skill tool with a skill's name to load its full instructions, " + "BEFORE acting on a task the skill covers. It is cheap and strongly recommended. " + "If a skill's instructions are already in the conversation above (loaded earlier " + "this session), do not reload it.", ); return lines.join("\n"); } /* * Local state - used to make subsequent (re-)loads of a skill a NO-OP. * We clear `loadedSkills` on compaction. */ /* populated in `before_agent_start` with list of skills discovered by Pi */ let currentSkills: Skill[] = []; /* skills loaded into context */ const loadedSkills = new Set(); let registered = false; export default function skilled_labour(pi: ExtensionAPI) { pi.on("session_start", () => { /* Trigges on: * - new session: `pi` or `/new` command * - resume persisted session: `pi -r ` or `/resume` command * - `/fork` - pick a message in conversation tree, new session is made * sharing the conversation up until the selected point * - `/reload` - retains session but triggers a reload of extensions * settings, resources, ... */ loadedSkills.clear(); }); pi.on("session_compact", () => { /* * Summarization may drop the loaded skill from the conversation. */ loadedSkills.clear(); }); /* * Triggers once per agent "run", defined as all the stages * between the user submitting a prompt through various tool calls etc until * the model returns an answer and awaits the next prompt. */ pi.on("before_agent_start", (event) => { currentSkills = event.systemPromptOptions?.skills ?? []; /* only register the tool once */ if (!registered) { registered = true; pi.registerTool({ name: "load_skill", label: "Load Skill", description: "Load a skill's full instructions by name. Call this BEFORE acting on any task that a listed skill covers " + "— it is cheap (one call) and nearly always improves results. No-op if the skill is already loaded. " + "Pass the exact skill name from the list in the system prompt.", promptSnippet: 'Load a skill\'s full instructions by name, e.g. load_skill({ name: "odin-programming" }).', promptGuidelines: [ "Before acting on a task, check the list in the system prompt. " + "If any skill matches the task, call load_skill with its name FIRST, then follow the " + "instructions it returns. Do not write code or run commands for a skill-covered task " + "without loading the skill first.", ], parameters: Type.Object({ name: Type.String({ description: "Exact skill name from the list, e.g. \"odin-programming\".", }), }), async execute(_toolCallId, params) { const name = String(params?.name ?? "").trim(); if (!name) { throw new Error("load_skill requires a skill name from the list."); } if (loadedSkills.has(name)) { return { content: [ { type: "text", text: `Skill "${name}" already loaded, do not load skills twice, they are in context`, }, ], details: undefined, }; } const skill = currentSkills.find((s) => s.name === name); if (!skill) { const available = currentSkills.filter((s) => !s.disableModelInvocation).map((s) => s.name).join(", ") || "none"; throw new Error(`No skill named "${name}". Available: ${available}.`); } let body: string; try { body = stripSkillFrontmatter(readFileSync(skill.filePath, "utf8")); } catch (err) { throw new Error(`Failed to load skill "${name}" from ${skill.filePath}: ${err instanceof Error ? err.message : String(err)}`); } body = absolutize(body, skill.baseDir); loadedSkills.add(name); return { content: [ { type: "text", text: `\n` + `References are relative to ${skill.baseDir}.\n\n${body}\n\n\n` + `Skill "${name}" loaded. Do not attempt loading the skill again later`, }, ], details: undefined, }; }, }); } /* Replace pi's verbose block with the compact section. */ const stripped = stripSkillBlock(event.systemPrompt); const section = buildSkillsSection(currentSkills); const systemPrompt = section ? `${stripped}\n${section}\n` : `${stripped}\n`; return { systemPrompt }; }); }