/** * agy-pi — Google Antigravity CLI (agy) come subagent multimodale dentro pi. * * Espone un set completo di strumenti specializzati per generazione ed editing * di immagini, analisi di file, trascrizione audio, analisi video e gestione * conversazione. Ogni strumento costruisce un prompt ottimizzato secondo le * best practice di Gemini/Nano Banana (Keep+Change+Add+Render, un cambiamento * per turno, ri-attacco dell'immagine base, ecc.). * * L'agente (pi) decide quale strumento usare in base al task. */ import { execFile, spawn } 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; if (fs.existsSync(c)) return c; } return "agy"; } // --------------------------------------------------------------------------- // Helper: ultimo conversation_id // --------------------------------------------------------------------------- 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; } } 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(); // --output-format json: agy non scrive nulla su stdout quando è piped/redirected // (bug #76). Il formato json emette un oggetto con la risposta in .response. const fullArgs = [...args, "--output-format", "json"]; try { const { stdout, stderr } = await execFileAsync(bin, fullArgs, { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, signal, env: { ...process.env, PATH: `${path.join(os.homedir(), ".local", "bin")}:${process.env.PATH ?? ""}` }, }); // estrai la risposta dal JSON let output = stdout; try { const parsed = JSON.parse(stdout); if (parsed && typeof parsed.response === "string") { output = parsed.response; } } catch { /* non-JSON: usa stdout grezzo */ } return { output, 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: estrarre il percorso immagine dall'output // --------------------------------------------------------------------------- function extractImagePath(text: string): string | undefined { const m = text.match(/IMAGE_PATH:\s*(\S+)/i) || text.match(/(\/[^\s]+\.(?:png|jpe?g|webp|gif))/i); return m ? m[1] : undefined; } // --------------------------------------------------------------------------- // Helper: copiare l'immagine generata in una cartella di output // --------------------------------------------------------------------------- function copyImage(src: string | undefined, outputDir?: string): string | undefined { if (!src || !outputDir) return src; try { fs.mkdirSync(outputDir, { recursive: true }); const dest = path.join(outputDir, path.basename(src)); fs.copyFileSync(src, dest); return dest; } catch { return src; } } // --------------------------------------------------------------------------- // Helper: esecuzione comune di un tool agy // --------------------------------------------------------------------------- interface AgyExecOptions { prompt: string; mode?: "chat" | "image" | "analyze"; model?: string; effort?: "low" | "medium" | "high"; newConversation?: boolean; stateless?: boolean; // non continua la conversazione (ri-attacca l'immagine base) addDirs?: string[]; filePaths?: string[]; yolo?: boolean; timeoutMs?: number; outputDir?: string; signal?: AbortSignal; } async function executeAgy(opts: AgyExecOptions) { const args: string[] = ["-p", opts.prompt]; // add-dir per i file const dirs = new Set(); for (const d of opts.addDirs ?? []) if (d) dirs.add(d); for (const f of opts.filePaths ?? []) if (f) dirs.add(path.dirname(f)); for (const d of dirs) args.push("--add-dir", d); // conversazione const useState = !opts.stateless && !opts.newConversation; let convId: string | null = null; if (opts.newConversation) convId = null; else if (opts.stateless) convId = null; else 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"); const timeout = opts.timeoutMs ?? (opts.mode === "image" ? IMAGE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS); const r = await runAgy(args, timeout, opts.signal); // aggiorna stato conversazione (solo se non stateless) if (r.exitCode === 0 && !opts.stateless) { const latest = getLatestConversationId(); if (latest) writeState(latest); } let text = r.output.trim(); if (r.error) { const err = r.error .split("\n") .filter((l) => !/logging before google\.Init/i.test(l)) .join("\n") .trim(); if (err && !text) text = err; } const imagePath = copyImage(extractImagePath(text), opts.outputDir); if (imagePath && opts.outputDir) { text += `\n\n[immagine copiata in: ${imagePath}]`; } return { text, imagePath, conversationId: convId, exitCode: r.exitCode, }; } // --------------------------------------------------------------------------- // Helper: costruire il suffisso "IMAGE_PATH" per i tool immagine // --------------------------------------------------------------------------- const IMG_SUFFIX = "\n\nAlla fine della risposta, scrivi su una riga esattamente: IMAGE_PATH: "; // --------------------------------------------------------------------------- // Registrazione microfono (F12) e trascrizione // --------------------------------------------------------------------------- const MAX_RECORD_MS = 120_000; // 2 min interface Recording { proc: ReturnType; file: string; } let recording: Recording | null = null; function startRecording(): string { const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`); const proc = spawn( "ffmpeg", ["-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-t", "120", file], { stdio: "ignore" }, ); recording = { proc, file }; // se ffmpeg termina da solo (timeout 2 min), azzera lo stato proc.on("exit", () => { if (recording && recording.proc === proc) recording = null; }); return file; } function stopRecording(): Promise { return new Promise((resolve) => { if (!recording) return resolve(null); const { proc, file } = recording; recording = null; let done = false; const finish = () => { if (!done) { done = true; resolve(file); } }; proc.on("exit", finish); proc.kill("SIGINT"); // ffmpeg finalizza il file setTimeout(finish, 3000); // fallback }); } // Ottimizza l'audio: taglia il silenzio iniziale/finale e converte in MP3 (più piccolo) async function optimizeAudio(input: string): Promise { const output = input.replace(/\.wav$/, ".mp3"); try { await execFileAsync( "ffmpeg", [ "-y", "-i", input, "-af", "silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.5,areverse,silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.5,areverse", "-c:a", "libmp3lame", "-q:a", "4", output, ], { timeout: 30_000 }, ); return output; } catch { return input; // fallback al file originale } } function extractText(content: any): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter((p: any) => p && p.type === "text" && typeof p.text === "string") .map((p: any) => p.text) .join(" "); } return ""; } function getConversationContext(ctx: any, maxEntries = 8): string { try { const entries = ctx.sessionManager.getEntries(); const recent = entries.slice(-maxEntries); const lines: string[] = []; for (const e of recent) { if (e.type !== "message" || !e.message) continue; const text = extractText(e.message.content); if (!text) continue; const who = e.message.role === "user" ? "Utente" : e.message.role === "assistant" ? "Assistente" : "Strumento"; lines.push(`${who}: ${text}`); } return lines.join("\n"); } catch { return ""; } } // --------------------------------------------------------------------------- // Estensione // --------------------------------------------------------------------------- export default function agyExtension(pi: ExtensionAPI) { // ========================================================================= // TOOL: agy — generico (chat / image / analyze) // ========================================================================= pi.registerTool({ name: "agy", label: "agy (Antigravity subagent)", description: "Delega un task generico a Google Antigravity CLI (agy), che usa Gemini multimodale. " + "Utile per conversazioni di ragionamento multi-turno, generazione immagini e analisi di file. " + "Per task specializzati usa agy_generate, agy_edit, agy_inpaint, agy_style_transfer, agy_compose, " + "agy_analyze, agy_transcribe, agy_video.", parameters: Type.Object({ prompt: Type.String({ description: "Il task/prompt da dare a agy." }), mode: Type.Optional( Type.Union([Type.Literal("chat"), Type.Literal("image"), Type.Literal("analyze")], { description: "chat (default) | image | analyze", }), ), model: Type.Optional(Type.String({ description: "Modello agy (es. 'Gemini 3.1 Pro (High)')." })), effort: Type.Optional( Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]), ), newConversation: Type.Optional(Type.Boolean({ description: "true per forzare una nuova conversazione." })), addDir: Type.Optional(Type.String({ description: "Cartella da aggiungere al workspace agy." })), filePath: Type.Optional(Type.String({ description: "File (immagine/audio/video) da analizzare." })), yolo: Type.Optional(Type.Boolean({ description: "true per --dangerously-skip-permissions." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; onUpdate?.(`agy: ${p.mode === "image" ? "generazione immagine" : "elaborazione"}...`); const res = await executeAgy({ prompt: p.prompt, mode: p.mode, model: p.model, effort: p.effort, newConversation: p.newConversation, addDirs: p.addDir ? [p.addDir] : [], filePaths: p.filePath ? [p.filePath] : [], yolo: p.yolo, signal, }); return { content: [{ type: "text", text: res.text }], details: { conversationId: res.conversationId, exitCode: res.exitCode, imagePath: res.imagePath, model: p.model }, }; }, }); // ========================================================================= // TOOL: agy_generate — generazione immagine strutturata // ========================================================================= pi.registerTool({ name: "agy_generate", label: "agy generate image", description: "Genera un'immagine da zero con parametri strutturati (soggetto, azione, luogo, composizione, stile, luce, formato). " + "Usa la formula narrativa [Soggetto]+[Azione]+[Luogo]+[Composizione]+[Stile] per il massimo controllo.", parameters: Type.Object({ subject: Type.String({ description: "Soggetto: chi/cosa è nell'immagine. Sii specifico." }), action: Type.Optional(Type.String({ description: "Azione: cosa sta succedendo." })), location: Type.Optional(Type.String({ description: "Luogo/contesto/sfondo." })), composition: Type.Optional( Type.String({ description: "Composizione: inquadratura (es. 'close-up', 'wide shot', 'low-angle')." }), ), style: Type.Optional(Type.String({ description: "Stile: estetica (es. 'fotorealistico', 'watercolor', 'film noir')." })), lighting: Type.Optional(Type.String({ description: "Illuminazione (es. 'golden hour', 'softbox', 'neon')." })), aspectRatio: Type.Optional( Type.String({ description: "Formato (es. '1:1', '16:9', '9:16', '4:3', '21:9')." }), ), text: Type.Optional(Type.String({ description: "Testo da includere nell'immagine (tra virgolette)." })), negative: Type.Optional(Type.String({ description: "Cosa evitare, in framing positivo (es. 'nessun testo')." })), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine generata." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const parts = [`Genera un'immagine: ${p.subject}`]; if (p.action) parts.push(`Azione: ${p.action}`); if (p.location) parts.push(`Luogo/contesto: ${p.location}`); if (p.composition) parts.push(`Composizione: ${p.composition}`); if (p.style) parts.push(`Stile: ${p.style}`); if (p.lighting) parts.push(`Illuminazione: ${p.lighting}`); if (p.aspectRatio) parts.push(`Formato/aspect ratio: ${p.aspectRatio}`); if (p.text) parts.push(`Includi il testo "${p.text}" nell'immagine.`); if (p.negative) parts.push(`Evita: ${p.negative}.`); const prompt = parts.join(". ") + IMG_SUFFIX; onUpdate?.("agy_generate: generazione immagine..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_edit — editing controllato (Keep + Change + Add + Render) // ========================================================================= pi.registerTool({ name: "agy_edit", label: "agy edit image", description: "Modifica un'immagine esistente in modo controllato usando la formula Keep+Change+Add+Render. " + "Specifica cosa mantenere invariato, cosa cambiare, cosa aggiungere e il target di resa. " + "Un solo cambiamento per turno per il massimo controllo.", parameters: Type.Object({ baseImage: Type.String({ description: "Percorso dell'immagine base da modificare." }), keep: Type.String({ description: "Cosa mantenere invariato (es. 'soggetto, posa, illuminazione, composizione')." }), change: Type.String({ description: "Cosa cambiare (es. 'il colore del divano in blu navy')." }), add: Type.Optional(Type.String({ description: "Cosa aggiungere (es. 'un vaso sul tavolo')." })), render: Type.Optional( Type.String({ description: "Target di resa (es. 'foto premium', 'stile editoriale', 'formato 4:5')." }), ), preserveAspectRatio: Type.Optional( Type.Boolean({ description: "true per non cambiare l'aspect ratio dell'input." }), ), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const keep = p.preserveAspectRatio ? `${p.keep}. Non cambiare l'aspect ratio dell'immagine di input.` : p.keep; let prompt = `Usando l'immagine al percorso ${p.baseImage} come base, mantieni ${keep} invariato. Cambia ${p.change}.`; if (p.add) prompt += ` Aggiungi ${p.add}.`; if (p.render) prompt += ` Render come ${p.render}.`; prompt += IMG_SUFFIX; onUpdate?.("agy_edit: modifica immagine..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, filePaths: [p.baseImage], outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_inpaint — editing di una zona specifica (semantic masking) // ========================================================================= pi.registerTool({ name: "agy_inpaint", label: "agy inpaint (edit zona specifica)", description: "Modifica SOLO una parte specifica dell'immagine lasciando il resto intatto (inpainting / semantic masking). " + "Definisci l'elemento target e la sostituzione; tutto il resto resta identico.", parameters: Type.Object({ baseImage: Type.String({ description: "Percorso dell'immagine base." }), target: Type.String({ description: "L'elemento specifico da modificare (es. 'la maglietta del soggetto')." }), replacement: Type.String({ description: "La nuova descrizione dell'elemento (es. 'una maglietta rossa')." }), keepRest: Type.Optional( Type.String({ description: "Cosa mantenere identico (default: tutto il resto)." }), ), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const keep = p.keepRest ?? "tutto il resto"; const prompt = `Usando l'immagine al percorso ${p.baseImage}, cambia SOLO ${p.target} in ${p.replacement}. ` + `Mantieni ${keep} esattamente identico, preservando stile, illuminazione e composizione originali.` + IMG_SUFFIX; onUpdate?.("agy_inpaint: modifica zona specifica..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, filePaths: [p.baseImage], outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_style_transfer — applica uno stile preservando il contenuto // ========================================================================= pi.registerTool({ name: "agy_style_transfer", label: "agy style transfer", description: "Applica uno stile artistico a un'immagine preservando il contenuto e la composizione originali " + "(style transfer). Es. trasformare una foto in un dipinto Van Gogh.", parameters: Type.Object({ baseImage: Type.String({ description: "Percorso dell'immagine base." }), style: Type.String({ description: "Lo stile da applicare (es. 'pittura Van Gogh', 'architectural drawing', 'film noir')." }), preserve: Type.Optional( Type.String({ description: "Cosa preservare (default: 'la composizione originale')." }), ), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const preserve = p.preserve ?? "la composizione originale"; const prompt = `Usando l'immagine al percorso ${p.baseImage}, trasforma il contenuto nello stile di ${p.style}. ` + `Preserva ${preserve} ma renderizzala con lo stile richiesto.` + IMG_SUFFIX; onUpdate?.("agy_style_transfer: applica stile..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, filePaths: [p.baseImage], outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_compose — combina più immagini // ========================================================================= pi.registerTool({ name: "agy_compose", label: "agy compose (combina immagini)", description: "Combina più immagini in una nuova composizione (multi-image composition / fusion). " + "Specifica il ruolo di ciascuna immagine e l'istruzione di fusione.", parameters: Type.Object({ images: Type.Array(Type.String({ description: "Percorsi delle immagini da combinare." }), { description: "Lista di percorsi immagine (fino a ~6-14).", }), instruction: Type.String({ description: "Istruzione di fusione: cosa prendere da ciascuna immagine e come combinarle.", }), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const refs = (p.images as string[]).map((img, i) => `Immagine ${i + 1}: ${img}`).join("\n"); const prompt = `Combina le seguenti immagini in una nuova composizione:\n${refs}\n\n` + `Istruzione: ${p.instruction}. Specifica il ruolo di ciascuna immagine.` + IMG_SUFFIX; onUpdate?.("agy_compose: combina immagini..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, filePaths: p.images, outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, images: p.images, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_character — consistenza personaggio // ========================================================================= pi.registerTool({ name: "agy_character", label: "agy character consistency", description: "Mantiene la consistenza di un personaggio/oggetto attraverso più generazioni o edit. " + "Usa un'immagine di riferimento e un token di consistenza per preservare le caratteristiche.", parameters: Type.Object({ referenceImage: Type.String({ description: "Percorso dell'immagine di riferimento del personaggio." }), name: Type.String({ description: "Nome/token del personaggio (es. 'Maya-giacca-blu')." }), features: Type.String({ description: "Caratteristiche immutabili da preservare (es. 'cicatrice sopracciglio sinistro')." }), task: Type.String({ description: "Cosa fare con il personaggio (es. 'mettilo in una scena notturna')." }), model: Type.Optional(Type.String({ description: "Modello agy." })), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const prompt = `Usando l'immagine al percorso ${p.referenceImage} come riferimento del personaggio '${p.name}', ` + `${p.task}. Mantieni le caratteristiche del personaggio identiche: ${p.features}. ` + `Usa il token '${p.name}' per riferirti al personaggio.` + IMG_SUFFIX; onUpdate?.("agy_character: mantieni consistenza personaggio..."); const res = await executeAgy({ prompt, mode: "image", model: p.model, stateless: true, filePaths: [p.referenceImage], outputDir: p.outputDir, signal, }); return { content: [{ type: "text", text: res.text }], details: { imagePath: res.imagePath, referenceImage: p.referenceImage, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_analyze — analisi di un file (immagine/audio/video) // ========================================================================= pi.registerTool({ name: "agy_analyze", label: "agy analyze file", description: "Analizza un file (immagine, audio o video) con Gemini multimodale. " + "Per immagini: descrizione, OCR, analisi. Per audio: contenuto, trascrizione. Per video: scene, contenuto.", parameters: Type.Object({ filePath: Type.String({ description: "Percorso del file da analizzare." }), question: Type.Optional(Type.String({ description: "Domanda specifica sull'analisi." })), model: Type.Optional(Type.String({ description: "Modello agy." })), yolo: Type.Optional(Type.Boolean({ description: "true per --dangerously-skip-permissions (necessario per audio/video)." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const prompt = p.question ? `Analizza il file al percorso ${p.filePath}. ${p.question}` : `Analizza il file al percorso ${p.filePath} e descrivilo in dettaglio.`; onUpdate?.("agy_analyze: analisi file..."); const res = await executeAgy({ prompt, mode: "analyze", model: p.model, stateless: true, filePaths: [p.filePath], yolo: p.yolo, signal, }); return { content: [{ type: "text", text: res.text }], details: { filePath: p.filePath, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_transcribe — trascrizione audio // ========================================================================= pi.registerTool({ name: "agy_transcribe", label: "agy transcribe audio", description: "Trascrive il contenuto di un file audio (voce, discorso) in testo. " + "Richiede --yolo (auto-approva gli strumenti).", parameters: Type.Object({ filePath: Type.String({ description: "Percorso del file audio (wav, mp3, m4a, ecc.)." }), language: Type.Optional(Type.String({ description: "Lingua del contenuto (es. 'italiano', 'english')." })), model: Type.Optional(Type.String({ description: "Modello agy." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const lang = p.language ? ` La lingua del contenuto è ${p.language}.` : ""; const prompt = `Trascrivi il contenuto del file audio al percorso ${p.filePath}.` + lang + ` Restituisci la trascrizione testuale completa e fedele.`; onUpdate?.("agy_transcribe: trascrizione audio..."); const res = await executeAgy({ prompt, mode: "analyze", model: p.model, stateless: true, filePaths: [p.filePath], yolo: true, signal, }); return { content: [{ type: "text", text: res.text }], details: { filePath: p.filePath, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_video — analisi video // ========================================================================= pi.registerTool({ name: "agy_video", label: "agy analyze video", description: "Analizza un file video: descrive scene, contenuto, codec, risoluzione, tracce audio. " + "Richiede --yolo (auto-approva gli strumenti).", parameters: Type.Object({ filePath: Type.String({ description: "Percorso del file video (mp4, mov, ecc.)." }), question: Type.Optional(Type.String({ description: "Domanda specifica sul video." })), model: Type.Optional(Type.String({ description: "Modello agy." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; const prompt = p.question ? `Analizza il video al percorso ${p.filePath}. ${p.question}` : `Analizza il video al percorso ${p.filePath}: descrivi cosa mostra, codec, risoluzione e se contiene audio.`; onUpdate?.("agy_video: analisi video..."); const res = await executeAgy({ prompt, mode: "analyze", model: p.model, stateless: true, filePaths: [p.filePath], yolo: true, signal, }); return { content: [{ type: "text", text: res.text }], details: { filePath: p.filePath, exitCode: res.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_models — elenca i modelli disponibili // ========================================================================= pi.registerTool({ name: "agy_models", label: "agy list models", description: "Elenca i modelli disponibili per agy (Gemini, Claude, ecc.).", parameters: Type.Object({}), async execute(toolCallId, params, signal) { const r = await runAgy(["models"], 30_000, signal); return { content: [{ type: "text", text: r.output.trim() || r.error || "(nessun output)" }], details: { exitCode: r.exitCode }, }; }, }); // ========================================================================= // TOOL: agy_conversation — gestione stato conversazione // ========================================================================= 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 | list | reset" }, ), }), 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: {} }; } 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 }; } }, }); // ========================================================================= // Comandi interattivi // ========================================================================= 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 res = await executeAgy({ prompt: args.trim() }); ctx.ui.setStatus("agy", ""); ctx.ui.notify(res.text || "(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"); }, }); // ========================================================================= // Registrazione microfono (F12) + trascrizione via Gemini + prompt su pi // ========================================================================= async function handleRecordToggle(ctx: any) { if (!recording) { startRecording(); ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, max 2 min)", "info"); ctx.ui.setStatus("agy-rec", "🔴 REGISTRAZIONE..."); return; } ctx.ui.setStatus("agy-rec", "⏹️ Finalizzazione..."); const file = await stopRecording(); if (!file) { ctx.ui.setStatus("agy-rec", ""); ctx.ui.notify("Nessuna registrazione attiva", "warning"); return; } ctx.ui.notify("Registrazione fermata, ottimizzazione audio...", "info"); const optimized = await optimizeAudio(file); ctx.ui.notify("Trascrizione in corso...", "info"); const context = getConversationContext(ctx); const res = await executeAgy({ prompt: `Trascrivi fedelmente il contenuto del file audio al percorso ${optimized}.` + `\n\nContesto della conversazione:\n${context || "(nessuno)"}` + `\n\nRestituisci SOLO la trascrizione testuale, senza commenti.`, mode: "analyze", stateless: true, filePaths: [optimized], yolo: true, model: "Gemini 3.6 Flash (Medium)", effort: "low", }); ctx.ui.setStatus("agy-rec", ""); const transcription = res.text.trim(); if (!transcription) { ctx.ui.notify("Trascrizione vuota o errore", "error"); return; } // Inserisci il risultato come prompt su pi if (ctx.isIdle()) { pi.sendUserMessage(transcription); } else { pi.sendUserMessage(transcription, { deliverAs: "followUp" }); } ctx.ui.notify("✅ Trascrizione inviata come prompt a pi", "info"); } pi.registerShortcut("f12", { description: "Avvia/ferma registrazione microfono (max 2 min) e trascrive via Gemini", handler: async (ctx) => { await handleRecordToggle(ctx); }, }); pi.registerCommand("agy:record", { description: "Avvia/ferma registrazione microfono (come F12)", handler: async (_args, ctx) => { await handleRecordToggle(ctx); }, }); pi.registerCommand("agy:record:stop", { description: "Ferma la registrazione microfono in corso", handler: async (_args, ctx) => { if (!recording) { ctx.ui.notify("Nessuna registrazione in corso", "warning"); return; } await handleRecordToggle(ctx); }, }); }