270 lines
9.6 KiB
TypeScript
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",
|
|
},
|
|
];
|