/** * Definiciones de agente al estilo opencode. * * Cada agente es un archivo Markdown en `/agents/*.md` (o en * `/.pi/agents/*.md` para definiciones de proyecto) con frontmatter: * * --- * name: planner * description: Planning specialist ... * aliases: planificador * advertise: true * shortcut: f5 * model: provider/model-id * thinking: high * systemPromptMode: replace * inheritProjectContext: true * inheritSkills: false * tools: read, grep, find, ls, bash * acceptanceRole: read-only * --- * * * Claves soportadas: * name Identificador único (también el nombre del archivo). * description Texto mostrado en el selector (/agent). * aliases Nombres alternativos separados por comas. * advertise false = oculto en el selector (sigue accesible por nombre). * shortcut Atajos de teclado directos al agente, separados por * comas (p. ej. f5). Se recomiendan teclas de función: * llegan en todos los terminales (ver README). * model "provider/id" o "id" (debe resolverse de forma única). * thinking off|minimal|low|medium|high|xhigh|max * systemPromptMode replace | append (cómo se usa el cuerpo del archivo) * inheritProjectContext true/false (por defecto true) * inheritSkills true/false (por defecto true) * tools Lista de herramientas activas separadas por comas. * acceptanceRole read-only = solo lectura (bash restringido, sin escritura). * defaultContext Aceptado e ignorado (fresco/subagente no aplica al * cambio inline; ver README). */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); export interface AgentDefinition { /** Nombre único; se usa para cambiar de agente. */ name: string; description: string; aliases: string[]; /** false = no aparece en el selector (sigue accesible por nombre o alias). */ advertise: boolean; /** Atajos de teclado directos al agente (frontmatter `shortcut:`, separados por comas). */ shortcuts: string[]; /** Modelo propio del agente: "provider/id" o un "id" único. Sin valor = modelo actual. */ model?: string; /** Nivel de thinking propio del agente. */ thinking?: ThinkingLevelName; /** replace = el cuerpo sustituye el system prompt; append = se añade. */ systemPromptMode: "append" | "replace"; /** Cuerpo del archivo Markdown (system prompt del agente). */ systemPrompt: string; inheritProjectContext: boolean; inheritSkills: boolean; /** Allowlist de herramientas activas; sin clave = sin restricción. */ tools?: string[]; /** read-only = solo lectura (bloquea escritura y restringe bash). */ acceptanceRole?: "read-only" | "full"; /** Origen del archivo (para depuración). */ sourcePath?: string; } /** Directorio de configuración del agente (equivalente a getAgentDir()). */ export function agentDir(): string { return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); } /** Divide el frontmatter YAML simple ("key: value") del cuerpo. */ function splitFrontmatter(text: string): { fields: Map; body: string } { const fields = new Map(); const normalized = text.replace(/^\uFEFF/, ""); const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) return { fields, body: normalized.trim() }; for (const line of match[1].split(/\r?\n/)) { const idx = line.indexOf(":"); if (idx <= 0) continue; const key = line.slice(0, idx).trim().toLowerCase(); let value = line.slice(idx + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || (value.startsWith("'") && value.endsWith("'") && value.length >= 2) ) { value = value.slice(1, -1); } if (key) fields.set(key, value); } return { fields, body: match[2].trim() }; } function parseBoolean(raw: string | undefined, fallback: boolean): boolean { if (raw === undefined) return fallback; const v = raw.trim().toLowerCase(); if (v === "true" || v === "yes" || v === "on" || v === "1") return true; if (v === "false" || v === "no" || v === "off" || v === "0") return false; return fallback; } function parseList(raw: string | undefined): string[] { if (!raw) return []; return raw .split(/[,;]/) .map((s) => s.trim()) .filter(Boolean); } function parseTools(raw: string | undefined): string[] | undefined { if (raw === undefined) return undefined; const list = raw .split(/[\s,]+/) .map((s) => s.trim()) .filter(Boolean); return list.length > 0 ? list : undefined; } /** Construye una definición a partir de un archivo Markdown con frontmatter. */ export function parseAgentFile(text: string, sourcePath: string): AgentDefinition | undefined { const { fields, body } = splitFrontmatter(text); const fallbackName = path.basename(sourcePath, path.extname(sourcePath)).toLowerCase(); const name = (fields.get("name") ?? fallbackName).trim().toLowerCase(); if (!name) return undefined; const thinkingRaw = fields.get("thinking")?.trim().toLowerCase(); const thinking = thinkingRaw && THINKING_LEVELS.has(thinkingRaw) ? (thinkingRaw as ThinkingLevelName) : undefined; const modeRaw = fields.get("systempromptmode")?.trim().toLowerCase(); const systemPromptMode: "append" | "replace" = modeRaw === "replace" ? "replace" : "append"; const roleRaw = fields.get("acceptancerole")?.trim().toLowerCase(); const acceptanceRole: "read-only" | "full" | undefined = roleRaw === "read-only" || roleRaw === "readonly" ? "read-only" : roleRaw === "full" ? "full" : undefined; const model = fields.get("model")?.trim() || undefined; return { name, description: fields.get("description")?.trim() ?? "", aliases: parseList(fields.get("aliases")).map((a) => a.toLowerCase()), advertise: parseBoolean(fields.get("advertise"), true), shortcuts: (fields.get("shortcut") ?? "") .split(/[,\s]+/) .map((s) => s.trim().toLowerCase()) .filter(Boolean), model, thinking, systemPromptMode, systemPrompt: body, inheritProjectContext: parseBoolean(fields.get("inheritprojectcontext"), true), inheritSkills: parseBoolean(fields.get("inheritskills"), true), tools: parseTools(fields.get("tools")), acceptanceRole, sourcePath, }; } function readDefsFromDir(dir: string): AgentDefinition[] { let entries: string[]; try { entries = fs.readdirSync(dir); } catch { return []; } const defs: AgentDefinition[] = []; for (const entry of entries.sort()) { if (!entry.toLowerCase().endsWith(".md")) continue; const filePath = path.join(dir, entry); try { const def = parseAgentFile(fs.readFileSync(filePath, "utf8"), filePath); if (def) defs.push(def); } catch { // Archivo ilegible: se ignora para no romper la carga. } } return defs; } /** * Carga las definiciones: `/agents` (o $PI_AGENTS_DIR) y * `/.pi/agents`. Un proyecto puede sobrescribir un agente de usuario * repitiendo su `name`. Si no hay archivos, se usan los agentes integrados. */ export function loadAgentDefinitions(cwd: string): AgentDefinition[] { const byName = new Map(); const userDir = process.env.PI_AGENTS_DIR ?? path.join(agentDir(), "agents"); const projectDir = path.join(cwd, ".pi", "agents"); const dirs = projectDir === userDir ? [userDir] : [userDir, projectDir]; for (const dir of dirs) { for (const def of readDefsFromDir(dir)) { byName.set(def.name, def); } } if (byName.size === 0) { for (const def of BUILTIN_AGENTS) byName.set(def.name, def); } return [...byName.values()]; } /** Resuelve nombre o alias a la definición. */ export function findAgent(defs: AgentDefinition[], nameOrAlias: string): AgentDefinition | undefined { const needle = nameOrAlias.trim().toLowerCase(); if (!needle) return undefined; return defs.find((d) => d.name === needle || d.aliases.includes(needle)); } /** * Agentes integrados: se usan solo si no existe ningún archivo en agents/. * `build` es el agente por defecto (comportamiento normal de pi). */ const BUILTIN_AGENTS: AgentDefinition[] = [ { name: "build", description: "Implementación: ejecuta planes y edita código (agente por defecto)", aliases: ["implement", "primary"], advertise: true, shortcuts: [], systemPromptMode: "append", systemPrompt: "", inheritProjectContext: true, inheritSkills: true, acceptanceRole: "full", }, { name: "plan", description: "Planificación de solo lectura: analiza y propone pasos sin tocar archivos", aliases: ["planner"], advertise: true, shortcuts: [], systemPromptMode: "replace", systemPrompt: [ "You are `plan`: the planning agent.", "", "Your only job is to turn a goal into a concrete, verifiable implementation plan.", "You never edit or create files. Use only read-only tools to explore the repository.", "", "Output format (always):", "", "Plan:", "1. : what to change, where, and why", "2. ...", "", "Files: the files each step touches.", "Verification: how to confirm each step (commands, tests, type checks).", "Risks: edge cases, likely breakages, dependencies between steps.", "Open decisions: anything the user must decide before execution.", ].join("\n"), inheritProjectContext: true, inheritSkills: false, tools: ["read", "grep", "find", "ls", "bash"], acceptanceRole: "read-only", }, ];