/** * Agent profiles (§7.7). Markdown with YAML-ish frontmatter, in precedence order: * project (`.pi/agi/agents/`, trust-gated), user (`~/.pi/agent/agi/agents/`), * built-in. */ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { parseFrontmatter } from "../state.ts"; export interface AgentProfile { name: string; description: string; disabled: boolean; body: string; /** Where this profile came from, for diagnostics and `/agi-doctor`. */ source: "builtin" | "user" | "project"; sourcePath?: string; /** Obsolete capability/model fields found in frontmatter and intentionally ignored. */ deprecatedFields: string[]; } const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export function validateProfileName(raw: string): string { const name = raw.trim().replace(/\.md$/i, ""); if (!PROFILE_NAME_PATTERN.test(name) || name.includes("..") || name.includes("/") || name.includes("\\")) { throw new Error( `Invalid agent profile name '${raw}'. Use 1-64 lowercase letters, digits, or hyphens, starting with a letter or digit.`, ); } return name; } /** * R-WORK-13: four built-ins, not eight. The orchestrator's selection error rate * rises with the number of near-identical options it has to choose between, and * users add their own. */ export const BUILTIN_PROFILES: AgentProfile[] = [ { name: "worker", description: "Default execution and deep-reasoning persona.", disabled: false, source: "builtin", deprecatedFields: [], body: "You are an implementer. Make the change described in your task, then verify it yourself with the\n" + "project's own tools (build, tests, linter) before reporting. Report the actual output of what you\n" + "ran, not what you expect it to say.", }, { name: "explore", description: "Reconnaissance persona for the next iterative investigation step.", disabled: false, source: "builtin", deprecatedFields: [], body: "You are doing reconnaissance. Read, search, and summarize while preserving repository contents.\n" + "Return a precise answer with file paths and line numbers.", }, { name: "review", description: "Critique persona for a diff, design, or plan. Names concrete problems.", disabled: false, source: "builtin", deprecatedFields: [], body: "Perform a focused review. Find real problems and say why each matters, with the file and line.\n" + "Distinguish a defect from a preference. Report a clean review after thorough inspection.", }, { name: "verify", description: "Verification persona. Runs tests and builds and reports their real output.", disabled: false, source: "builtin", deprecatedFields: [], body: "You verify claims. Run the commands, capture their real output, and report pass or fail with the\n" + "evidence. Keep the repository unchanged and report every finding.", }, ]; function boolField(fields: Record, key: string, fallback: boolean, file: string): boolean { const raw = fields[key]; if (raw === undefined) return fallback; if (raw === "true") return true; if (raw === "false") return false; throw new Error(`${file}: frontmatter '${key}' must be true or false, got '${raw}'.`); } /** * Parse one profile file. Throws with the file and field named, because these * errors are read by a user editing the file and by the orchestrator choosing an * agent. */ export function parseProfile(content: string, fallbackName: string, file: string, source: AgentProfile["source"]): AgentProfile { const doc = parseFrontmatter(content, file); const name = validateProfileName(doc.fields.name ?? fallbackName); const description = doc.fields.description; if (description === undefined || description.length === 0) { throw new Error(`${file}: frontmatter 'description' is required — it is what the orchestrator selects on.`); } const deprecatedFields = ["model", "thinking", "fallbackModels", "tools", "excludeTools", "extensions"] .filter((field) => doc.fields[field] !== undefined); return { name, description, disabled: boolField(doc.fields, "disabled", false, file), body: doc.body.trim(), source, sourcePath: file, deprecatedFields, }; } export function renderProfile(profile: AgentProfile): string { const lines = ["---", `name: ${profile.name}`, `description: ${profile.description}`]; if (profile.disabled) lines.push("disabled: true"); lines.push("---", "", profile.body, ""); return lines.join("\n"); } export interface ProfileProblem { name: string; path: string; reason: string; } export interface LoadedProfiles { profiles: Map; problems: ProfileProblem[]; warnings: ProfileProblem[]; } function loadDir(dir: string, source: AgentProfile["source"], into: Map, problems: ProfileProblem[], warnings: ProfileProblem[]): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; problems.push({ name: "(directory)", path: dir, reason: (error as Error).message }); return; } for (const entry of entries) { // A directory named `foo.md` throws EISDIR on read; this dir is user-writable, // so anything that is not a plain file is skipped (the Phase 2 BUG-10 lesson). if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const file = path.join(dir, entry.name); const fallbackName = entry.name.replace(/\.md$/i, ""); try { const profile = parseProfile(fs.readFileSync(file, "utf8"), fallbackName, file, source); // A profile whose frontmatter name disagrees with its filename would be // selectable under a name that does not match the file the user edits. if (profile.name !== fallbackName) { throw new Error(`frontmatter name '${profile.name}' does not match filename '${entry.name}'.`); } into.set(profile.name, profile); if (profile.deprecatedFields.length > 0) { warnings.push({ name: profile.name, path: file, reason: `ignored legacy field(s): ${profile.deprecatedFields.join(", ")}; workers inherit the parent model and normal Pi capabilities`, }); } } catch (error) { // One broken profile must never hide the others (R-STATE-18 in spirit): // it is reported and skipped, so `worker` still works. problems.push({ name: fallbackName, path: file, reason: (error as Error).message }); } } } export function userProfilesDir(): string { return path.join(getAgentDir(), "agi", "agents"); } export function projectProfilesDir(cwd: string): string { return path.join(cwd, ".pi", "agi", "agents"); } /** * §7.7 precedence: built-in, overridden by user, overridden by project. Project * profiles are read only under proven trust (R-CONF-1) because their behavioral * system prompt is executable agent input. Profiles never select models or tools. */ export function loadProfiles(cwd: string, options: { isProjectTrusted?: () => boolean } = {}): LoadedProfiles { const profiles = new Map(); const problems: ProfileProblem[] = []; const warnings: ProfileProblem[] = []; for (const builtin of BUILTIN_PROFILES) profiles.set(builtin.name, builtin); loadDir(userProfilesDir(), "user", profiles, problems, warnings); let trusted = false; try { trusted = options.isProjectTrusted?.() === true; } catch { trusted = false; } if (trusted) loadDir(projectProfilesDir(cwd), "project", profiles, problems, warnings); return { profiles, problems, warnings }; } /** * R-WORK-15: write the four built-ins to the user directory on first activation, * never overwriting an existing file. Concrete examples to edit beat an empty * directory and a schema in the docs. */ export function bootstrapProfiles(dir = userProfilesDir()): string[] { const written: string[] = []; fs.mkdirSync(dir, { recursive: true }); for (const profile of BUILTIN_PROFILES) { const file = path.join(dir, `${profile.name}.md`); try { // "wx" is the whole point: an existing file, however edited, is left alone. const fd = fs.openSync(file, "wx"); try { fs.writeFileSync(fd, renderProfile(profile), "utf8"); } finally { fs.closeSync(fd); } written.push(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; throw error; } } return written; }