// 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 };
});
}