Files
agy-pi/extensions/index.ts
T
enne2 aa888fe9cc feat(vocale): interpretazione come analista inter-agent in JSON + guida Perplexity
L'interpretazione del prompt vocale ora usa un framing inter-agent: Gemini è un
analista tecnico/middleware che scrive un briefing per l'orchestratore (NON
risponde all'utente), in JSON strutturato.
- Nuovo prompt: ruolo analista, destinatario = solo l'agente orchestratore,
  nessun testo rivolto all'utente, plan-first (nessuna esecuzione).
- Contratto JSON: {trascrizione_corretta, intent_analisi, note_per_agent,
  azioni_raccomandate, prompt_utente_pulito, ricerca_necessaria, suggerimenti_ricerca}.
- extractVoiceBriefing(): estrae prompt_utente_pulito (-> finalText) e se
  ricerca_necessaria=true aggiunge al prompt finale la nota di usare la ricerca
  web (Perplexity) per best practices/versioni/documentazione.
- Testato: parser JSON (valido, con markdown attorno, fallback su testo grezzo).
- docs/vocal-workflow.md aggiornato.
2026-08-11 20:26:59 +02:00

2713 lines
97 KiB
TypeScript

/**
* 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, execFileSync, spawn, spawnSync } 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, ExtensionContext } 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
// ---------------------------------------------------------------------------
// Configurazione persistente (~/.config/agy-pi/config.json)
// ---------------------------------------------------------------------------
const CONFIG_DIR = path.join(os.homedir(), ".config", "agy-pi");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
interface AgyConfig {
geminiApiKey?: string;
enne2ApiKey?: string;
sttBackend?: string; // gemini | enne2
sttUrl?: string;
sttModel?: string;
sttMaxDuration?: number; // secondi
ttsBackend?: string; // gemini | enne2
ttsNotify?: boolean;
ttsModel?: string;
agyBin?: string;
agyDefaultModel?: string;
agyTimeoutMs?: number;
// Fase 2 — Context Injection
contextInject?: boolean; // on|off
contextTokens?: number; // token cap per il contesto iniettato
webSearch?: string; // auto|on|off — Strada A: pi cerca + inietta
vocalPlanningMode?: boolean; // Opzione 4: piano + conferma prima di eseguire
}
const CONFIG_DEFAULTS: AgyConfig = {
sttBackend: "gemini",
sttUrl: "https://ai.enne2.net",
sttModel: "gemma4:E4B",
sttMaxDuration: 120,
ttsBackend: "gemini",
ttsNotify: true,
ttsModel: "gemini-2.5-flash-preview-tts",
agyTimeoutMs: 180_000,
contextInject: true,
contextTokens: 1500,
webSearch: "auto",
vocalPlanningMode: true,
};
function loadConfig(): AgyConfig {
try {
return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) };
} catch {
return { ...CONFIG_DEFAULTS };
}
}
function saveConfig(cfg: AgyConfig) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
} catch {
/* ignora */
}
}
// Legge una chiave: config file → env var → default
function getConfig(key: keyof AgyConfig): string | undefined {
const cfg = loadConfig();
const v = cfg[key];
if (v === undefined || v === "") return undefined;
return String(v);
}
function setConfig(key: keyof AgyConfig, value: string) {
const cfg = loadConfig();
const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs", "contextTokens"];
const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject", "vocalPlanningMode"];
if (numKeys.includes(key)) {
(cfg as any)[key] = Number(value);
} else if (boolKeys.includes(key)) {
(cfg as any)[key] = value === "true" || value === "1" || value === "yes";
} else {
(cfg as any)[key] = value;
}
saveConfig(cfg);
}
function resetConfig() {
try {
fs.rmSync(CONFIG_FILE, { force: true });
} catch {
/* ignora */
}
}
// ---------------------------------------------------------------------------
// Helper: trovare il binario agy
// ---------------------------------------------------------------------------
function findAgy(): string {
const candidates = [
getConfig("agyBin"),
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<RunResult> {
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;
}
}
// Esegue uno script Python/OpenCV sull'immagine per metriche oggettive:
// dimensioni, aspect ratio, luminosità, densità bordi e colori dominanti.
// Restituisce JSON (stringa) o stringa vuota in caso di errore.
async function runOpenCV(imagePath: string): Promise<string> {
const script = `
import cv2, json, sys, collections
img = cv2.imread(sys.argv[1])
if img is None:
print(json.dumps({"error": "cannot read image"}))
sys.exit(1)
h, w = img.shape[:2]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
brightness = round(float(gray.mean()), 1)
small = cv2.resize(img, (32, 32), interpolation=cv2.INTER_AREA)
colors = [tuple(int(x) for x in p) for p in small.reshape(-1, 3)]
counts = collections.Counter(colors)
dominant = [{"rgb": list(c), "count": n} for c, n in counts.most_common(3)]
edges = cv2.Canny(gray, 100, 200)
edge_density = round(float(edges.mean() / 255.0), 3)
print(json.dumps({
"width": w, "height": h,
"aspect_ratio": round(w / h, 3),
"brightness": brightness,
"edge_density": edge_density,
"dominant_colors": dominant
}))
`;
try {
const { stdout } = await execFileAsync("python3", ["-c", script, imagePath], {
timeout: 15_000,
});
return stdout.trim();
} catch {
return "";
}
}
// ---------------------------------------------------------------------------
// 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;
contextBlock?: string; // Fase 2: contesto iniettato prima della richiesta
}
// ===========================================================================
// Fase 2 — Context Injection (pi → agy)
// Best practice: tag XML, richiesta per ultima (anti lost-in-the-middle),
// prefisso stabile/cache, token cap, memoria durevole automatica, niente segreti.
// =========================================================================
const MEMORY_FILE = path.join(CONFIG_DIR, "memory.json");
interface DurableMemory {
goal?: string;
decisions: string[];
conventions: string[];
openQuestions: string[];
updatedAt: string;
}
function loadMemory(): DurableMemory {
try {
const raw = JSON.parse(fs.readFileSync(MEMORY_FILE, "utf8"));
return {
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
conventions: Array.isArray(raw.conventions) ? raw.conventions : [],
openQuestions: Array.isArray(raw.openQuestions) ? raw.openQuestions : [],
goal: typeof raw.goal === "string" ? raw.goal : undefined,
updatedAt: raw.updatedAt ?? "",
};
} catch {
return { decisions: [], conventions: [], openQuestions: [], updatedAt: "" };
}
}
function saveMemory(mem: DurableMemory) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
MEMORY_FILE,
JSON.stringify({ ...mem, updatedAt: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
} catch {
/* ignora */
}
}
// Auto-update: quando la conversazione pi cresce oltre la soglia, condensa i
// punti chiave recenti in durable facts invece di rigirare tutto il transcript.
function autoUpdateMemory(ctx: any, force = false) {
try {
const mem = loadMemory();
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
if (force || entries.length > 24) {
const userTexts: string[] = [];
for (const e of entries) {
if (e.type === "message" && e.message?.role === "user") {
const c = e.message.content;
const text =
typeof c === "string"
? c
: Array.isArray(c)
? c.map((b: any) => b.text ?? "").join(" ")
: "";
if (text && text.length > 20) userTexts.push(text.slice(0, 160));
}
}
if (!mem.goal && userTexts.length) mem.goal = userTexts[0];
// ultima decisione/azione chiave (ultimo user prompt significativo)
if (userTexts.length > 1) {
const last = userTexts[userTexts.length - 1];
const lastShort = last.length > 100 ? last.slice(0, 100) : last;
if (!mem.decisions.includes(lastShort)) {
mem.decisions.push(lastShort);
if (mem.decisions.length > 8) mem.decisions.shift();
}
}
saveMemory(mem);
}
} catch {
/* ignora */
}
}
function getGitInfo(cwd: string): { branch: string; dirty: boolean; modified: string[] } {
try {
const branch =
execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
encoding: "utf8",
})
.trim() || "(nessun branch)";
const porcelain = execFileSync("git", ["-C", cwd, "status", "--porcelain"], {
encoding: "utf8",
})
.split("\n")
.filter(Boolean);
return {
branch,
dirty: porcelain.length > 0,
modified: porcelain.slice(0, 8).map((l) => l.slice(0, 70)),
};
} catch {
return { branch: "(no git)", dirty: false, modified: [] };
}
}
// Estrae lo stato recente della conversazione pi (ultimi N messaggi utente)
// e lo compatta — NON il transcript grezzo (lost-in-the-middle, token bloat).
function extractSessionState(ctx: any): string {
try {
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
const userTexts: string[] = [];
for (let i = entries.length - 1; i >= 0 && userTexts.length < 3; i--) {
const e = entries[i];
if (e.type === "message" && e.message?.role === "user") {
const c = e.message.content;
const text =
typeof c === "string"
? c
: Array.isArray(c)
? c.map((b: any) => b.text ?? "").join(" ")
: "";
if (text) userTexts.push(text.replace(/\s+/g, " ").trim().slice(0, 200));
}
}
return userTexts.reverse().join("\n");
} catch {
return "";
}
}
// Cerca sul web tramite Gemini API (googleSearch grounding) e restituisce i
// risultati iniettabili. È la Strada A: l'estensione cerca, poi inietta.
async function webSearchGemini(query: string, signal?: AbortSignal): Promise<string> {
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
if (!key) {
try {
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
} catch {
/* ignora */
}
}
if (!key) return "";
const model = process.env.AGY_GEMINI_MODEL ?? "gemini-3.5-flash";
try {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`,
{
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: query }] }],
tools: [{ googleSearch: {} }],
}),
},
);
if (!res.ok) return "";
const data: any = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.map((p: any) => p.text ?? "").join(" ") ?? "";
return text.trim().slice(0, 600);
} catch {
return "";
}
}
// Chiamata generica alla Gemini API (solo testo, nessun tool). Restituisce la
// risposta completa oppure stringa vuota in caso di errore.
async function geminiText(prompt: string, signal?: AbortSignal): Promise<string> {
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
if (!key) {
try {
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
} catch {
/* ignora */
}
}
if (!key) return "";
const model = process.env.AGY_GEMINI_MODEL ?? "gemini-3.5-flash";
try {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`,
{
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
}),
},
);
if (!res.ok) return "";
const data: any = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.map((p: any) => p.text ?? "").join(" ") ?? "";
return text.trim();
} catch {
return "";
}
}
// Genera DINAMICAMENTE uno script Python/OpenCV specifico per i requisiti,
// usando l'LLM. Lo script legge l'immagine da argv[1] e stampa su stdout un JSON
// standard: {"pass": bool, "score": 0..1, "findings": [...], "errors": [...]}.
async function generateOpenCVScript(requirements: string): Promise<string> {
const prompt =
`Sei un esperto di computer vision con OpenCV. Scrivi UN SINGOLO script Python (solo codice, senza markdown) che verifichi un'immagine rispetto a questi requisiti:\n\n` +
`REQUISITI: ${requirements}\n\n` +
`Lo script:\n` +
`- legge il percorso immagine da sys.argv[1]\n` +
`- importa solo cv2, numpy, json, sys (e eventuali standard library)\n` +
`- esegue verifiche OGGETTIVE pertinenti ai requisiti (es. intervalli di colore HSV, forme/contorni, simmetria, testo, dimensioni, orientamento, ecc.)\n` +
`- stampa su stdout UN SOLO oggetto JSON: {"pass": true/false, "score": 0..1, "findings": [stringhe], "errors": [stringhe]}\n` +
` - pass: true se l'immagine rispetta i requisiti\n` +
` - score: grado di conformità da 0 a 1\n` +
` - findings: brevi note oggettive misurate (es. "colore dominante rosso RGB(200,30,30)")\n` +
` - errors: eventuali problemi di lettura/verifica\n` +
`- NON deve fallire se l'immagine è leggibile: gestisci con try/except e metti l'errore in errors\n` +
`- usa solo OpenCV, numpy e librerie standard (nessuna libreria esterna)\n\n` +
`Restituisci SOLO il codice Python, nessun testo aggiuntivo, nessun blocco markdown.`;
const code = await geminiText(prompt);
// rimuovi eventuali fence markdown
return code.replace(/```python|```/g, "").trim();
}
// Esegue lo script OpenCV generato sull'immagine e restituisce il JSON su stdout.
async function runOpenCVScript(script: string, imagePath: string): Promise<string> {
if (!script) return "";
try {
const { stdout } = await execFileAsync("python3", ["-c", script, imagePath], {
timeout: 20_000,
});
return stdout.trim();
} catch {
return "";
}
}
// Cache anti-ridondanza: evita ricerche web ripetute sullo stesso topic in una
// finestra breve (il multi-step reasoning lancia prompt simili in sequenza).
let lastSearch = { topic: "", time: 0 };
// Euristica per webSearch=auto. Determina se il prompt beneficia di dati
// aggiornati dalla ricerca web. Filtri negativi (immagini, codice locale,
// comandi) per non sprecare chiamate API/token su task che non servono.
function needsWebSearch(prompt: string, mode?: string): boolean {
if (mode === "image" || mode === "analyze") return false;
const ws = getConfig("webSearch") ?? "auto";
if (ws === "on") return true;
if (ws === "off") return false;
const p = prompt.trim().toLowerCase();
if (!p || p.length < 14) return false;
// --- Filtri negativi: task che NON beneficiano della ricerca web ---
// generazione / editing immagini
if (/(genera|crea|disegna|produrr|dipin|realizz).{0,30}(immagine|logo|icona|poster|banner|ritratto|illustraz|foto|meme|sfondo)/i.test(p))
return false;
// analisi locale / OCR / trascrizione / traduzione / voce
if (/(analizza questo|analizza il|\bocr\b|trascri|leggi il file|traduci|riassumi il file)/.test(p)) return false;
// task di codice puramente locale
if (/^(scrivi|rifattorizza|correggi|implementa|aggiungi|rimuovi|crea|modifica|fix|refactor|aggiorna il file|genera il codice)/.test(p))
return false;
// comandi / gestione conversazione
if (/^(continua|ricordati|ignora|vai avanti|mostra|riassumi la conversazione|non)/.test(p)) return false;
// --- Trigger positivi ---
// segnali di recency / tempo corrente
if (/(oggi|stamattina|adesso|al momento|quest'anno|del 20\d\d|nel 20\d\d|recent|ultim|appena|di recente|attuale|latest)/i.test(p))
return true;
// versioni / package / tech aggiornati
if (/(versione|version|release|changelog|release notes|npm|package|dipendenz|compatibil|lts|stabile|beta|deprecat|\beol\b|end of life|release date)/i.test(p))
return true;
// confronti / alternative / raccomandazioni
if (/(differenza|confronta|\bvs\b|alternativ|migliore|best|top|consigli|oppure)/i.test(p)) return true;
// domande informative / definizioni
if (/(cos'?è|che cos|chi è|quanto|quando|dove|perché|come funzion|cosa significa|definizion|significat|prezzo|costo|storia di)/i.test(p))
return true;
// news / novità / fatti correnti
if (/(news|notizie|novità|novita|ultime notizie|accadut|succes|eventi|mercato|azienda|lancio|annunci)/i.test(p))
return true;
return false;
}
const estTokens = (s: string) => Math.ceil(s.length / 4);
function truncToTokens(s: string, limit: number): string {
if (estTokens(s) <= limit) return s;
return s.slice(0, limit * 4);
}
// Assemblea del blocco di contesto con tag XML e token cap (~1500).
// La richiesta utente va SEMPRE dopo (anti lost-in-the-middle).
async function buildContextBlock(
ctx: any,
prompt: string,
signal?: AbortSignal,
mode?: string,
): Promise<string> {
if (getConfig("contextInject") === "off") return "";
const budget = Number(getConfig("contextTokens") ?? 1500) || 1500;
const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
const git = getGitInfo(cwd);
const now = new Date().toISOString();
const os = `${process.platform} ${process.arch}`;
const parts: { label: string; xml: string; body: string }[] = [];
// --- <environment_snapshot> ---
const envBody = [
`cwd: ${cwd}`,
`os: ${os}`,
`time: ${now}`,
`git_branch: ${git.branch}${git.dirty ? " (dirty)" : ""}`,
git.modified.length ? `modified: ${git.modified.join(" | ")}` : "",
]
.filter(Boolean)
.join("\n");
parts.push({ label: "env", xml: "environment_snapshot", body: envBody });
// --- <session_state> ---
const sessionState = extractSessionState(ctx);
if (sessionState) {
parts.push({ label: "session", xml: "session_state", body: sessionState });
}
// --- <durable_memory> ---
const mem = loadMemory();
const memLines = [
mem.goal ? `goal: ${mem.goal.slice(0, 120)}` : "",
mem.decisions.length ? `decisions: ${mem.decisions.join(" | ")}` : "",
mem.conventions.length ? `conventions: ${mem.conventions.join(" | ")}` : "",
mem.openQuestions.length ? `open_questions: ${mem.openQuestions.join(" | ")}` : "",
].filter(Boolean);
if (memLines.length) {
parts.push({ label: "memory", xml: "durable_memory", body: memLines.join("\n") });
}
// --- <web_context> (Strada A) ---
if (needsWebSearch(prompt, mode)) {
// anti-ridondanza: non ricercare lo stesso topic entro 60s
const topic = prompt.trim().toLowerCase().slice(0, 60);
const now = Date.now();
if (topic !== lastSearch.topic || now - lastSearch.time > 60_000) {
lastSearch = { topic, time: now };
const web = await webSearchGemini(prompt.slice(0, 300), signal);
if (web) parts.push({ label: "web", xml: "web_context", body: web });
}
}
// Token cap: budget totale ~1500. Assegna in modo proporzionale.
const blocks: string[] = [];
let used = 0;
const envBudget = Math.min(300, budget);
const sessionBudget = Math.min(500, budget);
const memBudget = Math.min(400, budget);
const webBudget = Math.min(300, budget);
const budgets: Record<string, number> = {
env: envBudget,
session: sessionBudget,
memory: memBudget,
web: webBudget,
};
for (const p of parts) {
const cap = budgets[p.label] ?? 200;
if (used >= budget) break;
const body = truncToTokens(p.body, cap);
if (!body) continue;
blocks.push(`<${p.xml}>
${body}
</${p.xml}>`);
used += estTokens(body) + 6;
}
if (!blocks.length) return "";
return `[CONTEXT_AGENTE] Contesto ambiente e stato corrente per la risposta. Usa solo se pertinente; la richiesta dell'utente è sotto.
${blocks.join("\n\n")}`;
}
// Workaround: se un file da analizzare è FUORI dalla cartella di lavoro corrente,
// lo copia in /tmp (cartella nativamente accessibile da agy) con un nome univoco
// casuale e riscrive i riferimenti nel prompt. Previene errori di accesso/competenza
// quando agy legge file esterni al workspace, senza sovrascrivere file esistenti.
function stageExternalFiles(
prompt: string,
filePaths: string[],
): { prompt: string; filePaths: string[]; dirs: string[] } {
const cwdBase = path.resolve(process.cwd()) + path.sep;
const stagedPaths: string[] = [];
let newPrompt = prompt;
for (const f of filePaths) {
if (!f) continue;
const abs = path.resolve(f);
// già sotto la cartella di lavoro corrente → nessuna copia necessaria
if (abs.startsWith(cwdBase)) {
stagedPaths.push(f);
continue;
}
try {
const ext = path.extname(abs);
const unique = `agy_stage_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}${ext}`;
const dest = path.join(os.tmpdir(), unique);
fs.copyFileSync(abs, dest);
// riscrivi i riferimenti nel prompt (path originale e assoluto)
newPrompt = newPrompt.split(f).join(dest).split(abs).join(dest);
stagedPaths.push(dest);
} catch {
// se la copia fallisce, lascia il path originale (potrebbe comunque funzionare)
stagedPaths.push(f);
}
}
// /tmp è accessibile nativamente da agy → nessun --add-dir necessario
return { prompt: newPrompt, filePaths: stagedPaths, dirs: [] };
}
// Estrae dal briefing JSON prodotto dall'interpretazione vocale i campi per
// l'orchestratore: prompt pulito, se serve ricerca web e suggerimenti.
function extractVoiceBriefing(text: string): {
cleanPrompt: string;
needsSearch: boolean;
searchHints: string[];
} {
const result = { cleanPrompt: "", needsSearch: false, searchHints: [] as string[] };
try {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start >= 0 && end > start) {
const obj = JSON.parse(text.slice(start, end + 1));
if (typeof obj.prompt_utente_pulito === "string") result.cleanPrompt = obj.prompt_utente_pulito.trim();
result.needsSearch = String(obj.ricerca_necessaria).toLowerCase() === "true";
if (Array.isArray(obj.suggerimenti_ricerca)) {
result.searchHints = obj.suggerimenti_ricerca.map(String).filter(Boolean);
}
}
} catch {
/* JSON non parsato: si usa il testo grezzo come fallback */
}
return result;
}
async function executeAgy(opts: AgyExecOptions) {
// Staging dei file esterni (workaround accesso agy a file fuori dal workspace)
const staged = stageExternalFiles(opts.prompt, opts.filePaths ?? []);
const finalPrompt = opts.contextBlock ? `${opts.contextBlock}\n\n${staged.prompt}` : staged.prompt;
const args: string[] = ["-p", finalPrompt];
// add-dir per i file (i file esterni sono stagizzati in /tmp, accessibile nativamente)
const dirs = new Set<string>();
for (const d of opts.addDirs ?? []) if (d) dirs.add(d);
for (const f of staged.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);
const model = opts.model ?? getConfig("agyDefaultModel");
if (model) args.push("--model", model);
if (opts.effort) args.push("--effort", opts.effort);
if (opts.yolo) args.push("--dangerously-skip-permissions");
const timeout =
opts.timeoutMs ??
Number(getConfig("agyTimeoutMs") ?? DEFAULT_TIMEOUT_MS) ??
(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: <percorso assoluto dell'immagine generata>";
// ---------------------------------------------------------------------------
// Registrazione microfono (F12) e trascrizione
// ---------------------------------------------------------------------------
const MAX_RECORD_MS = 120_000; // 2 min
interface Recording {
proc: ReturnType<typeof spawn>;
file: string;
startTime: number;
timer?: NodeJS.Timeout;
}
let recording: Recording | null = null;
function startRecording(ctx?: any): string {
const maxDur = Number(getConfig("sttMaxDuration") ?? 120);
const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`);
const proc = spawn(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-t", String(maxDur), file],
{ stdio: "ignore" },
);
const startTime = Date.now();
let timer: NodeJS.Timeout | undefined;
if (ctx) {
const updateStatus = () => {
const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
const elapsedMinStr = String(Math.floor(elapsedSec / 60)).padStart(2, "0");
const elapsedSecStr = String(elapsedSec % 60).padStart(2, "0");
const maxMinStr = String(Math.floor(maxDur / 60)).padStart(2, "0");
const maxSecStr = String(maxDur % 60).padStart(2, "0");
ctx.ui.setStatus(
"agy-rec",
`🔴 REGISTRAZIONE... ${elapsedMinStr}:${elapsedSecStr} / ${maxMinStr}:${maxSecStr} (Esc per annullare)`,
);
};
updateStatus();
timer = setInterval(updateStatus, 500);
}
recording = { proc, file, startTime, timer };
// se ffmpeg termina da solo (timeout max durata), azzera lo stato e avvisa
proc.on("exit", (code) => {
if (recording && recording.proc === proc) {
if (recording.timer) clearInterval(recording.timer);
recording = null;
playSound("timeout");
if (ctx) {
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("⏰ Tempo massimo di registrazione raggiunto", "warning");
}
}
});
return file;
}
function stopRecording(): Promise<string | null> {
return new Promise((resolve) => {
if (!recording) return resolve(null);
if (recording.timer) clearInterval(recording.timer);
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
});
}
function cancelRecording(): Promise<void> {
return new Promise((resolve) => {
if (!recording) return resolve();
if (recording.timer) clearInterval(recording.timer);
const { proc, file } = recording;
recording = null;
let done = false;
const cleanup = () => {
if (!done) {
done = true;
try {
if (fs.existsSync(file)) fs.unlinkSync(file);
} catch {
/* ignora */
}
resolve();
}
};
proc.on("exit", cleanup);
proc.kill("SIGKILL");
setTimeout(cleanup, 1000);
});
}
// ---------------------------------------------------------------------------
// Feedback sonori sci-fi (sintesi procedurale ffmpeg)
// ---------------------------------------------------------------------------
type SoundType = "start" | "stop" | "cancel" | "timeout" | "done";
// Espressioni aevalsrc per effetti fantascientifici
const SOUNDS: Record<SoundType, { expr: string; dur: string }> = {
// Power up: sweep ascendente 440→1760Hz
start: { expr: "sin(2*PI*(440+1320*t/0.15)*t)*(1-t/0.15)", dur: "0.15" },
// Deactivate: sweep discendente 1200→300Hz
stop: { expr: "sin(2*PI*(1200-900*t/0.18)*t)*(1-t/0.18)", dur: "0.18" },
// Error/Abort: modulato discendente a bassa freq
cancel: { expr: "sin(2*PI*(350-200*t/0.35)*t)*exp(-2*t)*(1+0.3*sin(2*PI*8*t))", dur: "0.35" },
// Warning beacon: pulsazione bi-tono (radar)
timeout: { expr: "if(lt(mod(t,0.5),0.15),sin(2*PI*700*t),if(lt(mod(t,0.5),0.35),0,sin(2*PI*500*t)))*0.8", dur: "1.2" },
// Success chime: arpeggio Do-Mi-Sol acuto
done: { expr: "if(lt(t,0.08),sin(2*PI*523*t),if(lt(t,0.16),sin(2*PI*659*t),sin(2*PI*784*t)))*(1-0.3*t)", dur: "0.28" },
};
function playSound(type: SoundType) {
const cfg = SOUNDS[type];
if (!cfg) return;
const wav = path.join(os.tmpdir(), `agy-sound-${type}-${Date.now()}.wav`);
// genera il tono sci-fi con ffmpeg (silenzioso) e riproduce con paplay/aplay/ffplay
try {
execFileAsync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
`aevalsrc='${cfg.expr}':d=${cfg.dur}:s=44100`,
wav,
],
{ timeout: 10_000 },
)
.then(() => {
execFileAsync("paplay", [wav], { timeout: 5000 }).catch(() => {
// fallback su aplay/ffplay se paplay non disponibile
execFileAsync("aplay", ["-q", wav], { timeout: 5000 }).catch(() => {
execFileAsync("ffplay", ["-nodisp", "-autoexit", wav], { timeout: 5000 }).catch(() => {});
});
});
// pulizia
setTimeout(() => {
try {
fs.rmSync(wav, { force: true });
} catch {
/* ignora */
}
}, 3000);
})
.catch(() => {});
} catch {
/* ignora */
}
}
// Ottimizza l'audio: taglia il silenzio iniziale/finale e converte in WAV 16kHz mono
async function optimizeAudio(input: string): Promise<string> {
const output = input.replace(/\.wav$/, "-opt.wav");
try {
await execFileAsync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-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",
"-ac",
"1",
"-ar",
"16000",
output,
],
{ timeout: 30_000 },
);
return output;
} catch {
return input; // fallback al file originale
}
}
// Trascrizione affidabile e veloce via Gemini API diretta
// (agy CLI non supporta audio; la API supporta audio/wav nativamente)
// Rileva se l'audio contiene parlato reale (non solo silenzio)
// Ritorna true se c'è segnale, false se è silenzio
function audioHasSpeech(file: string): boolean {
try {
const res = spawnSync(
"ffmpeg",
["-hide_banner", "-i", file, "-af", "volumedetect", "-f", "null", "-"],
{ timeout: 30_000, encoding: "utf8" },
);
const stderr = res.stderr || "";
const maxMatch = stderr.match(/max_volume: ([\-0-9.]+) dB/);
const meanMatch = stderr.match(/mean_volume: ([\-0-9.]+) dB/);
if (maxMatch) {
const max = parseFloat(maxMatch[1]);
// max < -35dB ≈ silenzio quasi totale
if (max < -35) return false;
}
if (meanMatch) {
const mean = parseFloat(meanMatch[1]);
if (mean < -45) return false;
}
return true;
} catch {
return true; // se non possiamo verificare, assumiamo che ci sia parlato
}
}
// Risultato trascrizione con dettaglio errore
interface TranscriptResult {
text: string;
error?: string;
}
// Trascrizione affidabile e veloce via Gemini API diretta
// (agy CLI non supporta audio; la API supporta audio/mpeg nativamente)
async function transcribeWithGeminiAPI(file: string): Promise<TranscriptResult> {
// key da config file o env var
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
if (!key) {
try {
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
} catch {
/* ignora */
}
}
if (!key) return { text: "", error: "Key Gemini mancante (imposta con /agy:config set geminiApiKey <chiave>)" };
// rileva silenzio prima di chiamare l'API (evita allucinazioni su audio vuoto)
if (!audioHasSpeech(file)) {
return { text: "", error: "Nessun parlato rilevato nell'audio (silenzio o volume troppo basso)" };
}
const model = process.env.AGY_GEMINI_MODEL ?? "gemini-3.5-flash";
try {
// Comprimi in Opus/OGG (audio/ogg) — molto più efficiente dell'MP3 per il parlato,
// riduce il payload di ~60% evitando HTTP 413 su registrazioni lunghe
let audioFile = file;
let mimeType = "audio/wav";
if (file.toLowerCase().endsWith(".wav")) {
const ogg = file.replace(/\.wav$/i, ".ogg");
try {
await execFileAsync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "16k", ogg],
{ timeout: 60_000 },
);
audioFile = ogg;
mimeType = "audio/ogg";
} catch (e: any) {
return { text: "", error: `Conversione Opus fallita: ${e.message}` };
}
}
const b64 = fs.readFileSync(audioFile).toString("base64");
const body = {
contents: [
{
parts: [
{ text: "Trascrivi fedelmente il contenuto di questo audio in italiano. Restituisci SOLO la trascrizione testuale." },
{ inline_data: { mime_type: mimeType, data: b64 } },
],
},
],
};
// retry su errori transitori (429, 500, 503)
let lastErr = "";
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(120_000),
},
);
if (res.ok) {
const data: any = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
if (!text) {
return { text: "", error: "La Gemini API ha restituito una risposta vuota" };
}
return { text };
}
lastErr = `HTTP ${res.status}`;
const errBody = await res.text().catch(() => "");
try {
const e = JSON.parse(errBody);
lastErr = `${lastErr}: ${e?.error?.message ?? ""}`.trim();
} catch {
/* ignora */
}
if (res.status !== 429 && res.status !== 500 && res.status !== 503) break;
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
}
return { text: "", error: lastErr || "Errore API Gemini sconosciuto" };
} catch (e: any) {
return { text: "", error: `Errore API Gemini: ${e?.message ?? String(e)}` };
}
}
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 "";
}
}
// ===========================================================================
// Opzione 4 — Workflow vocale a 2 fasi: overlay TUI con trascrizione + piano
// e conferma utente prima dell'esecuzione (Enter esegui, Esc annulla, E modifica,
// F12 registra di nuovo). Riusa l'infrastruttura overlay di /agy:key e /agy:status.
// =========================================================================
interface VoicePlanDecision {
action: "send" | "cancel" | "record";
text: string;
}
async function showVoicePlanOverlay(
ctx: ExtensionContext,
transcript: string,
plan: string,
): Promise<VoicePlanDecision> {
const { Container, Text, matchesKey, Key } = await import("@earendil-works/pi-tui");
const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
return ctx.ui.custom<VoicePlanDecision>(
(tui, theme, _keybindings, done) => {
let text = plan;
let editing = false;
const container = new Container();
const render = () => {
container.clear();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new Text(theme.fg("accent", theme.bold("🎙️ Conferma vocale")), 1, 1),
);
container.addChild(new Text("", 0, 0));
container.addChild(new Text(theme.fg("dim", "Trascrizione:"), 1, 0));
container.addChild(new Text(theme.fg("text", transcript.slice(0, 250)), 1, 0));
container.addChild(new Text("", 0, 0));
container.addChild(new Text(theme.fg("accent", "📋 Piano proposto:"), 1, 0));
container.addChild(new Text(theme.fg("text", text), 1, 0));
container.addChild(new Text("", 0, 0));
container.addChild(
new Text(
theme.fg(
"dim",
editing
? "✏️ Modifica: digitando cambia il testo • Enter applica • Esc annulla"
: "Enter esegui • Esc annulla • E modifica • F12 registra di nuovo",
),
1,
0,
),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
};
render();
return {
render: (w) => {
render();
return container.render(w);
},
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (editing) {
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
editing = false;
tui.requestRender();
return;
}
if (matchesKey(data, Key.backspace)) {
text = text.slice(0, -1);
tui.requestRender();
return;
}
if (data.length === 1 && data.charCodeAt(0) >= 32) {
text += data;
tui.requestRender();
}
return;
}
if (matchesKey(data, Key.enter)) {
done({ action: "send", text });
return;
}
if (matchesKey(data, Key.escape)) {
done({ action: "cancel", text });
return;
}
if (matchesKey(data, "e") || data === "E") {
editing = true;
tui.requestRender();
return;
}
if (matchesKey(data, Key.f12)) {
done({ action: "record", text });
return;
}
},
};
},
{
overlay: true,
overlayOptions: { width: "60%", minWidth: 60, anchor: "center" },
},
);
}
// Trascrizione via server locale ai.enne2.net (gemma4:E4B supporta audio)
async function transcribeWithEnne2(file: string): Promise<TranscriptResult> {
const baseUrl = getConfig("sttUrl") ?? "https://ai.enne2.net";
const model = getConfig("sttModel") ?? "gemma4:E4B";
const apiKey = getConfig("enne2ApiKey") ?? process.env.ENNE2_API_KEY ?? "";
// rileva silenzio
if (!audioHasSpeech(file)) {
return { text: "", error: "Nessun parlato rilevato nell'audio (silenzio o volume troppo basso)" };
}
try {
// Comprimi in MP3 per evitare HTTP 413 su audio lunghi (come il backend gemini)
let audioFile = file;
let audioFormat = "wav";
if (file.toLowerCase().endsWith(".wav")) {
const mp3 = file.replace(/\.wav$/i, ".mp3");
try {
await execFileAsync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libmp3lame", "-q:a", "5", mp3],
{ timeout: 60_000 },
);
audioFile = mp3;
audioFormat = "mp3";
} catch {
/* fallback al WAV */
}
}
const b64 = fs.readFileSync(audioFile).toString("base64");
const body = {
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: "Trascrivi fedelmente il parlato in questo audio. Rispondi solo con la trascrizione." },
{ type: "input_audio", input_audio: { data: b64, format: audioFormat } },
],
},
],
max_tokens: 2000,
};
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
return { text: "", error: `Errore server enne2: HTTP ${res.status}` };
}
const data: any = await res.json();
const text = data?.choices?.[0]?.message?.content?.trim() ?? "";
return { text };
} catch (e: any) {
return { text: "", error: `Errore server enne2: ${e?.message ?? String(e)}` };
}
}
// Dispatcher: sceglie il backend di trascrizione (gemini | enne2)
async function transcribeAudio(file: string): Promise<TranscriptResult> {
const backend = getConfig("sttBackend") ?? "gemini";
if (backend === "enne2" || backend === "local") {
return transcribeWithEnne2(file);
}
return transcribeWithGeminiAPI(file);
}
// ---------------------------------------------------------------------------
// TTS via Gemini API (nessun engine esterno)
// ---------------------------------------------------------------------------
async function ttsSpeak(text: string, outputDir?: string): Promise<string | null> {
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
if (!key) {
try {
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
} catch {
/* ignora */
}
}
if (!key) return null;
const model = getConfig("ttsModel") ?? "gemini-2.5-flash-preview-tts";
try {
const body = {
contents: [{ parts: [{ text }] }],
generationConfig: { responseModalities: ["AUDIO"] },
};
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
},
);
if (!res.ok) return null;
const data: any = await res.json();
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const audioPart = parts.find((p: any) => p.inlineData);
if (!audioPart) return null;
// salva PCM raw (16-bit, 24kHz) e converti in WAV
const pcmFile = path.join(os.tmpdir(), `tts-${Date.now()}.pcm`);
fs.writeFileSync(pcmFile, Buffer.from(audioPart.inlineData.data, "base64"));
const wavFile = pcmFile.replace(".pcm", ".wav");
await execFileAsync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", pcmFile, wavFile],
{ timeout: 30_000 },
);
fs.rmSync(pcmFile, { force: true });
// copia in outputDir se richiesto
let finalFile = wavFile;
if (outputDir) {
try {
fs.mkdirSync(outputDir, { recursive: true });
const dest = path.join(outputDir, `tts-${Date.now()}.wav`);
fs.copyFileSync(wavFile, dest);
finalFile = dest;
} catch {
/* ignora */
}
}
// riproduci (fire-and-forget)
execFileAsync("paplay", [finalFile], { timeout: 60_000 }).catch(() => {});
return finalFile;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Helper: onUpdate nel formato corretto (oggetto con content, non stringa)
// ---------------------------------------------------------------------------
function toolUpdate(onUpdate: any, text: string) {
onUpdate?.({ content: [{ type: "text", text }] });
}
// ---------------------------------------------------------------------------
// 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." })),
injectContext: Type.Optional(
Type.Boolean({ description: "Inietta contesto ambiente/sessione/web nel prompt (default true)." }),
),
webSearch: Type.Optional(
Type.Boolean({ description: "Forza/non forzare la ricerca web (override di webSearch config)." }),
),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
toolUpdate(onUpdate, `agy: ${p.mode === "image" ? "generazione immagine" : "elaborazione"}...`);
// Fase 2: costruzione contesto iniettato (ambiente + sessione + memoria + web)
const wantInject = p.injectContext ?? true;
const prevWs = getConfig("webSearch");
if (typeof p.webSearch === "boolean") setConfig("webSearch", p.webSearch ? "on" : "off");
const contextBlock = wantInject ? await buildContextBlock(ctx, p.prompt, signal, p.mode) : "";
if (typeof p.webSearch === "boolean" && prevWs) setConfig("webSearch", prevWs);
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,
contextBlock,
});
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;
toolUpdate(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;
toolUpdate(onUpdate, "agy_edit: modifica immagine...");
const res = await executeAgy({
prompt,
mode: "image",
model: p.model,
stateless: true,
filePaths: [p.baseImage],
outputDir: p.outputDir,
yolo: true,
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;
toolUpdate(onUpdate, "agy_inpaint: modifica zona specifica...");
const res = await executeAgy({
prompt,
mode: "image",
model: p.model,
stateless: true,
filePaths: [p.baseImage],
outputDir: p.outputDir,
yolo: true,
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;
toolUpdate(onUpdate, "agy_style_transfer: applica stile...");
const res = await executeAgy({
prompt,
mode: "image",
model: p.model,
stateless: true,
filePaths: [p.baseImage],
outputDir: p.outputDir,
yolo: true,
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;
toolUpdate(onUpdate, "agy_compose: combina immagini...");
const res = await executeAgy({
prompt,
mode: "image",
model: p.model,
stateless: true,
filePaths: p.images,
outputDir: p.outputDir,
yolo: true,
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;
toolUpdate(onUpdate, "agy_character: mantieni consistenza personaggio...");
const res = await executeAgy({
prompt,
mode: "image",
model: p.model,
stateless: true,
filePaths: [p.referenceImage],
outputDir: p.outputDir,
yolo: true,
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.`;
toolUpdate(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_create_verified — generazione iterativa con verifica
// Loop self-contained: genera immagine → analizza con visione → verifica
// OpenCV → rigenera se non rispetta i requisiti (fino a maxIterations).
// =========================================================================
pi.registerTool({
name: "agy_create_verified",
label: "agy create verified image",
description:
"Genera un'immagine che soddisfi i requisiti, la verifica iterativamente con visione " +
"testuale e metriche OpenCV, e la rigenera finché non rispetta i requisiti (o fino a un limite). " +
"Ideale quando l'orchestratore non ha visione e serve un'immagine conforme a specifiche precise.",
parameters: Type.Object({
requirements: Type.String({ description: "Requisiti precisi che l'immagine deve soddisfare." }),
outputDir: Type.Optional(Type.String({ description: "Cartella dove salvare l'immagine finale." })),
maxIterations: Type.Optional(Type.Number({ description: "Limite massimo di iterazioni (default 3, max 5)." })),
useOpenCV: Type.Optional(Type.Boolean({ description: "Esegui metriche OpenCV oggettive (default true)." })),
model: Type.Optional(Type.String({ description: "Modello agy." })),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
const requirements = String(p.requirements ?? "").trim();
const maxIter = Math.min(Math.max(Number(p.maxIterations ?? 3) || 3, 1), 5);
const useCV = p.useOpenCV ?? true;
toolUpdate(onUpdate, "agy_create_verified: avvio loop di creazione verificata...");
let currentImage: string | undefined;
let lastIssues = "";
const report: {
iteration: number;
pass: boolean;
imagePath: string;
verdict: string;
opencv: string;
}[] = [];
let finalText = "";
// Genera DINAMICAMENTE lo script OpenCV specifico per i requisiti (una volta,
// i requisiti sono fissi) e lo riusa in ogni iterazione del loop.
let cvScript = "";
if (useCV) {
toolUpdate(onUpdate, "agy_create_verified: generazione script OpenCV dinamico...");
cvScript = await generateOpenCVScript(requirements);
}
for (let iter = 1; iter <= maxIter; iter++) {
toolUpdate(
onUpdate,
`agy_create_verified: iterazione ${iter}/${maxIter} (${iter === 1 ? "generazione" : "rigenerazione"})...`,
);
// 1) Genera (o rigenera) con prompt di correzione se non è la prima
const genPrompt = currentImage
? `L'immagine precedente (${currentImage}) non rispetta i requisiti per questi motivi: ${lastIssues}. ` +
`Rigenera/rettifica l'immagine per soddisfare esattamente: ${requirements}.`
: `Genera un'immagine che soddisfi esattamente questi requisiti: ${requirements}.`;
const genRes = await executeAgy({
prompt: genPrompt + IMG_SUFFIX,
mode: "image",
model: p.model,
stateless: true,
filePaths: currentImage ? [currentImage] : [],
outputDir: p.outputDir,
signal,
});
const imgPath = genRes.imagePath;
if (!imgPath) {
finalText = `Generazione fallita all'iterazione ${iter}: nessuna immagine prodotta.\n${genRes.text}`;
break;
}
currentImage = imgPath;
// 2) Analisi visione (giudice): PASS/FAIL + problemi rispetto ai requisiti
const judgePrompt =
`Analizza il file al percorso ${imgPath}. Requisiti richiesti: ${requirements}. ` +
`Verifica se l'immagine li rispetta. Rispondi iniziando con \"PASS:\" o \"FAIL:\", ` +
`poi elenca sinteticamente (max 3 punti) le deviazioni rispetto ai requisiti in caso di FAIL.`;
const judgeRes = await executeAgy({
prompt: judgePrompt,
mode: "analyze",
model: p.model,
stateless: true,
filePaths: [imgPath],
yolo: true,
signal,
});
const verdict = judgeRes.text.trim();
const visionPass = /^\s*PASS:?/i.test(verdict);
lastIssues = verdict;
// 3) Verifica OpenCV DINAMICA (script generato dall'LLM per i requisiti)
let cvMetrics = "";
let ocvPass: boolean | null = null;
if (useCV && cvScript) {
const out = await runOpenCVScript(cvScript, imgPath);
cvMetrics = out;
try {
const parsed = JSON.parse(out);
ocvPass = typeof parsed.pass === "boolean" ? parsed.pass : null;
if (typeof parsed.score === "number") cvMetrics += `\nscore: ${parsed.score}`;
if (Array.isArray(parsed.findings) && parsed.findings.length)
cvMetrics += `\nfindings: ${parsed.findings.join("; ")}`;
} catch {
ocvPass = null;
}
// Se OpenCV rileva non conformità, le aggiunge alle issue per guidare la rigenerazione
if (ocvPass === false) {
lastIssues = `${verdict}\nVerifica OpenCV: ${cvMetrics}`;
}
}
// Conforme solo se visione PASS e (se disponibile) anche OpenCV PASS
const pass = visionPass && (ocvPass === null || ocvPass === true);
report.push({ iteration: iter, pass, imagePath: imgPath, verdict, opencv: cvMetrics });
if (pass) {
finalText =
`✅ Immagine verificata dopo ${iter} iterazione/i.\nPercorso: ${imgPath}` +
(cvMetrics ? `\nMetriche OpenCV: ${cvMetrics}` : "") +
`\nVerdetto visione:\n${verdict}`;
break;
}
if (iter === maxIter) {
finalText =
`⚠️ Requisiti non soddisfatti dopo ${maxIter} iterazioni.\nPercorso: ${imgPath}` +
(cvMetrics ? `\nMetriche OpenCV: ${cvMetrics}` : "") +
`\nUltimo verdetto visione:\n${verdict}`;
}
}
return {
content: [{ type: "text", text: finalText || "Nessun output." }],
details: {
iterations: report.length,
imagePath: currentImage,
report,
},
};
},
});
// =========================================================================
// TOOL: agy_transcribe — trascrizione audio (via Gemini API diretta)
// =========================================================================
pi.registerTool({
name: "agy_transcribe",
label: "agy transcribe audio",
description:
"Trascrive il contenuto di un file audio (voce, discorso) in testo usando la Gemini API diretta " +
"(veloce e affidabile; agy CLI non supporta audio). Richiede la key Gemini in ~/.agy-chat/gemini-key o GEMINI_API_KEY.",
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 Gemini (default: gemini-3.5-flash)." })),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
toolUpdate(onUpdate, "agy_transcribe: trascrizione audio...");
const tr = await transcribeAudio(p.filePath);
if (!tr.text) {
return {
content: [{ type: "text", text: `Trascrizione fallita: ${tr.error ?? "vuota"}` }],
details: { filePath: p.filePath, error: tr.error },
isError: true,
};
}
const transcript = tr.text;
return {
content: [{ type: "text", text: transcript }],
details: { filePath: p.filePath, model: p.model ?? "gemini-3.5-flash" },
};
},
});
// =========================================================================
// 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.`;
toolUpdate(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_tts — text-to-speech via Gemini API
// =========================================================================
pi.registerTool({
name: "agy_tts",
label: "agy TTS (text to speech)",
description:
"Converte un testo in audio (TTS) usando la Gemini API (gemini-2.5-flash-preview-tts) e lo riproduce. " +
"Utile per notifiche vocali, sintesi parlate e aggiornamenti di stato. " +
"Richiede la key Gemini in ~/.agy-chat/gemini-key o GEMINI_API_KEY.",
parameters: Type.Object({
text: Type.String({ description: "Il testo da pronunciare." }),
play: Type.Optional(Type.Boolean({ description: "true per riprodurre l'audio (default: true)." })),
outputDir: Type.Optional(Type.String({ description: "Cartella dove salvare il file audio." })),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
toolUpdate(onUpdate, "agy_tts: sintesi vocale...");
return ttsSpeak(p.text, p.outputDir).then((file) => {
if (!file) {
return {
content: [{ type: "text", text: "TTS fallito: key Gemini mancante o errore API." }],
details: {},
isError: true,
};
}
return {
content: [{ type: "text", text: `Audio TTS generato: ${file}` }],
details: { audioFile: file },
};
});
},
});
// =========================================================================
// 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 <prompt>",
handler: async (args, ctx) => {
if (!args?.trim()) {
ctx.ui.notify("Uso: /agy <prompt>", "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);
playSound("start");
ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, Esc per annullare)", "info");
return;
}
playSound("stop");
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 tr = await transcribeAudio(optimized);
if (!tr.text) {
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify(`Trascrizione fallita: ${tr.error ?? "vuota"}`, "error");
playSound("cancel");
return;
}
const transcript = tr.text;
// Legge il testo scritto a mano nel campo editor prima della registrazione
// e lo combina con la trascrizione audio (comportamento "multimodale").
const editorText = (ctx.ui.getEditorText?.() ?? "").trim();
// Interpreta con Gemini (testo) — framing inter-agent: Gemini è un analista
// che scrive un briefing per l'orchestratore (NON risponde all'utente),
// in JSON strutturato, con direttiva plan-first (nessuna esecuzione).
ctx.ui.notify("Interpretazione con Gemini...", "info");
const context = getConversationContext(ctx);
const res = await executeAgy({
prompt:
`[CONTESTO INTERNO — COMUNICAZIONE TRA AGENTI]\n` +
`Sei un analista tecnico/middleware per un agente AI orchestratore. NON rispondere all'utente: il tuo output sarà letto SOLO dall'orchestratore, che poi risponderà all'utente.` +
`\n\nAnalizza la richiesta vocale (e l'eventuale testo dell'editor) e produci un briefing strutturato per l'orchestratore.` +
`\n\nTrascrizione vocale:\n${transcript}` +
(editorText
? `\n\nTesto scritto dall'utente nel campo di input (da combinare con la voce):\n${editorText}`
: "") +
`\n\nContesto della conversazione:\n${context || "(nessuno)"}` +
`\n\nRestituisci UN SOLO oggetto JSON (nessun testo aggiuntivo) con questi campi:` +
`\n{"trascrizione_corretta":"...","intent_analisi":"...","note_per_agent":"...","azioni_raccomandate":["..."],"prompt_utente_pulito":"...","ricerca_necessaria":true/false,"suggerimenti_ricerca":["..."]}` +
`\nRegole:` +
`\n- NON eseguire alcuno strumento o azione; solo analisi e briefing.` +
`\n- Nessun saluto o testo rivolto all'utente: solo JSON tecnico per l'orchestratore.` +
`\n- prompt_utente_pulito = la richiesta rielaborata che l'orchestratore userà come prompt verso l'utente.` +
`\n- ricerca_necessaria=true se serve verificare best practices, versioni, documentazione o dati aggiornati.` +
`\n- suggerimenti_ricerca: se ricerca_necessaria, indica all'orchestratore di usare la ricerca web (Perplexity) e su cosa.`,
stateless: true,
model: "Gemini 3.6 Flash (Medium)",
yolo: true,
});
ctx.ui.setStatus("agy-rec", "");
// Estrae il briefing JSON; fallback sul testo grezzo se non parsato
const briefing = extractVoiceBriefing(res.text);
let finalText = briefing.cleanPrompt || res.text.trim() || transcript;
// Guida per l'agente successivo: usa la ricerca web/Perplexity se necessario
if (briefing.needsSearch) {
const hints = briefing.searchHints.length ? ` Suggerimenti: ${briefing.searchHints.join("; ")}` : "";
finalText += `\n\n[Nota per l'agente: per rispondere correttamente, usa la ricerca web (Perplexity) per verificare best practices/versioni/documentazione aggiornate.${hints}]`;
}
// Il testo dell'editor è stato consumato: lo svuota per evitare reinvii duplicati.
if (editorText) {
try {
ctx.ui.setEditorText?.("");
} catch {
/* ignora */
}
}
// Opzione 4: piano + conferma in overlay TUI prima di eseguire (se abilitata)
const planningMode = getConfig("vocalPlanningMode") ?? "true";
if (planningMode !== "false") {
const decision = await showVoicePlanOverlay(ctx, transcript, finalText);
if (decision.action === "cancel") {
ctx.ui.notify("Vocale annullato — nessuna azione eseguita.", "info");
playSound("cancel");
return;
}
if (decision.action === "record") {
ctx.ui.notify("Registra di nuovo con F12.", "info");
return;
}
finalText = decision.text;
}
// Inserisci il risultato come prompt su pi
if (ctx.isIdle()) {
pi.sendUserMessage(finalText);
} else {
pi.sendUserMessage(finalText, { deliverAs: "followUp" });
}
ctx.ui.notify("✅ Trascrizione inviata come prompt a pi", "info");
playSound("done");
// notifica vocale (config ttsNotify o AGY_TTS_NOTIFY=0 per disattivare)
const ttsNotify = getConfig("ttsNotify") ?? "true";
if (ttsNotify !== "false" && process.env.AGY_TTS_NOTIFY !== "0") {
ttsSpeak("Trascrizione completata e inviata.").catch(() => {});
}
}
pi.registerShortcut("f12", {
description: "Avvia/ferma registrazione microfono (max 2 min) e trascrive via Gemini",
handler: async (ctx) => {
await handleRecordToggle(ctx);
},
});
pi.registerShortcut("escape", {
description: "Annulla la registrazione microfono in corso",
handler: async (ctx) => {
if (recording) {
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
playSound("cancel");
}
},
});
pi.registerShortcut("esc", {
description: "Annulla la registrazione microfono in corso",
handler: async (ctx) => {
if (recording) {
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
playSound("cancel");
}
},
});
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);
},
});
pi.registerCommand("agy:record:cancel", {
description: "Annulla la registrazione microfono in corso",
handler: async (_args, ctx) => {
if (!recording) {
ctx.ui.notify("Nessuna registrazione in corso da annullare", "warning");
return;
}
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
playSound("cancel");
},
});
pi.registerCommand("agy:speak", {
description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak <testo>",
handler: async (args, ctx) => {
if (!args?.trim()) {
ctx.ui.notify("Uso: /agy:speak <testo>", "error");
return;
}
ctx.ui.setStatus("agy-tts", "🔊 sintesi vocale...");
const file = await ttsSpeak(args.trim());
ctx.ui.setStatus("agy-tts", "");
if (file) ctx.ui.notify(`🔊 Audio: ${file}`, "info");
else ctx.ui.notify("TTS fallito (key Gemini mancante?)", "error");
},
});
pi.registerCommand("agy:vocal", {
description: "Attiva/disattiva il feedback vocale TTS. Uso: /agy:vocal [on|off|status]",
handler: async (args, ctx) => {
const arg = (args ?? "").trim().toLowerCase();
const current = getConfig("ttsNotify") ?? "true";
if (arg === "on") {
setConfig("ttsNotify", "true");
ctx.ui.notify("🔊 Feedback vocale ATTIVATO", "info");
ttsSpeak("Feedback vocale attivato.").catch(() => {});
return;
}
if (arg === "off") {
setConfig("ttsNotify", "false");
ctx.ui.notify("🔇 Feedback vocale DISATTIVATO", "info");
return;
}
if (arg === "status" || !arg) {
const on = current !== "false";
ctx.ui.notify(on ? "🔊 Feedback vocale: ATTIVO" : "🔇 Feedback vocale: DISATTIVO", "info");
return;
}
// toggle
if (current === "false") {
setConfig("ttsNotify", "true");
ctx.ui.notify("🔊 Feedback vocale ATTIVATO", "info");
ttsSpeak("Feedback vocale attivato.").catch(() => {});
} else {
setConfig("ttsNotify", "false");
ctx.ui.notify("🔇 Feedback vocale DISATTIVATO", "info");
}
},
});
// =========================================================================
// Comando: /agy:config — gestione configurazione persistente
// =========================================================================
const CONFIG_KEYS: { key: keyof AgyConfig; desc: string }[] = [
{ key: "geminiApiKey", desc: "Chiave API Google Gemini (STT/TTS)" },
{ key: "enne2ApiKey", desc: "Token per il server proxy ai.enne2.net (opzionale)" },
{ key: "sttBackend", desc: "Backend trascrizione: gemini | enne2" },
{ key: "sttUrl", desc: "URL base backend enne2" },
{ key: "sttModel", desc: "Modello STT backend enne2" },
{ key: "sttMaxDuration", desc: "Durata max registrazione (secondi)" },
{ key: "ttsBackend", desc: "Backend TTS: gemini | enne2" },
{ key: "ttsNotify", desc: "Notifiche vocali automatiche: true | false" },
{ key: "ttsModel", desc: "Modello TTS Gemini" },
{ key: "agyBin", desc: "Path del binario agy" },
{ key: "agyDefaultModel", desc: "Modello predefinito per le chiamate agy" },
{ key: "agyTimeoutMs", desc: "Timeout esecuzione agy (ms)" },
{ key: "contextInject", desc: "Iniezione contesto nel prompt agy: true | false" },
{ key: "contextTokens", desc: "Token cap per il contesto iniettato (default 1500)" },
{ key: "webSearch", desc: "Ricerca web Strada A: auto | on | off" },
{ key: "vocalPlanningMode", desc: "Opzione 4: piano+conferma dopo il vocale (true|false)" },
];
pi.registerCommand("agy:config", {
description:
"Gestisce la configurazione dell'estensione. Uso: /agy:config [get|set|reset] [chiave] [valore]",
handler: async (args, ctx) => {
const parts = (args ?? "").trim().split(/\s+/);
const action = parts[0] ?? "";
const key = parts[1] as keyof AgyConfig | undefined;
const value = parts.slice(2).join(" ");
// /agy:config — elenca tutto
if (!action) {
const cfg = loadConfig();
const lines = CONFIG_KEYS.map(({ key: k, desc }) => {
const v = (cfg as any)[k];
const masked =
k === "geminiApiKey" || k === "enne2ApiKey"
? v
? `${String(v).slice(0, 4)}...${String(v).slice(-4)}`
: "(non impostata)"
: v ?? "(non impostata)";
return `${k} = ${masked}${desc}`;
});
ctx.ui.notify(`Config agy-pi (${CONFIG_FILE}):\n${lines.join("\n")}`, "info");
return;
}
// /agy:config get <key>
if (action === "get" && key) {
const v = getConfig(key);
ctx.ui.notify(`${key} = ${v ?? "(non impostata)"}`, "info");
return;
}
// /agy:config set <key> <value>
if (action === "set" && key && value) {
if (!CONFIG_KEYS.some((c) => c.key === key)) {
ctx.ui.notify(`Chiave sconosciuta: ${key}`, "error");
return;
}
setConfig(key, value);
ctx.ui.notify(`✅ ${key} impostato. File: ${CONFIG_FILE}`, "info");
return;
}
// /agy:config reset
if (action === "reset") {
resetConfig();
ctx.ui.notify("Configurazione azzerata (default ripristinati).", "info");
return;
}
ctx.ui.notify(
"Uso: /agy:config | /agy:config get <chiave> | /agy:config set <chiave> <valore> | /agy:config reset",
"warning",
);
},
});
// =========================================================================
// Comando: /agy:key — dialog overlay TUI per inserire/modificare la chiave
// API Gemini in modo interattivo (campo mascherato, Enter conferma, Esc
// annulla). Usa i componenti TUI di pi (overlay in sovrimpressione).
// =========================================================================
pi.registerCommand("agy:key", {
description:
"Apre un dialog overlay per inserire/modificare la chiave API Gemini (campo mascherato)",
handler: async (_args, ctx) => {
// Import dinamico: se pi-tui non fosse disponibile, fallisce solo questo
// comando senza rompere il caricamento dell'intera estensione.
const {
Container,
Text,
matchesKey,
Key,
} = await import("@earendil-works/pi-tui");
const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
const result = await ctx.ui.custom<string | null>(
(tui, theme, _keybindings, done) => {
let value = "";
const currentKey = getConfig("geminiApiKey") ?? "";
const maskCurrent = currentKey
? `${currentKey.slice(0, 4)}...${currentKey.slice(-4)}`
: "(non impostata)";
const mask = (v: string) => "•".repeat(v.length);
const container = new Container();
const renderDialog = () => {
container.clear();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new Text(theme.fg("accent", theme.bold("🔑 Chiave API Gemini")), 1, 1),
);
container.addChild(
new Text(theme.fg("dim", `Attuale: ${maskCurrent}`), 1, 0),
);
container.addChild(new Text("", 0, 0));
const field = value ? mask(value) : "(vuota)";
container.addChild(
new Text(
theme.fg("text", "Nuova chiave: ") + theme.fg("warning", field),
1,
0,
(s) => theme.bg("toolPendingBg", s),
),
);
container.addChild(new Text("", 0, 0));
container.addChild(
new Text(
theme.fg("dim", "Digita la chiave • Enter conferma • Esc annulla"),
1,
0,
),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
};
renderDialog();
return {
render: (w) => {
renderDialog();
return container.render(w);
},
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.enter)) {
const trimmed = value.trim();
if (trimmed) {
setConfig("geminiApiKey", trimmed);
// Aggiorna anche ~/.agy-chat/gemini-key per coerenza con gli script TTS/STT
try {
fs.mkdirSync(AGY_CHAT_DIR, { recursive: true });
fs.writeFileSync(
path.join(AGY_CHAT_DIR, "gemini-key"),
trimmed,
{ mode: 0o600 },
);
} catch {
/* ignora */
}
done(trimmed);
}
return;
}
if (matchesKey(data, Key.escape)) {
done(null);
return;
}
if (matchesKey(data, Key.backspace)) {
value = value.slice(0, -1);
tui.requestRender();
return;
}
// Caratteri stampabili permessi per una chiave API (A-Z a-z 0-9 _ . -)
if (data.length === 1 && data.charCodeAt(0) >= 32) {
if (/^[A-Za-z0-9._-]+$/.test(data)) {
value += data;
}
tui.requestRender();
}
},
};
},
{
overlay: true,
overlayOptions: {
width: "50%",
minWidth: 54,
anchor: "center",
},
},
);
if (result) {
ctx.ui.notify(
`✅ Chiave API Gemini aggiornata: ${result.slice(0, 4)}...${result.slice(-4)}`,
"info",
);
try {
playSound("done");
} catch {
/* ignora */
}
} else {
ctx.ui.notify("Chiave non modificata.", "info");
}
},
});
// =========================================================================
// Comando: /agy:status — diagnostica estensione in un overlay TUI
// Mostra: binario agy, versione, chiave Gemini, modello attivo, ultimo
// errore dal log, stato config. Chiuso con Enter o Esc.
// =========================================================================
pi.registerCommand("agy:status", {
description: "Mostra lo stato dell'estensione (agi, chiave, modello, ultimo errore)",
handler: async (_args, ctx) => {
const { Container, Text, matchesKey, Key } = await import("@earendil-works/pi-tui");
const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
// ---- raccolta diagnostica ----
const agyBin = findAgy();
const agyExists =
agyBin === "agy" ? fs.existsSync("/home/enne2/.local/bin/agy") : fs.existsSync(agyBin);
let agyVersion = "(sconosciuta)";
if (agyExists) {
try {
agyVersion =
(
await execFileAsync(agyBin, ["--version"], {
timeout: 8000,
env: { ...process.env, NO_COLOR: "1" },
})
).stdout.trim() || "(sconosciuta)";
} catch {
agyVersion = "(errore esecuzione)";
}
}
const cfgKey = getConfig("geminiApiKey") ?? "";
const fileKey = (() => {
try {
const f = path.join(AGY_CHAT_DIR, "gemini-key");
return fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim() : "";
} catch {
return "";
}
})();
const key = cfgKey || fileKey;
const keyStatus = key ? "✅ valida" : "❌ mancante (usa /agy:key)";
const keyMask = key ? `${key.slice(0, 4)}...${key.slice(-4)}` : "—";
const model = getConfig("agyDefaultModel") || "(default agy)";
const sttBackend = getConfig("sttBackend") ?? "gemini";
const ttsNotify = getConfig("ttsNotify") ?? "true";
// ultimo errore dal log agy (ultima riga con 'ERROR'/'denied'/'quota')
let lastError = "(nessuno)";
try {
const logDir = path.join(os.homedir(), ".gemini", "antigravity-cli", "log");
if (fs.existsSync(logDir)) {
const logs = fs
.readdirSync(logDir)
.map((f) => path.join(logDir, f))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
const logFile = logs[0];
if (logFile) {
const lines = fs.readFileSync(logFile, "utf8").split("\n");
const meaningful = [...lines]
.reverse()
.filter((l) => /error|denied|quota|failed|timed out/i.test(l))
.filter((l) => !/^ERROR: logging before google\.Init:/i.test(l.trim()));
if (meaningful.length) {
const raw = meaningful[0];
// estrai il messaggio dopo il pattern glog: "go:file.go:NN] msg" oppure dopo ":"
const m = raw.match(/\][\s]*\s*\s*(.*)$/) || raw.match(/:\s*(.*)$/i);
lastError = (m ? m[1] : raw).replace(/^ERROR:|^WARN:/i, "").trim().slice(0, 90) || "(vedi log)";
}
}
}
} catch {
lastError = "(non disponibile)";
}
// ---- overlay TUI ----
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const container = new Container();
const render = () => {
container.clear();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new Text(theme.fg("accent", theme.bold("📊 Stato agy-pi")), 1, 1),
);
container.addChild(new Text("", 0, 0));
const line = (label: string, v: string, color: "success" | "error" | "text" | "muted" | "accent" | "warning" | "dim") =>
container.addChild(
new Text(theme.fg("dim", `${label}: `) + theme.fg(color, v), 1, 0),
);
line("Binario agy", agyExists ? `trovato ${agyBin}` : "❌ NON trovato",
agyExists ? "success" : "error");
line("Versione", agyVersion, "text");
line("Chiave Gemini", keyStatus, key ? "success" : "error");
line("Chiave (mask)", keyMask, "muted");
line("Modello attivo", model, "accent");
line("Backend STT", sttBackend, "text");
line("Notifiche TTS", ttsNotify, "text");
container.addChild(new Text("", 0, 0));
container.addChild(
new Text(theme.fg("warning", "Ultimo errore:") + " " + theme.fg("text", lastError), 1, 0),
);
container.addChild(new Text("", 0, 0));
container.addChild(
new Text(theme.fg("dim", "Enter o Esc per chiudere"), 1, 0),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
};
render();
return {
render: (w) => {
render();
return container.render(w);
},
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) done();
},
};
},
{
overlay: true,
overlayOptions: { width: "55%", minWidth: 56, anchor: "center" },
},
);
},
});
}