Files

270 lines
9.6 KiB
TypeScript

/**
* Definiciones de agente al estilo opencode.
*
* Cada agente es un archivo Markdown en `<agent-dir>/agents/*.md` (o en
* `<cwd>/.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
* ---
* <cuerpo: system prompt del agente>
*
* 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<string>(["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<string, string>; body: string } {
const fields = new Map<string, string>();
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: `<agent-dir>/agents` (o $PI_AGENTS_DIR) y
* `<cwd>/.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<string, AgentDefinition>();
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. <step>: 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",
},
];