/** * agy-pi — Google Antigravity CLI (agy) come subagent multimodale dentro pi. * * Permette a pi (agente principale, anche text-only) di delegare a agy task che * richiedono Gemini multimodale: conversazioni multi-turno, generazione immagini, * analisi di immagini/audio/video. * * Strumenti registrati: * - agy : delega un task a agy (chat, generazione o analisi) * - agy_conversation : gestione dello stato conversazione (id, list, reset) * * Comandi registrati: * - /agy : invia un prompt a agy in modo interattivo * - /agy:new : forza una nuova conversazione * - /agy:list : elenca le conversazioni * - /agy:reset : azzera lo stato conversazione */ import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { promisify } from "node:util"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Configurazione // --------------------------------------------------------------------------- const AGY_CHAT_DIR = path.join(os.homedir(), ".agy-chat"); const STATE_FILE = path.join(AGY_CHAT_DIR, "conversation_id"); const CONV_DIR = path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations"); const DEFAULT_TIMEOUT_MS = 180_000; // 3 min const IMAGE_TIMEOUT_MS = 300_000; // 5 min // --------------------------------------------------------------------------- // Helper: trovare il binario agy // --------------------------------------------------------------------------- function findAgy(): string { const candidates = [ process.env.AGY_BIN, path.join(os.homedir(), ".local", "bin", "agy"), "agy", ].filter(Boolean) as string[]; for (const c of candidates) { if (c === "agy") return c; // lascia che lo risolva la shell/PATH if (fs.existsSync(c)) return c; } return "agy"; } // --------------------------------------------------------------------------- // Helper: ultimo conversation_id (dal file .db più recente in conversations/) // --------------------------------------------------------------------------- function getLatestConversationId(): string | null { try { if (!fs.existsSync(CONV_DIR)) return null; const files = fs .readdirSync(CONV_DIR) .filter((f) => f.endsWith(".db")) .map((f) => path.join(CONV_DIR, f)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); if (files.length === 0) return null; return path.basename(files[0], ".db"); } catch { return null; } } // --------------------------------------------------------------------------- // Helper: leggere/scrivere lo stato conversazione // --------------------------------------------------------------------------- function readState(): string | null { try { return fs.readFileSync(STATE_FILE, "utf8").trim() || null; } catch { return null; } } function writeState(id: string) { try { fs.mkdirSync(AGY_CHAT_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, id, "utf8"); } catch { /* ignora */ } } function resetState() { try { fs.rmSync(STATE_FILE, { force: true }); } catch { /* ignora */ } } // --------------------------------------------------------------------------- // Helper: eseguire agy // --------------------------------------------------------------------------- interface RunResult { output: string; error: string | null; exitCode: number; } async function runAgy( args: string[], timeoutMs: number, signal?: AbortSignal, ): Promise { const bin = findAgy(); try { const { stdout, stderr } = await execFileAsync(bin, args, { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, signal, env: { ...process.env, PATH: `${path.join(os.homedir(), ".local", "bin")}:${process.env.PATH ?? ""}` }, }); return { output: stdout, error: stderr || null, exitCode: 0 }; } catch (err: any) { const code = typeof err.code === "number" ? err.code : 1; return { output: err.stdout || "", error: err.stderr || err.message || String(err), exitCode: code, }; } } // --------------------------------------------------------------------------- // Helper: costruire gli argomenti agy // --------------------------------------------------------------------------- interface AgyOptions { prompt: string; mode?: "chat" | "image" | "analyze"; model?: string; effort?: "low" | "medium" | "high"; newConversation?: boolean; addDir?: string; filePath?: string; yolo?: boolean; resumeId?: string; } function buildArgs(opts: AgyOptions): string[] { const args: string[] = ["-p", opts.prompt]; // add-dir (per analisi di file) const dirs = new Set(); if (opts.addDir) dirs.add(opts.addDir); if (opts.filePath) dirs.add(path.dirname(opts.filePath)); for (const d of dirs) args.push("--add-dir", d); // conversazione let convId: string | null = null; if (opts.resumeId) convId = opts.resumeId; else if (!opts.newConversation) convId = readState(); if (convId) args.push("--conversation", convId); if (opts.model) args.push("--model", opts.model); if (opts.effort) args.push("--effort", opts.effort); if (opts.yolo) args.push("--dangerously-skip-permissions"); return args; } // --------------------------------------------------------------------------- // Helper: formattare il risultato per il tool // --------------------------------------------------------------------------- function formatResult(r: RunResult, convId: string | null): string { let text = r.output.trim(); if (r.error) { // agy scrive i log su stderr; mostriamo solo errori significativi const err = r.error .split("\n") .filter((l) => !/logging before google\.Init/i.test(l)) .join("\n") .trim(); if (err && !text) text = err; } if (convId) text += `\n\n[conversazione agy: ${convId}]`; return text; } // --------------------------------------------------------------------------- // Estensione // --------------------------------------------------------------------------- export default function agyExtension(pi: ExtensionAPI) { // --- Tool: agy ---------------------------------------------------------- pi.registerTool({ name: "agy", label: "agy (Antigravity subagent)", description: "Delega un task a Google Antigravity CLI (agy), che usa Gemini multimodale. " + "Utile per: conversazioni di ragionamento multi-turno, generazione di immagini, " + "e analisi di file immagine/audio/video (passa filePath/addDir). " + "La conversazione viene mantenuta tra le chiamate (multi-shot).", parameters: Type.Object({ prompt: Type.String({ description: "Il task/prompt da dare a agy. Per generare un'immagine, descrivila. " + "Per analizzare un file, chiedi di analizzarlo e indica il percorso.", }), mode: Type.Optional( Type.Union( [ Type.Literal("chat"), Type.Literal("image"), Type.Literal("analyze"), ], { description: "chat (default) | image (genera immagine) | analyze (analizza file)" }, ), ), model: Type.Optional( Type.String({ description: "Modello agy (es. 'Gemini 3.1 Pro (High)', 'Claude Opus 4.6 (Thinking)'). " + "Ometti per usare il default.", }), ), effort: Type.Optional( Type.Union( [Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], { description: "Livello di ragionamento (default: medium)" }, ), ), newConversation: Type.Optional( Type.Boolean({ description: "true per forzare una nuova conversazione agy (dimentica i turni precedenti). Default: false.", }), ), addDir: Type.Optional( Type.String({ description: "Cartella da aggiungere al workspace di agy (necessaria per analizzare file).", }), ), filePath: Type.Optional( Type.String({ description: "Percorso del file (immagine/audio/video) da analizzare. La sua cartella viene aggiunta automaticamente.", }), ), yolo: Type.Optional( Type.Boolean({ description: "true per usare --dangerously-skip-permissions (auto-approva gli strumenti). " + "Necessario per analisi audio/video. Usa solo in ambienti fidati.", }), ), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const opts = params as AgyOptions; const args = buildArgs(opts); const timeout = opts.mode === "image" ? IMAGE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; onUpdate?.(`agy: ${opts.mode === "image" ? "generazione immagine" : "elaborazione"}...`); const r = await runAgy(args, timeout, signal); // Aggiorna lo stato conversazione let convId = readState(); if (r.exitCode === 0) { const latest = getLatestConversationId(); if (latest) { writeState(latest); convId = latest; } } const text = formatResult(r, convId); // Rileva il percorso di un'immagine generata const imgMatch = text.match(/IMAGE_PATH:\s*(\S+)/i) || text.match(/(\/[^\s]+\.(?:png|jpe?g|webp|gif))/i); const imagePath = imgMatch ? imgMatch[1] : undefined; return { content: [{ type: "text", text }], details: { conversationId: convId, exitCode: r.exitCode, imagePath, model: opts.model, }, }; }, }); // --- Tool: agy_conversation --------------------------------------------- pi.registerTool({ name: "agy_conversation", label: "agy conversation state", description: "Gestisce lo stato della conversazione agy: mostra l'ID corrente, elenca le conversazioni, o azzera lo stato.", parameters: Type.Object({ action: Type.Union( [Type.Literal("id"), Type.Literal("list"), Type.Literal("reset")], { description: "id: mostra l'ID corrente | list: elenca conversazioni | reset: azzera lo stato" }, ), }), async execute(toolCallId, params) { const action = (params as { action: string }).action; if (action === "id") { return { content: [{ type: "text", text: readState() ?? "(nessuna conversazione attiva)" }], details: {}, }; } if (action === "reset") { resetState(); return { content: [{ type: "text", text: "Stato conversazione azzerato." }], details: {} }; } // list try { if (!fs.existsSync(CONV_DIR)) { return { content: [{ type: "text", text: "(nessuna conversazione trovata)" }], details: {} }; } const files = fs .readdirSync(CONV_DIR) .filter((f) => f.endsWith(".db")) .map((f) => path.join(CONV_DIR, f)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs) .slice(0, 15); const lines = files.map((f) => { const id = path.basename(f, ".db"); const mtime = new Date(fs.statSync(f).mtimeMs).toISOString().replace("T", " ").slice(0, 19); return `${id}\t${mtime}`; }); return { content: [{ type: "text", text: lines.join("\n") || "(nessuna conversazione trovata)" }], details: {}, }; } catch (e: any) { return { content: [{ type: "text", text: `Errore: ${e.message}` }], details: {}, isError: true }; } }, }); // --- Comando: /agy ------------------------------------------------------- pi.registerCommand("agy", { description: "Invia un prompt a agy (subagent Antigravity). Uso: /agy ", handler: async (args, ctx) => { if (!args?.trim()) { ctx.ui.notify("Uso: /agy ", "error"); return; } ctx.ui.setStatus("agy", "agy: elaborazione..."); const r = await runAgy(buildArgs({ prompt: args.trim() }), DEFAULT_TIMEOUT_MS); if (r.exitCode === 0) { const latest = getLatestConversationId(); if (latest) writeState(latest); } ctx.ui.setStatus("agy", ""); ctx.ui.notify(r.output.trim() || r.error || "(nessun output)", "info"); }, }); pi.registerCommand("agy:new", { description: "Forza una nuova conversazione agy", handler: async (_args, ctx) => { resetState(); ctx.ui.notify("Nuova conversazione agy pronta.", "info"); }, }); pi.registerCommand("agy:list", { description: "Elenca le conversazioni agy", handler: async (_args, ctx) => { const id = readState(); ctx.ui.notify(`Conversazione corrente: ${id ?? "(nessuna)"}`, "info"); }, }); pi.registerCommand("agy:reset", { description: "Azzera lo stato conversazione agy", handler: async (_args, ctx) => { resetState(); ctx.ui.notify("Stato conversazione azzerato.", "info"); }, }); }