Publica agent-switcher: extensión de pi para cambio de agentes estilo opencode
This commit is contained in:
commit
e4b86fc6eb
7 files changed
+1156
No files matched your search
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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",
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user