import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { approveAll, type CopilotSession } from "@github/copilot-sdk";
import {
BuiltSkillSchema,
renderSkillMarkdown,
requireTargetPlacement,
SkillPlanSchema,
slugifySkillName,
toBuiltSkill,
type BuiltSkill,
type SkillArchitecture,
type SkillPlan,
type SkillSubmission,
} from "../../common/skill";
import { unresolvedTokens } from "../../common/values";
import type { SkillBuildInput, SkillBuildProgress, SkillPreview } from "../../common/ipc";
import { requireCatalogue } from "../architectures/catalogue-registry";
import { AgentBuilder, type BaseLive } from "../builders/agent-builder";
import { createReadTools } from "../builders/read-tools";
import { loadPersistedAnalysis } from "../describer/describer";
import { createLogger } from "../logger";
import { isValidSessionId, sessionDir } from "../recorder/session-store";
import { SKILL_BUILDER_INSTRUCTIONS } from "./instructions";
import { createSkillBuilderTools } from "./tools";
const log = createLogger("SkillBuilder");
const TURN_TIMEOUT_MS = 180_000;
const MAX_PREVIEWS = 4;
const KICKOFF_PROMPT =
"Read get_analysis (and get_timeline where the tool mapping needs evidence), then call " +
"propose_plan with how you'll generalize this task, its fixed values (each an id + name + " +
"value, referenced from steps as {{id}}), and its ordered steps (each a short title + " +
"description, with every concrete native tool it calls). Stop after propose_plan so the user can review it.";
const CREATE_PROMPT =
"The user reviewed and edited the plan below. Build the SKILL.md from EXACTLY this plan — do not " +
"add, drop, reorder, or rename its values or steps. Call submit_skill with a generalized, " +
"native-tool-first instructions body that follows these steps faithfully and references each fixed " +
"value by its {{id}} token (never inline the literal). The name and description are already decided — " +
"you may echo them.";
const msg = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** Root folder Scout auto-loads user skills from (overridable for dev/tests). */
function skillsRoot(): string {
const override = process.env.SKILL_RECORDER_SKILLS_DIR;
if (override) return path.resolve(override);
return path.join(os.homedir(), ".copilot", "skills");
}
/** True when `dir` is `root` or nested inside it (so we can safely re-use it). */
function isInside(root: string, dir: string): boolean {
const rel = path.relative(root, dir);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
/**
* Where {@link SkillBuilder.create} writes the finished SKILL.md:
* - **install** — into the agent's live skills folder ({@link skillsRoot}), auto-loaded.
* - **export** — into a user-picked folder (a "download"), as `
//SKILL.md`.
*/
export type SkillTarget = { kind: "install" } | { kind: "export"; dir: string };
type SkillTargetPicker = () => Promise;
interface PreparedSkill extends SkillPreview {
skill: BuiltSkill;
planKey: string;
}
interface BuildOperation {
canceled: boolean;
}
export class SkillPreviewExpiredError extends Error {
constructor() {
super("This skill preview is no longer current. Review the skill again before placing it.");
this.name = "SkillPreviewExpiredError";
}
}
interface LiveBuild extends BaseLive {
sessionDir: string;
architecture: SkillArchitecture;
copilot: CopilotSession;
holder: { plan: SkillPlan | undefined; submission: SkillSubmission | undefined };
/** Last plan proposed this build (kept so submit can reference it). */
lastPlan: SkillPlan | null;
}
function readJson(file: string): T | null {
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, "utf8")) as T;
} catch {
return null;
}
}
/** Load a previously built + persisted skill for a session, if any. */
export function loadPersistedSkill(sessionId: string): BuiltSkill | null {
if (!isValidSessionId(sessionId)) return null;
const raw = readJson(path.join(sessionDir(sessionId), "skill.json"));
if (!raw) return null;
const parsed = BuiltSkillSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
/**
* Drives the multi-turn GitHub Copilot CLI agent that turns a recording's analysis
* into a generalized, native-tool-first skill for a target architecture. Shares the
* {@link AgentBuilder} pool (one live conversation per recording) so the plan →
* refine → build flow stays in a single session. Streams progress out via a
* callback and writes the final SKILL.md into the target agent's skills folder.
*/
export class SkillBuilder extends AgentBuilder {
private readonly previews = new Map();
private readonly operations = new Map();
constructor(private readonly emitProgress: (p: SkillBuildProgress) => void) {
super("SkillBuilder");
}
/** Propose a plan (first pass) or refine the current one with NL feedback. */
async build(input: SkillBuildInput): Promise {
const { sessionId, architecture, feedback } = input;
if (this.active.has(sessionId)) throw new Error("A build is already running for this session.");
requireCatalogue(architecture, "skill");
const analysis = loadPersistedAnalysis(sessionId);
if (!analysis) throw new Error("There is no analysis for this recording yet.");
const operation = this.begin(sessionId);
this.discardPreview(sessionId);
try {
const refining = Boolean(feedback && feedback.trim());
this.emit(sessionId, "start", refining ? "Refining the plan…" : "Planning the skill…");
let live = this.live.get(sessionId);
if (!refining || !live) {
await this.disposeLive(sessionId); // fresh conversation for a fresh plan
live = await this.createLive(sessionId, architecture);
}
const prompt = refining ? renderRefinePrompt(feedback!.trim(), live.lastPlan) : KICKOFF_PROMPT;
this.requireActive(operation);
return await this.runProposeTurn(live, prompt, operation);
} finally {
this.finish(sessionId);
}
}
/** Prepare the exact file for optional review, without writing or placing it. */
async prepare(sessionId: string, editedPlan: SkillPlan): Promise {
const plan = SkillPlanSchema.parse(editedPlan);
const operation = this.begin(sessionId);
try {
let prepared = this.previews.get(sessionId);
if (!prepared || prepared.planKey !== JSON.stringify(plan)) {
this.discardPreview(sessionId);
prepared = await this.generate(sessionId, plan, operation);
this.requireActive(operation);
this.previews.set(sessionId, prepared);
while (this.previews.size > MAX_PREVIEWS) {
const oldest = this.previews.keys().next().value;
if (oldest === undefined) break;
this.previews.delete(oldest);
}
}
this.emit(sessionId, "done", "Skill ready for review. Nothing has been installed.");
return { id: prepared.id, markdown: prepared.markdown };
} finally {
this.finish(sessionId);
}
}
discardPreview(sessionId: string, previewId?: string): void {
if (previewId === undefined || this.previews.get(sessionId)?.id === previewId) {
this.previews.delete(sessionId);
}
}
override async cancel(sessionId: string): Promise {
const operation = this.operations.get(sessionId);
if (operation) operation.canceled = true;
await super.cancel(sessionId);
}
override async forget(sessionId: string): Promise {
this.discardPreview(sessionId);
await this.cancel(sessionId);
await super.forget(sessionId);
}
override async dispose(): Promise {
for (const operation of this.operations.values()) operation.canceled = true;
this.previews.clear();
await super.dispose();
}
/** Place a reviewed candidate, or generate and place in one step for direct callers.
* The picker runs under the operation guard, before any direct-path generation. */
async create(
sessionId: string,
editedPlan?: SkillPlan,
target: SkillTarget | SkillTargetPicker = { kind: "install" },
previewId?: string,
): Promise<{ skill: BuiltSkill; path: string } | null> {
const plan = editedPlan
? SkillPlanSchema.parse(editedPlan)
: this.live.get(sessionId)?.lastPlan ?? null;
if (!plan) throw new Error("There is no plan to build from yet.");
const operation = this.begin(sessionId);
try {
let prepared = previewId === undefined ? undefined : this.requirePreview(sessionId, plan, previewId);
const destination = typeof target === "function" ? await target() : target;
this.requireActive(operation);
if (!destination) return null;
requireTargetPlacement(plan.architecture, "skill", destination.kind);
if (previewId !== undefined) {
// The folder picker can outlive a discarded or replaced preview.
prepared = this.requirePreview(sessionId, plan, previewId);
} else {
this.discardPreview(sessionId);
prepared = await this.generate(sessionId, plan, operation);
}
this.requireActive(operation);
if (!prepared) throw new Error("There is no prepared skill to place.");
const exportPath =
destination.kind === "export"
? this.exportSkillTo(prepared.skill, destination.dir, prepared.markdown)
: this.exportSkill(prepared.skill, prepared.markdown);
const finalSkill: BuiltSkill = { ...prepared.skill, exportedPath: exportPath, exportedAt: Date.now() };
this.persist(sessionDir(sessionId), finalSkill);
this.discardPreview(sessionId);
this.emit(
sessionId,
"done",
destination.kind === "export" ? `Skill exported to ${exportPath}` : `Skill added: ${exportPath}`,
);
return { skill: finalSkill, path: exportPath };
} finally {
this.finish(sessionId);
}
}
// --- internals -----------------------------------------------------------
private begin(sessionId: string): BuildOperation {
if (this.active.has(sessionId)) throw new Error("Wait for the current step to finish.");
const operation: BuildOperation = { canceled: false };
this.active.add(sessionId);
this.operations.set(sessionId, operation);
return operation;
}
private finish(sessionId: string): void {
this.operations.delete(sessionId);
this.active.delete(sessionId);
}
private requireActive(operation: BuildOperation): void {
if (operation.canceled) throw new Error("Skill build canceled.");
}
private requirePreview(sessionId: string, plan: SkillPlan, id: string): PreparedSkill {
const prepared = this.previews.get(sessionId);
if (!prepared || prepared.id !== id || prepared.planKey !== JSON.stringify(plan)) {
throw new SkillPreviewExpiredError();
}
return prepared;
}
private async generate(
sessionId: string,
plan: SkillPlan,
operation: BuildOperation,
): Promise {
let live = this.live.get(sessionId);
if (live && live.architecture !== plan.architecture) {
await this.disposeLive(sessionId);
live = undefined;
}
this.requireActive(operation);
if (!live) live = await this.createLive(sessionId, plan.architecture);
this.requireActive(operation);
live.lastPlan = plan;
this.emit(sessionId, "drafting", "Writing the skill…");
live.holder.submission = undefined;
try {
await live.copilot.sendAndWait(`${CREATE_PROMPT}\n\n${renderPlanForPrompt(plan)}`, TURN_TIMEOUT_MS);
} catch (err) {
await live.copilot.abort().catch(() => undefined);
throw new Error(`Skill build failed: ${msg(err)}`);
}
this.requireActive(operation);
const submission = live.holder.submission as SkillSubmission | undefined;
if (!submission) throw new Error("The agent finished without submitting a skill.");
const unknownTokens = unresolvedTokens(submission.body, plan.values);
if (unknownTokens.length) {
log.warn(`skill body references unknown value tokens: ${unknownTokens.map((t) => `{{${t}}}`).join(", ")}`);
}
const finalSubmission: SkillSubmission = {
name: plan.name,
description: plan.description,
allowedTools: submission.allowedTools.length ? submission.allowedTools : plan.allowedTools,
body: submission.body,
};
const skill = toBuiltSkill(sessionId, plan.architecture, finalSubmission, plan);
return { id: randomUUID(), skill, planKey: JSON.stringify(plan), markdown: renderSkillMarkdown(skill) };
}
private emit(sessionId: string, phase: SkillBuildProgress["phase"], message: string): void {
this.emitProgress({ sessionId, phase, message });
}
private async createLive(sessionId: string, architecture: SkillArchitecture): Promise {
const dir = sessionDir(sessionId);
const analysis = loadPersistedAnalysis(sessionId);
if (!analysis) throw new Error("There is no analysis for this recording yet.");
const holder: LiveBuild["holder"] = { plan: undefined, submission: undefined };
const tools = [
...createReadTools({
sessionDir: dir,
analysis,
onProgress: (m) => this.emit(sessionId, "working", m),
}),
...createSkillBuilderTools({
architecture,
onProgress: (m) => this.emit(sessionId, "working", m),
onPlan: (p) => {
holder.plan = p;
},
onSubmit: (s) => {
holder.submission = s;
},
}),
];
const catalogue = requireCatalogue(architecture, "skill").content;
const systemContent = `${SKILL_BUILDER_INSTRUCTIONS}\n\n${catalogue}`.trim();
const client = await this.ensureClient();
const copilot = await client.createSession({
systemMessage: { mode: "append", content: systemContent },
tools,
onPermissionRequest: approveAll,
workingDirectory: dir,
enableHostGitOperations: false,
infiniteSessions: { enabled: false },
availableTools: tools.map((t) => t.name),
...(this.model ? { model: this.model } : {}),
});
const live: LiveBuild = {
sessionId,
sessionDir: dir,
architecture,
copilot,
holder,
lastPlan: null,
};
this.registerLive(live);
return live;
}
private async runProposeTurn(
live: LiveBuild,
prompt: string,
operation: BuildOperation,
): Promise {
live.holder.plan = undefined;
this.emit(live.sessionId, "working", "Thinking…");
try {
await live.copilot.sendAndWait(prompt, TURN_TIMEOUT_MS);
} catch (err) {
await live.copilot.abort().catch(() => undefined);
throw new Error(`Planning failed: ${msg(err)}`);
}
this.requireActive(operation);
const plan = live.holder.plan;
if (!plan) throw new Error("The agent finished without proposing a plan.");
live.lastPlan = plan;
this.emit(live.sessionId, "done", "Plan ready for your review.");
return plan;
}
/** Write the SKILL.md into the target agent's live skills folder; returns its path. */
private exportSkill(skill: BuiltSkill, markdown: string): string {
const root = skillsRoot();
const name = slugifySkillName(skill.name);
const prior = loadPersistedSkill(skill.sessionId);
const priorDir = prior?.exportedPath ? path.dirname(prior.exportedPath) : null;
// Re-install over the same folder only when it already lives under the install root;
// a prior *export* (download) folder must not be reused here. Otherwise pick a fresh,
// non-colliding directory so we never clobber an unrelated skill.
const reuse = priorDir !== null && isInside(root, priorDir);
let dir = reuse ? (priorDir as string) : path.join(root, name);
if (!reuse && existsSync(dir)) {
let n = 2;
while (existsSync(path.join(root, `${name}-${n}`))) n++;
dir = path.join(root, `${name}-${n}`);
}
mkdirSync(dir, { recursive: true });
const file = path.join(dir, "SKILL.md");
writeFileSync(file, markdown);
return file;
}
/** Export (download) the SKILL.md into a user-picked folder as `//SKILL.md`;
* returns its path. Always picks a fresh, non-colliding subfolder within `baseDir`. */
private exportSkillTo(skill: BuiltSkill, baseDir: string, markdown: string): string {
const name = slugifySkillName(skill.name);
let dir = path.join(baseDir, name);
if (existsSync(dir)) {
let n = 2;
while (existsSync(path.join(baseDir, `${name}-${n}`))) n++;
dir = path.join(baseDir, `${name}-${n}`);
}
mkdirSync(dir, { recursive: true });
const file = path.join(dir, "SKILL.md");
writeFileSync(file, markdown);
return file;
}
private persist(dir: string, skill: BuiltSkill): void {
try {
writeFileSync(path.join(dir, "skill.json"), JSON.stringify(skill, null, 2));
} catch (err) {
log.warn("failed to persist skill:", msg(err));
}
}
}
/** Render the final, user-edited plan into a compact spec the create turn builds from. */
function renderPlanForPrompt(plan: SkillPlan): string {
const lines = [`Title: ${plan.title}`, `Name: ${plan.name}`, `Description: ${plan.description}`];
if (plan.generalization) lines.push(`Generalization: ${plan.generalization}`);
if (plan.values.length) {
lines.push(
"",
"Values (reference each by its {{id}} token in the body — never write the literal value yourself):",
);
for (const v of plan.values) lines.push(`- {{${v.id}}}${v.name ? ` — ${v.name}` : ""}`);
}
if (plan.steps.length) {
lines.push("", "Steps (in order):");
plan.steps.forEach((s, idx) => {
const head = [s.title, s.text].filter(Boolean).join(" — ");
const bits = [`${idx + 1}. (${s.kind}) ${head}`];
if (s.tools.length) bits.push(`[tools: ${s.tools.join(" → ")}]`);
lines.push(bits.join(" "));
});
}
if (plan.allowedTools.length) lines.push("", `allowed-tools: ${plan.allowedTools.join(", ")}`);
return lines.join("\n");
}
function renderRefinePrompt(feedback: string, prior: SkillPlan | null): string {
const lines = [
"The user reviewed your proposed plan and wants changes. Revise the plan and call",
"propose_plan again (do not write the skill yet).",
"",
];
if (prior) {
lines.push(`Current plan: ${prior.title} (${prior.name})`);
lines.push(`- generalization: ${prior.generalization || "(none)"}`);
if (prior.values.length) {
lines.push(`- values: ${prior.values.map((v) => v.name || v.id).join(", ")}`);
}
lines.push("");
}
lines.push(`Their feedback: ${feedback}`);
return lines.join("\n");
}