Files
agy-pi/extensions/index.ts
T
Matteo Benedetto a0f94c8811 feat(images): direct Antigravity image API for image tools with CLI fallback
Replace the agent/CLI image path with a direct call to the Antigravity
Cloud Code gateway (same OAuth token and project discovery already used by
the provider), keeping the CLI as an automatic fallback.

Protocol validated with 15 live probes on 2026-09-13:
- POST v1internal:generateContent (non-streaming), requestType "image_gen",
  responseModalities ["IMAGE"], imageConfig {aspectRatio, imageSize}
- image returned as candidates[0].content.parts[].inlineData (base64, jpeg);
  parts with thought=true are intermediate images and must be skipped
- 512/2K/4K verified (512x512 .. 5504x3072, 7-25s, 1.1-2.9K output tokens)
- image input works (single, multiple) and editing honours the input when
  imageConfig.aspectRatio is supplied; HTTP 200 without images carries a
  finishMessage explaining the refusal
- gemini-3-pro-image is not entitled on this account (HTTP 404)
- the image model quota is shared and can be exhausted (429 QUOTA_EXHAUSTED
  with a "reset after" delay)

Changes:
- add antgGenerateImages(): direct client with token refresh retry, image
  part parsing and finishMessage surfacing
- add antgSaveImages() and agyImageTask(): direct-first runner with automatic
  CLI fallback, reporting engine, imagePaths, usage and fallback reason
- add antgParseQuotaDelayMs() + blocked-until guard so an exhausted image
  quota skips the direct attempt instead of retrying it every time
- route agy_generate, agy_edit, agy_inpaint, agy_style_transfer, agy_compose
  and agy_character through agyImageTask; add imageSize to agy_generate
- fix extractImagePath(): it matched the IMAGE_PATH placeholder echoed from
  the prompt and returned a bogus path on CLI failures; now only existing
  absolute image paths are accepted

Verified: module loads, tools invoke agy_generate through the direct engine
(8.6s, 1199 output tokens, file saved to outputDir), the CLI fallback fires
with an explicit reason on 429, and extractImagePath passes 5/5 cases tested
against the real function extracted from this file.
2026-09-13 19:49:18 +02:00

3422 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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";
import {
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
calculateCost,
type Context,
createAssistantMessageEventStream,
type Model,
type SimpleStreamOptions,
type ThinkingLevelMap,
type ToolCall,
} from "@earendil-works/pi-ai/compat";
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
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"]);
const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".webm"]);
const AUDIO_MAX_BYTES = 25 * 1024 * 1024;
// ---------------------------------------------------------------------------
// 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 {
sttMaxDuration?: number; // secondi
agyBin?: string;
agyDefaultModel?: string;
agyTimeoutMs?: number;
// Fase 2 — Context Injection
contextInject?: boolean; // on|off
contextTokens?: number; // token cap per il contesto iniettato
vocalPlanningMode?: boolean; // Opzione 4: piano + conferma prima di eseguire
voiceModel?: string; // Modello Antigravity multimodale per l'audio
}
const CONFIG_DEFAULTS: AgyConfig = {
sttMaxDuration: 120,
agyTimeoutMs: 180_000,
contextInject: true,
contextTokens: 1500,
vocalPlanningMode: true,
voiceModel: "gemini-3.7-flash-medium",
};
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)[] = ["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 {
// Candidati: prima la riga IMAGE_PATH, poi qualsiasi path immagine assoluto nel testo.
// I placeholder ecoati dal prompt (es. "IMAGE_PATH: <percorso assoluto ...>") e i
// path inesistenti vengono scartati: senza il filtro si ottenevano percorsi fasulli.
const candidates: string[] = [];
const m = text.match(/IMAGE_PATH:\s*(\S+)/i);
if (m?.[1]) candidates.push(m[1]);
for (const mm of text.matchAll(/(\/[^\s"'`]+?\.(?:png|jpe?g|webp|gif))/gi)) candidates.push(mm[1]);
for (const candidate of candidates) {
const clean = candidate.replace(/[.,;:)\]}>]+$/, "");
if (!clean || clean.includes("<") || clean.includes(">")) continue;
if (fs.existsSync(clean)) return clean;
}
return 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 "";
}
}
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") });
}
// 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 budgets: Record<string, number> = {
env: envBudget,
session: sessionBudget,
memory: memBudget,
};
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: trascrizione, prompt pulito, se serve ricerca web e suggerimenti.
function extractVoiceBriefing(text: string): {
transcript: string;
cleanPrompt: string;
needsSearch: boolean;
searchHints: string[];
} {
const result = { transcript: "", 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.trascrizione_corretta === "string") result.transcript = obj.trascrizione_corretta.trim();
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>";
// ---------------------------------------------------------------------------
// Task immagine unificato: prima il protocollo diretto Antigravity (nessun
// subprocess, ~7-25s), con fallback automatico al percorso agy CLI se il
// modello non è disponibile o la risposta non contiene immagini.
// ---------------------------------------------------------------------------
const AGY_IMAGE_OUT_DIR = path.join(AGY_CHAT_DIR, "generated_images");
function antgSaveImages(images: AntgInlineImage[], outputDir: string | undefined, prefix: string): string[] {
const dir = outputDir ?? AGY_IMAGE_OUT_DIR;
const saved: string[] = [];
try {
fs.mkdirSync(dir, { recursive: true });
} catch {
return saved;
}
const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
images.forEach((img, i) => {
const ext = img.mimeType.includes("jpeg") ? "jpg" : img.mimeType.includes("webp") ? "webp" : "png";
const file = path.join(dir, `${prefix}_${stamp}_${i}.${ext}`);
try {
fs.writeFileSync(file, Buffer.from(img.data, "base64"));
saved.push(file);
} catch {
/* ignora */
}
});
return saved;
}
async function agyImageTask(opts: {
prompt: string; // prompt in stile CLI (può contenere i percorsi + IMG_SUFFIX)
images?: string[];
aspectRatio?: string;
imageSize?: string;
model?: string;
outputDir?: string;
signal?: AbortSignal;
label: string;
}): Promise<{
text: string;
imagePaths: string[];
engine: "direct" | "cli";
usage?: any;
exitCode?: number;
directError?: string;
}> {
const imageModel = opts.model && /image/i.test(opts.model) ? opts.model : ANTG_IMAGE_MODEL_DEFAULT;
let directError: string | undefined;
const quotaBlocked = Date.now() < antgImageBlockedUntilMs;
try {
if (quotaBlocked) {
const mins = Math.ceil((antgImageBlockedUntilMs - Date.now()) / 60000);
throw new Error(`quota API immagine esaurita (reset stimato tra ~${mins} min): ${antgImageBlockedReason}`);
}
// Prompt per il modello: niente IMG_SUFFIX, percorsi → riferimenti allegati,
// più un vincolo esplicito di composizione (migliora affidabilità su input).
let prompt = opts.prompt.split(IMG_SUFFIX).join("").trim();
for (const file of opts.images ?? []) prompt = prompt.split(file).join("(immagine allegata)");
if (opts.images?.length) {
prompt += "\nMantieni esattamente la composizione e l'aspect ratio dell'immagine di input; non ritagliare e non aggiungere testo.";
}
const res = await antgGenerateImages({
prompt,
images: opts.images,
aspectRatio: opts.aspectRatio,
imageSize: opts.imageSize,
model: imageModel,
signal: opts.signal,
});
if (!res.images.length) {
throw new Error(
res.finishMessage
? `il modello non ha prodotto immagini: ${res.finishMessage.slice(0, 200)}`
: `nessuna immagine nella risposta (finishReason=${res.finishReason ?? "?"})`,
);
}
const imagePaths = antgSaveImages(res.images, opts.outputDir, opts.label.replace(/[^a-z0-9]+/gi, "_"));
const outTok = res.usage?.candidatesTokenCount;
const text =
(res.text ? `${res.text}\n\n` : "") +
`✅ ${opts.label}: ${imagePaths.length} immagine/i via API diretta Antigravity (${res.model}${outTok ? `, ${outTok} token output` : ""}).` +
(imagePaths.length ? `\n${imagePaths.map((f) => `- ${f}`).join("\n")}` : "");
return { text, imagePaths, engine: "direct", usage: res.usage };
} catch (e: any) {
directError = e?.message ?? String(e);
}
// Fallback: percorso CLI agy (comportamento precedente all'integrazione diretta)
const res = await executeAgy({
prompt: opts.prompt,
mode: "image",
model: opts.model,
stateless: true,
filePaths: opts.images,
outputDir: opts.outputDir,
yolo: true,
signal: opts.signal,
});
// Il path deve esistere davvero: se il CLI fallisce (es. quota) l'output può
// contenere il placeholder del prompt, che non è un'immagine.
const cliPath = res.imagePath && fs.existsSync(res.imagePath) ? res.imagePath : undefined;
const note = cliPath
? `\n\n️ Fallback CLI agy (percorso diretto non disponibile): ${directError ?? "motivo non specificato"}`
: `\n\n⚠️ Nessuna immagine prodotta. API diretta: ${directError ?? "errore non specificato"}${
res.exitCode ? ` | CLI agy exitCode ${res.exitCode}` : ""
}`;
return {
text: res.text + note,
imagePaths: cliPath ? [cliPath] : [],
engine: "cli",
exitCode: res.exitCode,
directError,
};
}
// ---------------------------------------------------------------------------
// 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} (Ctrl+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
}
}
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,
// Spazio testo letterale nell'editor senza inviare, F12 registra di nuovo).
// =========================================================================
interface VoicePlanDecision {
action: "send" | "cancel" | "record" | "literal";
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 • Spazio testo letterale • 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;
}
// Spazio: chiude l'overlay e inserisce la TRASCRIZIONE LETTERALE
// nell'editor, senza inviare il prompt proposto.
if (matchesKey(data, Key.space)) {
done({ action: "literal", text });
return;
}
if (matchesKey(data, Key.f12)) {
done({ action: "record", text });
return;
}
},
};
},
{
overlay: true,
overlayOptions: { width: "60%", minWidth: 60, anchor: "center" },
},
);
}
// ---------------------------------------------------------------------------
// Helper: onUpdate nel formato corretto (oggetto con content, non stringa)
// ---------------------------------------------------------------------------
function toolUpdate(onUpdate: any, text: string) {
onUpdate?.({ content: [{ type: "text", text }] });
}
interface AudioLocalMetadata {
fileName: string;
durationSeconds: number | null;
codec: string | null;
container: string | null;
sampleRateHz: number | null;
channels: number | null;
bitDepth: number | null;
peakDbfs: number | null;
meanDbfs: number | null;
silenceIntervals: Array<{ startSeconds: number; endSeconds?: number }>;
}
function audioMimeType(file: string): string {
const ext = path.extname(file).toLowerCase();
return {
".wav": "audio/wav",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".flac": "audio/flac",
".ogg": "audio/ogg",
".opus": "audio/opus",
".webm": "audio/webm",
}[ext] ?? "application/octet-stream";
}
async function inspectAudioLocally(file: string): Promise<AudioLocalMetadata> {
const probe = await execFileAsync(
"ffprobe",
[
"-v", "error",
"-show_entries",
"format=format_name,duration:stream=codec_name,codec_type,sample_rate,channels,bits_per_sample",
"-of", "json",
file,
],
{ timeout: 30_000, maxBuffer: 1_000_000 },
);
const parsed = JSON.parse(probe.stdout || "{}");
const stream = (parsed.streams ?? []).find((item: any) => item.codec_type === "audio") ?? parsed.streams?.[0] ?? {};
const numberOrNull = (value: unknown): number | null => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
let peakDbfs: number | null = null;
let meanDbfs: number | null = null;
let silenceIntervals: Array<{ startSeconds: number; endSeconds?: number }> = [];
try {
const measured = await execFileAsync(
"ffmpeg",
["-hide_banner", "-i", file, "-af", "volumedetect,silencedetect=noise=-50dB:d=0.02", "-f", "null", "-"],
{ timeout: 60_000, maxBuffer: 2_000_000 },
);
const stderr = measured.stderr || "";
const peakMatch = stderr.match(/max_volume:\s*(-?[0-9.]+|-inf)\s*dB/);
const meanMatch = stderr.match(/mean_volume:\s*(-?[0-9.]+|-inf)\s*dB/);
if (peakMatch && peakMatch[1] !== "-inf") peakDbfs = Number(peakMatch[1]);
if (meanMatch && meanMatch[1] !== "-inf") meanDbfs = Number(meanMatch[1]);
const starts = [...stderr.matchAll(/silence_start:\s*([0-9.]+)/g)].map((match) => Number(match[1]));
const ends = [...stderr.matchAll(/silence_end:\s*([0-9.]+)/g)].map((match) => Number(match[1]));
silenceIntervals = starts.map((start, index) => ({ startSeconds: start, endSeconds: ends[index] }));
} catch {
// ffprobe è la misura minima; l'analisi Gemini può comunque procedere.
}
return {
fileName: path.basename(file),
durationSeconds: numberOrNull(parsed.format?.duration),
codec: stream.codec_name ?? null,
container: parsed.format?.format_name ?? null,
sampleRateHz: numberOrNull(stream.sample_rate),
channels: numberOrNull(stream.channels),
bitDepth: numberOrNull(stream.bits_per_sample),
peakDbfs,
meanDbfs,
silenceIntervals,
};
}
async function analyzeAudioWithAntigravity(
file: string,
question: string | undefined,
modelName: string | undefined,
outputDir: string | undefined,
signal?: AbortSignal,
): Promise<{ text: string; metadata: AudioLocalMetadata; waveformPath?: string; reportPath?: string; model: string }> {
const requestedModel = modelName || getConfig("voiceModel") || ANTG_DEFAULT_MODEL;
if (!requestedModel.startsWith("gemini-")) {
throw new Error("L'analisi audio multimodale richiede un modello Gemini Antigravity, non un modello testuale alternativo.");
}
if (signal?.aborted) throw new Error("Analisi audio annullata");
const size = fs.statSync(file).size;
if (size > AUDIO_MAX_BYTES) throw new Error(`File audio troppo grande: ${size} byte (limite ${AUDIO_MAX_BYTES})`);
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "agy-audio-analysis-"));
try {
const metadata = await inspectAudioLocally(file);
const waveform = path.join(workDir, "waveform.png");
await execFileAsync(
"ffmpeg",
[
"-hide_banner", "-loglevel", "error", "-y", "-i", file,
"-filter_complex", "showwavespic=s=1600x420:split_channels=0:colors=0x4f7cff",
"-frames:v", "1", waveform,
],
{ timeout: 60_000, maxBuffer: 1_000_000 },
);
if (signal?.aborted) throw new Error("Analisi audio annullata");
const instructions =
"Analizza ascoltando il file audio e osservando anche la waveform PNG allegata. " +
"Rispondi in italiano con una descrizione percettiva prudente, timbro/materiale probabile, " +
"attacco, transienti, corpo, decadimento, silenzi, clipping e artefatti. " +
"Se l'utente chiede una valutazione, separa ciò che osservi dai metadati locali da ciò che inferisci dall'ascolto. " +
"Non inventare parlato o dettagli non udibili. " +
(question ? `Richiesta specifica: ${question}\n\n` : "") +
`Metadati misurati localmente:\n${JSON.stringify(metadata, null, 2)}`;
const response = await antgGenerate({
parts: [
{ text: instructions },
{ inlineData: { mimeType: audioMimeType(file), data: fs.readFileSync(file).toString("base64") } },
{ inlineData: { mimeType: "image/png", data: fs.readFileSync(waveform).toString("base64") } },
],
model: requestedModel,
maxOutputTokens: 5000,
temperature: 0.2,
thinking: "low",
stream: true,
});
let waveformPath: string | undefined;
let reportPath: string | undefined;
if (outputDir) {
const destination = path.resolve(outputDir);
fs.mkdirSync(destination, { recursive: true });
const stem = path.basename(file, path.extname(file)).replace(/[^a-zA-Z0-9._-]+/g, "_");
waveformPath = path.join(destination, `${stem}.waveform.png`);
reportPath = path.join(destination, `${stem}.analysis.json`);
fs.copyFileSync(waveform, waveformPath);
fs.writeFileSync(reportPath, JSON.stringify({ filePath: file, model: requestedModel, metadata, analysis: response.text }, null, 2));
}
return { text: response.text, metadata, waveformPath, reportPath, model: requestedModel };
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignora */ }
}
}
// Trascrizione audio esclusivamente tramite il gateway Antigravity OAuth.
// La CLI agy non accetta file audio, ma cloudcode-pa supporta inlineData
// audio con i modelli Gemini multimodali dell'account Antigravity.
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 && parseFloat(maxMatch[1]) < -35) return false;
if (meanMatch && parseFloat(meanMatch[1]) < -45) return false;
return true;
} catch { return true; }
}
interface TranscriptResult { text: string; error?: string; }
async function transcribeWithAntigravity(file: string, language?: string): Promise<TranscriptResult> {
if (!audioHasSpeech(file)) return { text: "", error: "Nessun parlato rilevato nell'audio (silenzio o volume troppo basso)" };
let audioFile = file, 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 (error: any) { return { text: "", error: `Conversione Opus fallita: ${error.message}` }; }
}
try {
const response = await antgGenerate({
parts: [
{ text: `Trascrivi fedelmente il contenuto di questo audio${language ? ` in ${language}` : " in italiano"}. Restituisci SOLO la trascrizione testuale.` },
{ inlineData: { mimeType, data: fs.readFileSync(audioFile).toString("base64") } },
],
model: getConfig("voiceModel") || "gemini-3.7-flash-medium", maxOutputTokens: 3000, thinking: "low", stream: true,
});
const text = response.text.trim();
return text ? { text } : { text: "", error: "Antigravity ha restituito una trascrizione vuota" };
} catch (error: any) { return { text: "", error: `Trascrizione Antigravity fallita: ${error?.message ?? String(error)}` }; }
}
// =========================================================================
// Client diretto Antigravity — protocollo v1internal (cloudcode-pa)
// Parla direttamente con i server di inferenza di Antigravity usando il
// token OAuth dell'account (nessun subprocess agy). Endpoint, envelope e
// flusso OAuth sono stati reverse-engineered e documentati pubblicamente
// (opencode-antigravity-auth, antigravity-proxy, torana-edge).
//
// USO CONSERVATIVO: quota per-modello (retrieveUserQuota/fetchAvailableModels),
// richieste serializzate (una alla volta), retry limitati (1 refresh + 1 retry
// su 401/403). L'uso automatizzato massiccio fa scattare il re-auth di Google
// e può portare a ban dell'account — vedi ToS Antigravity/Gemini.
// =========================================================================
const ANTG_TOKEN_FILE = path.join(os.homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
const ANTG_OAUTH_URL = "https://oauth2.googleapis.com/token";
const ANTG_HOSTS = ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"];
const ANTG_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const ANTG_CLIENT_SECRET = "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";
const ANTG_UA = "antigravity/cli/1.1.17";
const ANTG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1";
const ANTG_METADATA = JSON.stringify({ ideType: "ANTIGRAVITY", platform: "LINUX", pluginType: "GEMINI" });
interface AntgModelInfo {
backend: string;
thinkingLevel?: "low" | "medium" | "high";
}
const ANTG_MODEL_MAP: Record<string, AntgModelInfo> = {
"gemini-3.7-flash": { backend: "gemini-3.7-flash-medium" },
"gemini-3.7-flash-medium": { backend: "gemini-3.7-flash-medium" },
"gemini-3.7-flash-low": { backend: "gemini-3.7-flash-low" },
"gemini-3.7-flash-high": { backend: "gemini-3.7-flash-high" },
"gemini-3.5-flash": { backend: "gemini-3.5-flash-low" },
"gemini-3.6-flash-medium": { backend: "gemini-3.6-flash-medium" },
"gemini-3.6-flash-high": { backend: "gemini-3.6-flash-high" },
"gemini-3.1-pro": { backend: "gemini-3.1-pro-low", thinkingLevel: "low" },
"gemini-3.1-pro-high": { backend: "gemini-3.1-pro-high", thinkingLevel: "high" },
"claude-sonnet-4.6": { backend: "claude-sonnet-4-6" },
"claude-opus-4.6": { backend: "claude-opus-4-6-thinking" },
"gpt-oss-120b": { backend: "gpt-oss-120b-medium" },
};
const ANTG_DEFAULT_MODEL = "gemini-3.7-flash-medium";
let antgToken: { access: string; expiresAtMs: number; refresh: string } | null = null;
let antgProject: { pid: string; base: string } | null = null;
let antgQueue: Promise<unknown> = Promise.resolve(); // serializzazione richieste
function antgParseExpiryMs(s: string | undefined): number {
if (!s) return 0;
if (typeof s === "number") return s * 1000;
// RFC3339 con nanosecondi: tronca la frazione ai ms per Date.parse
const m = String(s).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/);
if (!m) return 0;
const ms = Date.parse(m[1] + (m[2] ? m[2].slice(0, 4) : "") + (m[3] || "Z"));
return Number.isNaN(ms) ? 0 : ms;
}
function antgReadTokenFile(): any {
try {
return JSON.parse(fs.readFileSync(ANTG_TOKEN_FILE, "utf8"));
} catch {
return null;
}
}
function antgWriteTokenFile(data: any) {
try {
const tmp = ANTG_TOKEN_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tmp, ANTG_TOKEN_FILE);
} catch {
/* ignora */
}
}
async function antgRefreshToken(refreshToken: string): Promise<{ access: string; expiresAtMs: number; refresh: string }> {
const resp = await fetch(ANTG_OAUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: ANTG_CLIENT_ID,
client_secret: ANTG_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
signal: AbortSignal.timeout(30_000),
});
const payload: any = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(`OAuth refresh fallito (HTTP ${resp.status}): ${JSON.stringify(payload).slice(0, 200)}`);
const expiresIn = Number(payload.expires_in ?? 3600);
return {
access: payload.access_token,
expiresAtMs: Date.now() + expiresIn * 1000,
refresh: payload.refresh_token || refreshToken,
};
}
async function antgGetAccessToken(): Promise<string> {
if (antgToken && antgToken.expiresAtMs > Date.now() + 120_000) return antgToken.access;
const data = antgReadTokenFile();
if (!data?.token?.refresh_token) {
throw new Error("Nessun token OAuth Antigravity: avvia `agy` almeno una volta per autenticarti con l'account.");
}
const tok = data.token;
const expMs = antgParseExpiryMs(tok.expiry);
if (tok.access_token && expMs > Date.now() + 120_000) {
antgToken = { access: tok.access_token, expiresAtMs: expMs, refresh: tok.refresh_token };
return tok.access_token;
}
const fresh = await antgRefreshToken(tok.refresh_token);
data.token = {
...tok,
access_token: fresh.access,
token_type: "Bearer",
expiry: new Date(fresh.expiresAtMs).toISOString().replace(/\.\d{3}Z$/, ".000000Z"),
};
antgWriteTokenFile(data);
antgToken = fresh;
return fresh.access;
}
function antgDeepFind(obj: any, key: string): any {
if (obj && typeof obj === "object") {
if (key in obj) return obj[key];
for (const v of Object.values(obj)) {
const r = antgDeepFind(v, key);
if (r != null) return r;
}
}
return undefined;
}
function antgHeaders(token: string, stream = false): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": ANTG_UA,
"X-Goog-Api-Client": ANTG_API_CLIENT,
"Client-Metadata": ANTG_METADATA,
...(stream ? { Accept: "text/event-stream" } : {}),
};
}
async function antgLoadCodeAssist(token: string, base: string): Promise<string> {
const resp = await fetch(`${base}/v1internal:loadCodeAssist`, {
method: "POST",
headers: antgHeaders(token),
body: "{}",
signal: AbortSignal.timeout(60_000),
});
const payload: any = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(`loadCodeAssist HTTP ${resp.status}`);
const pid = antgDeepFind(payload, "cloudaicompanionProject") ?? antgDeepFind(payload, "cloudaicompanion_project");
if (!pid) throw new Error("loadCodeAssist senza project id");
return String(pid);
}
// ---------------------------------------------------------------------------
// Generazione immagini via protocollo diretto Antigravity (stesso token OAuth
// del canale account). Validato con probe il 13/09/2026:
// POST v1internal:generateContent (NON streaming), requestType "image_gen",
// responseModalities ["IMAGE"], imageConfig {aspectRatio, imageSize};
// immagine in candidates[0].content.parts[].inlineData (base64, image/jpeg).
// I parts con thought=true sono immagini intermedie di ragionamento: ignorarli.
// HTTP 200 può non contenere immagini: in tal caso candidate.finishMessage
// spiega il rifiuto (nessun addebito) → si ricade sul percorso CLI.
// gemini-3-pro-image NON è disponibile su questo account (HTTP 404).
// ---------------------------------------------------------------------------
const ANTG_IMAGE_MODEL_DEFAULT = "gemini-3.1-flash-image"; // Nano Banana 2
const ANTG_IMAGE_MAX_OUTPUT_TOKENS = 32768; // 4K richiede ~2.9K token di output
// Quota immagine: l'API diretta risponde 429 RESOURCE_EXHAUSTED con
// "quota will reset after XhYmZs" quando la capacità del modello è satura.
// Finché il blocco è attivo si va diretti al percorso CLI, evitando round-trip
// inutili.
let antgImageBlockedUntilMs = 0;
let antgImageBlockedReason = "";
function antgParseQuotaDelayMs(message: string): number {
const m = message.match(/reset after\s*(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/i);
if (!m) return 0;
const h = Number(m[1] ?? 0);
const min = Number(m[2] ?? 0);
const sec = Number(m[3] ?? 0);
return ((h * 60 + min) * 60 + sec) * 1000;
}
interface AntgInlineImage {
mimeType: string;
data: string;
}
interface AntgImageCallResult {
images: AntgInlineImage[];
text: string;
finishReason?: string;
finishMessage?: string;
usage?: any;
model: string;
}
function antgMimeFromPath(file: string): string {
switch (path.extname(file).toLowerCase()) {
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".jpg":
case ".jpeg":
return "image/jpeg";
default:
return "image/png";
}
}
async function antgGenerateImages(opts: {
prompt: string;
images?: string[];
aspectRatio?: string;
imageSize?: string;
model?: string;
signal?: AbortSignal;
}): Promise<AntgImageCallResult> {
const model = opts.model ?? ANTG_IMAGE_MODEL_DEFAULT;
const { pid, base } = await antgGetProject();
const parts: any[] = [];
for (const file of opts.images ?? []) {
try {
parts.push({
inlineData: { mimeType: antgMimeFromPath(file), data: fs.readFileSync(file).toString("base64") },
});
} catch (e: any) {
throw new Error(`Impossibile leggere l'immagine ${file}: ${e.message}`);
}
}
parts.push({ text: opts.prompt });
const generationConfig: any = {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: ANTG_IMAGE_MAX_OUTPUT_TOKENS,
responseModalities: ["IMAGE"], // 1 sola immagine (TEXT+IMAGE può duplicarla)
};
const imageConfig: any = {};
if (opts.aspectRatio) imageConfig.aspectRatio = opts.aspectRatio;
if (opts.imageSize) imageConfig.imageSize = opts.imageSize;
if (Object.keys(imageConfig).length) generationConfig.imageConfig = imageConfig;
const envelope = {
project: pid,
model,
requestType: "image_gen",
userAgent: "antigravity",
requestId: `agent-${crypto.randomUUID().replace(/-/g, "")}`,
request: { contents: [{ role: "user", parts }], generationConfig },
};
const call = (tok: string) =>
fetch(`${base}/v1internal:generateContent`, {
method: "POST",
headers: antgHeaders(tok),
body: JSON.stringify(envelope),
signal: antgAbortSignal(opts.signal, 300_000),
});
let resp = await call(await antgGetAccessToken());
if (resp.status === 401 || resp.status === 403) {
antgToken = null;
resp = await call(await antgGetAccessToken());
}
const raw = await resp.text();
if (!resp.ok) {
if (resp.status === 429 || /QUOTA_EXHAUSTED|RESOURCE_EXHAUSTED/.test(raw)) {
const delay = antgParseQuotaDelayMs(raw);
antgImageBlockedUntilMs = Date.now() + (delay || 30 * 60 * 1000);
antgImageBlockedReason = raw.replace(/\s+/g, " ").slice(0, 200);
}
throw new Error(`Antigravity image HTTP ${resp.status}: ${raw.slice(0, 300)}`);
}
let payload: any;
try {
payload = JSON.parse(raw);
} catch {
throw new Error(`Risposta immagine non JSON: ${raw.slice(0, 200)}`);
}
const gem = payload?.response ?? payload;
const candidate = gem?.candidates?.[0];
const responseParts: any[] = candidate?.content?.parts ?? [];
const images: AntgInlineImage[] = [];
const texts: string[] = [];
for (const part of responseParts) {
const inline = part?.inlineData ?? part?.inline_data;
if (inline?.data) {
if (part.thought === true) continue; // immagine intermedia di ragionamento
images.push({ mimeType: inline.mimeType ?? inline.mime_type ?? "image/png", data: inline.data });
} else if (typeof part?.text === "string" && part.thought !== true) {
texts.push(part.text);
}
}
return {
images,
text: texts.join("\n").trim(),
finishReason: candidate?.finishReason,
finishMessage: candidate?.finishMessage,
usage: gem?.usageMetadata,
model,
};
}
async function antgGetProject(): Promise<{ pid: string; base: string }> {
if (antgProject) return antgProject;
const token = await antgGetAccessToken();
let lastErr = "";
for (const base of ANTG_HOSTS) {
try {
antgProject = { pid: await antgLoadCodeAssist(token, base), base };
return antgProject;
} catch (e: any) {
lastErr = e.message;
}
}
throw new Error(`Discovery project fallito: ${lastErr}`);
}
interface AntgGenerateOpts {
prompt?: string;
parts?: any[];
model?: string;
system?: string;
maxOutputTokens?: number;
temperature?: number;
thinking?: "low" | "medium" | "high" | "off";
stream?: boolean;
}
function antgResolveModel(friendly: string | undefined): AntgModelInfo {
if (friendly && ANTG_MODEL_MAP[friendly]) return ANTG_MODEL_MAP[friendly];
if (friendly) return { backend: friendly };
return ANTG_MODEL_MAP[ANTG_DEFAULT_MODEL]!;
}
async function antgReadStream(resp: any): Promise<{ text: string; details: any }> {
const reader = resp.body?.getReader();
if (!reader) throw new Error("Nessun body streaming");
const decoder = new TextDecoder();
let buf = "", text = "", finish = "", usage: any, modelVersion = "", responseId = "", events = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// il framing SSE usa CRLF: normalizza a \n
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i);
buf = buf.slice(i + 2);
for (const line of chunk.split("\n")) {
if (!line.startsWith("data:")) continue;
const d = line.slice(5).trim();
if (!d) continue;
events++;
try {
const o = JSON.parse(d);
const inner = o.response ?? o;
for (const pt of inner.candidates?.[0]?.content?.parts ?? []) if (pt.text) text += pt.text;
if (inner.candidates?.[0]?.finishReason) finish = inner.candidates[0].finishReason;
if (inner.usageMetadata) usage = inner.usageMetadata;
if (inner.modelVersion) modelVersion = inner.modelVersion;
if (inner.responseId) responseId = inner.responseId;
} catch {
/* evento non-JSON: ignora */
}
}
}
}
return {
text: text.trim(),
details: { finishReason: finish || undefined, usage, modelVersion, responseId, events },
};
}
async function antgGenerate(opts: AntgGenerateOpts): Promise<{ text: string; details: any }> {
// serializza: una richiesta alla volta (uso conservativo del canale account)
const run = antgQueue.then(async () => {
const { pid, base } = await antgGetProject();
const mi = antgResolveModel(opts.model);
const requestId = `agent-${crypto.randomUUID().replace(/-/g, "")}`;
const gc: any = {
maxOutputTokens: opts.maxOutputTokens ?? 8192,
temperature: opts.temperature ?? 0.4,
};
if (mi.thinkingLevel) gc.thinkingConfig = { thinkingLevel: mi.thinkingLevel };
if (opts.thinking && opts.thinking !== "off") gc.thinkingConfig = { thinkingLevel: opts.thinking };
const userParts = opts.parts ?? (opts.prompt ? [{ text: opts.prompt }] : []);
const request: any = {
contents: [{ role: "user", parts: userParts }],
generationConfig: gc,
};
if (opts.system) request.systemInstruction = { parts: [{ text: opts.system }] };
const envelope = {
project: pid,
model: mi.backend,
request,
requestType: "agent",
userAgent: "antigravity",
requestId,
};
const doCall = async (token: string) => {
if (opts.stream !== false) {
const resp = await fetch(`${base}/v1internal:streamGenerateContent?alt=sse`, {
method: "POST",
headers: antgHeaders(token, true),
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`streamGenerateContent HTTP ${resp.status}`);
return await antgReadStream(resp);
}
const resp = await fetch(`${base}/v1internal:generateContent`, {
method: "POST",
headers: antgHeaders(token),
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(180_000),
});
const raw = await resp.text();
if (!resp.ok) throw new Error(`generateContent HTTP ${resp.status}: ${raw.slice(0, 200)}`);
const p = JSON.parse(raw);
const inner = p.response ?? p;
return {
text: (inner.candidates?.[0]?.content?.parts ?? []).map((x: any) => x.text ?? "").join("").trim(),
details: {
finishReason: inner.candidates?.[0]?.finishReason,
usage: inner.usageMetadata,
modelVersion: inner.modelVersion,
responseId: inner.responseId,
},
};
};
try {
return await doCall(await antgGetAccessToken());
} catch (e: any) {
// 401/403 → un solo refresh + retry; niente loop
if (/401|403|UNAUTHENTICATED/.test(String(e.message))) {
antgToken = null;
return await doCall(await antgGetAccessToken());
}
throw e;
}
});
antgQueue = run.catch(() => undefined);
return run as Promise<{ text: string; details: any }>;
}
// ---------------------------------------------------------------------------
// Pipeline Multimodale Diretta (Gemini): invio diretto dell'audio senza STT
// ---------------------------------------------------------------------------
interface AudioInterpretationResult {
transcript: string;
cleanPrompt: string;
needsSearch: boolean;
searchHints: string[];
rawText: string;
}
async function interpretAudioWithAntigravity(
audioFile: string,
editorText: string,
context: string,
targetModel?: string,
): Promise<AudioInterpretationResult> {
if (!audioHasSpeech(audioFile)) {
throw new Error("Nessun parlato rilevato nell'audio (silenzio o volume troppo basso)");
}
let sendFile = audioFile;
let mimeType = "audio/wav";
if (audioFile.toLowerCase().endsWith(".wav")) {
const ogg = audioFile.replace(/\.wav$/i, ".ogg");
try {
await execFileAsync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", audioFile, "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "16k", ogg],
{ timeout: 60_000 },
);
sendFile = ogg;
mimeType = "audio/ogg";
} catch {
/* fallback su wav */
}
}
const b64 = fs.readFileSync(sendFile).toString("base64");
const promptInstructions =
`[CONTESTO INTERNO — COMUNICAZIONE TRA AGENTI]\n` +
`Sei un analista tecnico/middleware per un agente AI orchestratore. Ascolta la traccia audio allegata (e l'eventuale testo dell'editor) e produci un briefing strutturato per l'orchestratore. NON rispondere all'utente: il tuo output sarà letto SOLO dall'orchestratore, che poi eseguirà le azioni.` +
(editorText ? `\n\nTesto scritto dall'utente nel campo di input (da combinare con la voce):\n${editorText}` : "") +
`\n\nContesto della conversazione precedente:\n${context || "(nessuno)"}` +
`\n\nRestituisci UN SOLO oggetto JSON (nessun markdown aggiuntivo tranne il blocco json) con questi campi esatti:` +
`\n{"trascrizione_corretta":"...","intent_analisi":"...","note_per_agent":"...","azioni_raccomandate":["..."],"prompt_utente_pulito":"...","ricerca_necessaria":true/false,"suggerimenti_ricerca":["..."]}` +
`\nRegole:` +
`\n- trascrizione_corretta: la trascrizione fedele e completa delle parole pronunciate nell'audio in italiano.` +
`\n- prompt_utente_pulito: la richiesta rielaborata e pulita che l'orchestratore userà come prompt effettivo.` +
`\n- NON eseguire alcuno strumento o azione; solo analisi, trascrizione e briefing in JSON.` +
`\n- ricerca_necessaria=true se serve verificare best practices, documentazione o librerie aggiornate.` +
`\n- suggerimenti_ricerca: termini chiave per la ricerca web se necessaria.`;
const parts = [
{ text: promptInstructions },
{ inlineData: { mimeType, data: b64 } },
];
const model = targetModel && targetModel.startsWith("gemini-") ? targetModel : "gemini-3.7-flash-medium";
try {
const res = await antgGenerate({
parts,
model,
maxOutputTokens: 3000,
thinking: "low",
stream: true,
});
const rawText = res.text.trim();
const briefing = extractVoiceBriefing(rawText);
return {
transcript: briefing.transcript || briefing.cleanPrompt || rawText,
cleanPrompt: briefing.cleanPrompt || rawText,
needsSearch: briefing.needsSearch,
searchHints: briefing.searchHints,
rawText,
};
} catch (err: any) {
throw new Error(`Interpretazione audio Antigravity fallita: ${err?.message ?? String(err)}`);
}
}
// =========================================================================
// Provider "antigravity" — modelli del gateway come provider pi nativo
// Registrato con pi.registerProvider() + streamSimple: appare nel selettore
// modelli (e in `pi --list-models`). Riusa il client v1internal qui sopra.
// NOTA: canale account — quota per-modello e rischio ToS/ban reale se usato
// come default con traffico continuo. Preferire per uso selettivo.
// =========================================================================
interface AntgProviderModelDef {
id: string;
name: string;
reasoning: boolean;
images: boolean;
thinkingLevelMap?: ThinkingLevelMap;
contextWindow: number;
maxTokens: number;
}
const ANTG_THINK_MAP: ThinkingLevelMap = {
off: null,
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high",
max: "high",
};
const ANTG_PROVIDER_MODELS: AntgProviderModelDef[] = [
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)", reasoning: true, images: true, thinkingLevelMap: { off: null }, contextWindow: 1000000, maxTokens: 64000 },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)", reasoning: true, images: true, thinkingLevelMap: { off: null }, contextWindow: 1000000, maxTokens: 64000 },
{ id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)", reasoning: false, images: false, contextWindow: 262144, maxTokens: 32768 },
];
// ---------------------------------------------------------------------------
// Cache persistente dei modelli (~/.config/agy-pi/models-cache.json)
// Il catalogo vivo scaricato da /agy:refresh-models viene salvato qui, così la
// lista sopravvive al riavvio dell'estensione. Se il file è assente, illeggibile
// o corrotto si riparte da ANTG_PROVIDER_MODELS senza errori fatali.
// ---------------------------------------------------------------------------
const MODELS_CACHE_FILE = path.join(CONFIG_DIR, "models-cache.json");
const MODELS_CACHE_VERSION = 1;
interface AntgModelsCache {
version: number;
refreshedAt: string;
project?: string;
models: AntgProviderModelDef[];
}
function isAntgModelDef(value: any): value is AntgProviderModelDef {
return (
value !== null &&
typeof value === "object" &&
typeof value.id === "string" &&
value.id.trim() !== "" &&
typeof value.contextWindow === "number" &&
value.contextWindow > 0 &&
typeof value.maxTokens === "number" &&
value.maxTokens > 0
);
}
function loadModelsCache(): AntgModelsCache | null {
try {
const raw = JSON.parse(fs.readFileSync(MODELS_CACHE_FILE, "utf8"));
if (raw?.version !== MODELS_CACHE_VERSION) return null;
if (!Array.isArray(raw.models) || raw.models.length === 0) return null;
if (!raw.models.every(isAntgModelDef)) return null;
return raw as AntgModelsCache;
} catch {
return null; // assente, illeggibile o corrotto → default
}
}
function saveModelsCache(models: AntgProviderModelDef[], projectId?: string) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const tmp = `${MODELS_CACHE_FILE}.tmp`;
fs.writeFileSync(
tmp,
JSON.stringify(
{
version: MODELS_CACHE_VERSION,
refreshedAt: new Date().toISOString(),
project: projectId,
models,
},
null,
2,
),
{ mode: 0o600 },
);
fs.renameSync(tmp, MODELS_CACHE_FILE); // scrittura atomica
} catch {
/* ignora */
}
}
// Modelli attualmente registrati nel provider: cache persistente se presente,
// altrimenti i default statici. Aggiornabili con /agy:refresh-models (che ora
// salva su disco) e ripristinabili con /agy:models-reset.
let antgRegisteredModels: AntgProviderModelDef[] = loadModelsCache()?.models ?? ANTG_PROVIDER_MODELS;
// Configurazione del provider "antigravity" (riusata all'avvio e al refresh).
function antgProviderConfig(models: AntgProviderModelDef[]) {
return {
name: "Antigravity (account)",
baseUrl: ANTG_HOSTS[0],
apiKey: "antigravity",
api: "antigravity",
models: models.map((m) => {
const input: ("text" | "image")[] = m.images ? ["text", "image"] : ["text"];
return {
id: m.id,
name: m.name,
reasoning: m.reasoning,
thinkingLevelMap: m.thinkingLevelMap,
input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
};
}),
streamSimple: streamAntigravity,
};
}
// Scarica il catalogo modelli vivo dall'account e lo filtra: niente modelli
// interni (displayName vuoto, chat_*, tab_*), niente placeholder, niente
// gemini-2.5-* (ritirati → HTTP 429). Restituisce la lista per il provider.
async function antgFetchCatalog(token: string, base: string): Promise<AntgProviderModelDef[]> {
const resp = await fetch(`${base}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: antgHeaders(token),
body: "{}",
signal: AbortSignal.timeout(60_000),
});
const raw = await resp.text();
if (!resp.ok) throw new Error(`fetchAvailableModels HTTP ${resp.status}: ${raw.slice(0, 200)}`);
const payload = JSON.parse(raw);
const catalog: Record<string, any> = payload.models ?? {};
const out: AntgProviderModelDef[] = [];
for (const [backendId, info] of Object.entries(catalog)) {
const m = info as any;
const name = m.displayName;
if (!name || typeof name !== "string" || !name.trim()) continue; // interni/autocomplete
if (m.isInternal) continue;
if (backendId.startsWith("chat_") || backendId.startsWith("tab_")) continue;
if (backendId.includes("MODEL_PLACEHOLDER")) continue;
if (backendId.startsWith("gemini-2.5-")) continue; // ritirati (429)
const ctx = Number(m.maxTokens);
if (!ctx) continue; // modelli senza contesto (es. gemini-3.1-flash-image, generazione immagini)
const reasoning = m.supportsThinking === true;
const images = m.supportsImages === true;
const thinkingLevelMap = reasoning ? (backendId.startsWith("gemini-") ? ANTG_THINK_MAP : { off: null }) : undefined;
out.push({
id: backendId,
name,
reasoning,
images,
thinkingLevelMap,
contextWindow: ctx,
maxTokens: Number(m.maxOutputTokens) || 65536,
});
}
out.sort((a, b) => a.id.localeCompare(b.id));
return out;
}
// Campi accettati dal gateway (validazione protobuf stretta — i campi ignoti
// tipo $defs/$ref/$schema causano HTTP 400 INVALID_ARGUMENT). Perplexity +
// riproduzione locale confermano: solo type/properties/required/items/enum/…
const ANTG_SCHEMA_ALLOWED = new Set(["type", "description", "properties", "required", "items", "enum", "format", "nullable", "minimum", "maximum"]);
function antgToGeminiParameters(tschema: any): any {
try {
const root = JSON.parse(JSON.stringify(tschema));
const visit = (node: any): any => {
if (Array.isArray(node)) return node.map(visit);
if (node === null || typeof node !== "object") return node;
const out: any = {};
for (const [k, v] of Object.entries(node)) {
if (!ANTG_SCHEMA_ALLOWED.has(k)) continue;
if (k === "properties" && v && typeof v === "object" && !Array.isArray(v)) {
out.properties = Object.fromEntries(Object.entries(v as any).map(([n, c]) => [n, visit(c)]));
} else if (k === "items") {
out.items = visit(v);
} else {
out[k] = visit(v);
}
}
return out;
};
return visit(root);
} catch {
return { type: "object", properties: {} };
}
}
function antgAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
if (!signal) return AbortSignal.timeout(timeoutMs);
const c = new AbortController();
const t = setTimeout(() => c.abort(), timeoutMs);
const onAbort = () => c.abort(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
c.signal.addEventListener("abort", () => {
clearTimeout(t);
signal.removeEventListener("abort", onAbort);
}, { once: true });
return c.signal;
}
function antgWithQueue<T>(fn: () => Promise<T>): Promise<T> {
const p = antgQueue.then(() => fn());
antgQueue = p.catch(() => undefined);
return p;
}
function isValidGeminiThoughtSignature(sig: any): sig is string {
if (typeof sig !== "string" || !sig.trim()) return false;
// I payload di reasoning di altri provider possono essere stringhe JSON come {"id":"rs_..."}
// o etichette come "reasoning_content" / "thought" che Google rifiuta con Base64 decoding failed.
if (sig.startsWith("{") || sig.startsWith("[") || sig === "reasoning_content" || sig === "thought" || sig.length < 32) {
return false;
}
if (!/^[A-Za-z0-9+/=_-]+$/.test(sig)) return false;
try {
const buf = Buffer.from(sig, "base64");
if (buf.length < 24) return false;
// Le firme crittografiche Gemini contengono byte binari (non solo testo ASCII leggibile)
const str = buf.toString("utf8");
if (/^[a-zA-Z0-9_ -]+$/.test(str)) return false;
return true;
} catch {
return false;
}
}
function antgBuildGeminiRequest(model: Model<Api>, context: Context, options?: SimpleStreamOptions): any {
const contents: any[] = [];
const push = (role: "user" | "model", part: any) => {
const last = contents[contents.length - 1];
if (last && last.role === role) last.parts.push(part);
else contents.push({ role, parts: [part] });
};
const signedToolCallIds = new Set<string>();
for (const msg of context.messages) {
if (msg.role === "user") {
const items = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content;
for (const b of items as any[]) {
if (b.type === "text") push("user", { text: b.text });
else if (b.type === "image") push("user", { inlineData: { mimeType: b.mimeType, data: b.data } });
}
} else if (msg.role === "assistant") {
const isSame = msg.provider === model.provider && msg.model === model.id;
const parts: any[] = [];
for (const b of msg.content) {
if (b.type === "text") {
const rawSig = isSame ? (b.textSignature || (b as any).thoughtSignature) : undefined;
const sig = isValidGeminiThoughtSignature(rawSig) ? rawSig : undefined;
if ((!b.text || !b.text.trim()) && !sig) continue;
parts.push({ text: b.text, ...(sig ? { thoughtSignature: sig } : {}) });
} else if (b.type === "thinking") {
const rawSig = isSame ? (b.thinkingSignature || (b as any).thoughtSignature) : undefined;
const sig = isValidGeminiThoughtSignature(rawSig) ? rawSig : undefined;
if ((!b.thinking || !b.thinking.trim()) && !sig) continue;
// Claude richiede una signature per ogni thinking block. I thought di un
// modello diverso non sono riutilizzabili: manteniamo il contesto come testo.
if (sig) parts.push({ thought: true, text: b.thinking, thoughtSignature: sig });
else parts.push({ text: `[Previous model reasoning]\n${b.thinking}` });
} else if (b.type === "toolCall") {
const rawSig = isSame ? (b.thoughtSignature || (b as any).thought_signature) : undefined;
const sig = isValidGeminiThoughtSignature(rawSig) ? rawSig : undefined;
if (sig) {
if (b.id) signedToolCallIds.add(b.id);
if (b.name) signedToolCallIds.add(b.name);
parts.push({
functionCall: { name: b.name, args: b.arguments ?? {} },
thoughtSignature: sig,
});
} else {
// Chiamata non firmata (cross-model o da provider terzo come DeepSeek): Google Gemini 2.5/3.x
// rigetta con HTTP 400 i functionCall privi di valida Base64 thought_signature.
// La serializziamo come testo per mantenere il contesto senza causare l'errore 400.
parts.push({
text: `[Tool Call: ${b.name}]\nArgs: ${JSON.stringify(b.arguments ?? {})}`,
});
}
}
}
for (const p of parts) push("model", p);
} else if (msg.role === "toolResult") {
const items = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content;
const text = items.map((c: any) => (c.type === "text" ? c.text : "")).join("\n");
const isSigned = (msg.toolCallId && signedToolCallIds.has(msg.toolCallId)) || signedToolCallIds.has(msg.toolName);
if (isSigned) {
push("user", { functionResponse: { name: msg.toolName, response: { result: text, isError: !!msg.isError } } });
} else {
push("user", { text: `[Tool Result: ${msg.toolName}]\n${text}` });
}
}
}
const gc: any = { maxOutputTokens: model.maxTokens || 8192 };
if (model.id.startsWith("gemini-") && model.reasoning) {
const level = options?.reasoning ?? "low";
const antgLevel = level === "low" || level === "minimal" ? "low" : level === "medium" ? "medium" : "high";
gc.thinkingConfig = { thinkingLevel: antgLevel, includeThoughts: true };
}
const request: any = { contents, generationConfig: gc };
if (context.systemPrompt) request.systemInstruction = { parts: [{ text: context.systemPrompt }] };
if (context.tools?.length) {
request.tools = [{
functionDeclarations: context.tools.map((t) => ({
name: t.name,
description: t.description,
parameters: antgToGeminiParameters(t.parameters),
})),
}];
}
return request;
}
async function antgConsumeSse(resp: any, model: Model<Api>, output: AssistantMessage, stream: AssistantMessageEventStream) {
const reader = resp.body?.getReader();
if (!reader) throw new Error("Nessun body streaming");
const decoder = new TextDecoder();
let buf = "", finishReason = "", usageMeta: any;
const blocks = output.content;
let currentBlock: any = null;
const blockIndex = () => blocks.length - 1;
const endCurrent = () => {
if (!currentBlock) return;
if (currentBlock.type === "text") stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: output });
else stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: output });
currentBlock = null;
};
let toolCallCounter = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// il framing SSE usa CRLF: normalizza a \n
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i);
buf = buf.slice(i + 2);
for (const line of chunk.split("\n")) {
if (!line.startsWith("data:")) continue;
const d = line.slice(5).trim();
if (!d) continue;
let o: any;
try { o = JSON.parse(d); } catch { continue; }
const inner = o.response ?? o;
const candidate = inner.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
const sig = part.thoughtSignature || part.thought_signature || candidate.content?.thoughtSignature || candidate.content?.thought_signature;
if (part.text !== undefined) {
const isThinking = part.thought === true;
if (!currentBlock || (isThinking && currentBlock.type !== "thinking") || (!isThinking && currentBlock.type !== "text")) {
endCurrent();
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: sig };
blocks.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "", textSignature: sig };
blocks.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
if (sig) currentBlock.thinkingSignature = sig;
stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta: part.text, partial: output });
} else {
currentBlock.text += part.text;
if (sig) currentBlock.textSignature = sig;
stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: part.text, partial: output });
}
}
if (part.functionCall) {
endCurrent();
const tc: ToolCall = {
type: "toolCall",
id: part.functionCall.id || `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`,
name: part.functionCall.name || "",
arguments: part.functionCall.args ?? {},
...(sig ? { thoughtSignature: sig } : {}),
};
blocks.push(tc);
const ci = blockIndex();
stream.push({ type: "toolcall_start", contentIndex: ci, partial: output });
stream.push({ type: "toolcall_end", contentIndex: ci, toolCall: tc, partial: output });
}
}
}
if (inner.usageMetadata) usageMeta = inner.usageMetadata;
if (inner.candidates?.[0]?.finishReason) finishReason = inner.candidates[0].finishReason;
if (inner.responseId && !output.responseId) output.responseId = inner.responseId;
}
}
}
endCurrent();
if (usageMeta) {
output.usage.input = usageMeta.promptTokenCount ?? 0;
output.usage.output = (usageMeta.candidatesTokenCount ?? 0) + (usageMeta.thoughtsTokenCount ?? 0);
output.usage.reasoning = usageMeta.thoughtsTokenCount ?? 0;
output.usage.totalTokens = usageMeta.totalTokenCount ?? (output.usage.input + output.usage.output);
output.usage.cost = calculateCost(model, output.usage);
}
output.rawStopReason = finishReason || undefined;
output.stopReason = blocks.some((b) => b.type === "toolCall") ? "toolUse" : finishReason === "MAX_TOKENS" ? "length" : "stop";
}
function streamAntigravity(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "pending",
timestamp: Date.now(),
};
(async () => {
try {
stream.push({ type: "start", partial: output });
await antgWithQueue(async () => {
const { pid, base } = await antgGetProject();
const token = await antgGetAccessToken();
const envelope = {
project: pid,
model: model.id,
request: antgBuildGeminiRequest(model, context, options),
requestType: "agent",
userAgent: "antigravity",
requestId: `agent-${crypto.randomUUID().replace(/-/g, "")}`,
};
const signal = antgAbortSignal(options?.signal, 300_000);
const call = (tok: string) =>
fetch(`${base}/v1internal:streamGenerateContent?alt=sse`, {
method: "POST",
headers: antgHeaders(tok, true),
body: JSON.stringify(envelope),
signal,
});
let resp = await call(token);
if (resp.status === 401 || resp.status === 403) {
antgToken = null;
resp = await call(await antgGetAccessToken());
}
if (!resp.ok) {
const bodyText = await resp.text().catch(() => "");
throw new Error(`Antigravity HTTP ${resp.status}: ${bodyText.slice(0, 300)}`);
}
await antgConsumeSse(resp, model, output, stream);
});
if (output.stopReason === "pending") throw new Error("Provider stream terminato senza stop reason");
if (output.stopReason === "error" || output.stopReason === "aborted") throw new Error(output.errorMessage || "Errore sconosciuto");
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse" | "deferred", message: output });
stream.end();
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : String(error);
stream.push({ type: "error", reason: output.stopReason as "aborted" | "error", error: output });
stream.end();
}
})();
return stream;
}
// ---------------------------------------------------------------------------
// Estensione
// ---------------------------------------------------------------------------
export default function agyExtension(pi: ExtensionAPI) {
// =========================================================================
// TOOL: agy — generico (chat / image / analyze)
// =========================================================================
pi.registerTool({
name: "agy",
label: "agy (Antigravity subagent)",
description:
"Use Google Antigravity CLI for text-only multi-turn reasoning or general chat. " +
"Do NOT use for image generation/editing, image analysis, audio transcription, video analysis, or text-to-speech: " +
"use the dedicated agy_* tool instead.",
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 only (default); image/analyze are legacy modes—use dedicated tools.",
}),
),
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: "Optional attachment for legacy use; prefer the dedicated image, audio, or video tool." })),
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)." }),
),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
toolUpdate(onUpdate, `agy: ${p.mode === "image" ? "generazione immagine" : "elaborazione"}...`);
// Context injection: ambiente, sessione e memoria locale.
const wantInject = p.injectContext ?? true;
const contextBlock = wantInject ? await buildContextBlock(ctx, p.prompt, signal, p.mode) : "";
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:
"Generate a new image from a structured subject, action, location, composition, style, lighting, and format. " +
"Use ONLY for image generation from scratch, not editing or image analysis.",
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." })),
imageSize: Type.Optional(
Type.String({ description: 'Risoluzione immagine via API diretta: "512", "1K", "2K", "4K" (default 1K).' }),
),
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 agyImageTask({
prompt,
aspectRatio: p.aspectRatio,
imageSize: p.imageSize ?? "1K",
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_generate",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, engine: res.engine, directError: res.directError, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_edit — editing controllato (Keep + Change + Add + Render)
// =========================================================================
pi.registerTool({
name: "agy_edit",
label: "agy edit image",
description:
"Edit an existing image using Keep, Change, Add, and Render. " +
"Use ONLY for controlled image edits; use agy_inpaint for changing one specific region.",
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 agyImageTask({
prompt,
images: [p.baseImage],
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_edit",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, 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:
"Change ONLY one specified region or element of an image and preserve everything else. " +
"Use for localized inpainting; use agy_edit for broader controlled edits.",
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 agyImageTask({
prompt,
images: [p.baseImage],
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_inpaint",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_style_transfer — applica uno stile preservando il contenuto
// =========================================================================
pi.registerTool({
name: "agy_style_transfer",
label: "agy style transfer",
description:
"Apply an artistic style to an existing image while preserving its content and composition. " +
"Use ONLY for style transfer, not general image edits or analysis.",
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 agyImageTask({
prompt,
images: [p.baseImage],
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_style_transfer",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_compose — combina più immagini
// =========================================================================
pi.registerTool({
name: "agy_compose",
label: "agy compose (combina immagini)",
description:
"Combine two or more existing images into one new composition. " +
"Use ONLY for multi-image fusion; use agy_edit for editing one image.",
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 agyImageTask({
prompt,
images: p.images,
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_compose",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, images: p.images, engine: res.engine, directError: res.directError, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_character — consistenza personaggio
// =========================================================================
pi.registerTool({
name: "agy_character",
label: "agy character consistency",
description:
"Generate or edit an image while preserving a character or object's identity from a reference image. " +
"Use ONLY when identity consistency is required.",
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 agyImageTask({
prompt,
images: [p.referenceImage],
model: p.model,
outputDir: p.outputDir,
signal,
label: "agy_character",
});
return {
content: [{ type: "text", text: res.text }],
details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, referenceImage: p.referenceImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_analyze — fallback per analisi immagini quando Pi è text-only
// =========================================================================
pi.registerTool({
name: "agy_analyze",
label: "agy analyze image fallback",
description:
"Analyze a local image with Gemini ONLY as a fallback when the current Pi model cannot inspect images natively. " +
"Use for image understanding or OCR. Do NOT use for audio (use agy_transcribe), video (use agy_video), " +
"image generation/editing, or images the current model can already see.",
parameters: Type.Object({
filePath: Type.String({ description: "Local image path only: PNG, JPG, JPEG, GIF, WebP, BMP, TIFF, or SVG. Never audio or video." }),
question: Type.Optional(Type.String({ description: "Specific question about the image." })),
model: Type.Optional(Type.String({ description: "Gemini model used for fallback analysis." })),
yolo: Type.Optional(Type.Boolean({ description: "true to pass --dangerously-skip-permissions; use only when explicitly required." })),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
const filePath = String(p.filePath ?? "").trim();
const ext = path.extname(filePath).toLowerCase();
if (!IMAGE_EXTENSIONS.has(ext)) {
return {
content: [{ type: "text", text: "agy_analyze accepts images only. Use agy_transcribe for audio or agy_video for video." }],
details: { error: "unsupported_media_type", filePath },
isError: true,
};
}
if (ctx.model?.input?.includes("image")) {
return {
content: [{ type: "text", text: "The current Pi model supports native image input; agy_analyze is not needed." }],
details: { error: "native_vision_available", model: ctx.model.id },
isError: true,
};
}
const prompt = p.question
? `Analyze the image at ${filePath}. ${p.question}`
: `Analyze the image at ${filePath} and describe it in detail.`;
toolUpdate(onUpdate, "agy_analyze: analisi immagine...");
const res = await executeAgy({
prompt,
mode: "analyze",
model: p.model,
stateless: true,
filePaths: [filePath],
yolo: p.yolo,
signal,
});
return {
content: [{ type: "text", text: res.text }],
details: { filePath, exitCode: res.exitCode },
};
},
});
// =========================================================================
// TOOL: agy_transcribe — trascrizione audio (via Gemini API diretta)
// =========================================================================
pi.registerTool({
name: "agy_transcribe",
label: "agy transcribe audio",
description:
"Transcribe speech or other audio into text through the Antigravity API. " +
"Use ONLY for audio; use agy_analyze for images and agy_video for video.",
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 transcribeWithAntigravity(p.filePath, p.language);
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: getConfig("voiceModel") ?? "gemini-3.7-flash-medium" },
};
},
});
// =========================================================================
// TOOL: agy_analyze_audio — audio + waveform + metadati + Gemini
// =========================================================================
pi.registerTool({
name: "agy_analyze_audio",
label: "agy analyze audio multimodale",
description:
"Analyze a local audio file together with a generated waveform and measured metadata through Gemini over Antigravity OAuth. " +
"Use for sound effects, music, recordings, timbre, transients, silence and clipping. " +
"It does not assume speech and does not use an external Gemini API key.",
parameters: Type.Object({
filePath: Type.String({ description: "Percorso del file audio (WAV, MP3, M4A, FLAC, OGG, OPUS o WEBM)." }),
question: Type.Optional(Type.String({ description: "Domanda specifica sull'audio o sul suo uso." })),
model: Type.Optional(Type.String({ description: "Modello Gemini Antigravity (default: voiceModel configurato)." })),
outputDir: Type.Optional(Type.String({ description: "Cartella opzionale dove conservare waveform PNG e report JSON; l'audio non viene copiato." })),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const p = params as any;
const filePath = path.resolve(ctx.cwd, String(p.filePath ?? "").replace(/^@/, ""));
const ext = path.extname(filePath).toLowerCase();
if (!AUDIO_EXTENSIONS.has(ext)) throw new Error(`Formato audio non supportato: ${ext || "senza estensione"}`);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) throw new Error(`File audio non trovato: ${filePath}`);
toolUpdate(onUpdate, "agy_analyze_audio: waveform e metadati locali...");
const result = await analyzeAudioWithAntigravity(filePath, p.question, p.model, p.outputDir, signal);
toolUpdate(onUpdate, "agy_analyze_audio: analisi Gemini completata");
const local = JSON.stringify(result.metadata, null, 2);
return {
content: [{ type: "text", text: `${result.text}\n\nMetadati misurati localmente:\n${local}` }],
details: {
filePath,
model: result.model,
metadata: result.metadata,
waveformPath: result.waveformPath,
reportPath: result.reportPath,
},
};
},
});
// =========================================================================
// TOOL: agy_video — analisi video
// =========================================================================
pi.registerTool({
name: "agy_video",
label: "agy analyze video",
description:
"Analyze a video: scenes, content, codec, resolution, and audio tracks. " +
"Use ONLY for video; use agy_analyze for images and agy_transcribe for audio-only files.",
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_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:
"Administrative control for agy conversation state: show the current ID, list stored conversations, or reset state. " +
"Use only when the user explicitly requests conversation-state management.",
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 };
}
},
});
// =========================================================================
// TOOL: antigravity_chat — client diretto protocollo Antigravity (account)
// =========================================================================
pi.registerTool({
name: "antigravity_chat",
label: "antigravity direct protocol chat",
description:
"Send one text or multimodal request directly to Antigravity's cloudcode-pa gateway, without the agy subprocess. " +
"Use ONLY when direct gateway access is explicitly needed; for normal delegation use agy. " +
"Respect per-model quota and send requests serially; do not use for batch automation.",
parameters: Type.Object({
prompt: Type.String({ description: "Il messaggio da inviare al modello" }),
model: Type.Optional(
Type.String({
description:
"Modello (default gemini-3.5-flash): gemini-3.5-flash | gemini-3.6-flash-medium | gemini-3.6-flash-high | gemini-3.1-pro | gemini-3.1-pro-high | claude-sonnet-4.6 | claude-opus-4.6 | gpt-oss-120b",
}),
),
system: Type.Optional(Type.String({ description: "System instruction opzionale" })),
maxOutputTokens: Type.Optional(Type.Number({ description: "Token massimi di output (default 8192)" })),
temperature: Type.Optional(Type.Number({ description: "Temperatura 0-2 (default 0.4)" })),
thinking: Type.Optional(
Type.String({
description: "Livello thinking per Gemini: low | medium | high | off (default: auto in base al modello)",
}),
),
stream: Type.Optional(Type.Boolean({ description: "Streaming SSE (default true)" })),
}),
async execute(toolCallId, params) {
const p = params as any;
try {
const res = await antgGenerate({
prompt: p.prompt,
model: p.model,
system: p.system,
maxOutputTokens: p.maxOutputTokens,
temperature: p.temperature,
thinking: p.thinking,
stream: p.stream !== false,
});
return {
content: [{ type: "text", text: res.text || "(nessun testo nella risposta)" }],
details: { ...res.details, model: p.model ?? ANTG_DEFAULT_MODEL, project: antgProject?.pid ?? null },
};
} catch (e: any) {
return {
content: [{ type: "text", text: `Errore Antigravity: ${e.message}` }],
details: {},
isError: true,
};
}
},
});
// =========================================================================
// PROVIDER: antigravity — modelli del gateway nel selettore modelli
// =========================================================================
pi.registerProvider("antigravity", antgProviderConfig(antgRegisteredModels));
// =========================================================================
// Comando: /agy:refresh-models — aggiorna l'elenco modelli del provider dal
// catalogo vivo dell'account (fetchAvailableModels) e ri-registra il provider.
// =========================================================================
pi.registerCommand("agy:refresh-models", {
description:
"Aggiorna l'elenco modelli del provider Antigravity dal catalogo vivo dell'account (fetchAvailableModels) e ri-registra il provider (selettore modelli).",
handler: async (_args, ctx) => {
ctx.ui.setStatus("agy:refresh-models", "Aggiornamento modelli Antigravity...");
try {
const { pid, base } = await antgGetProject();
const token = await antgGetAccessToken();
const models = await antgFetchCatalog(token, base);
if (!models.length) throw new Error("Il catalogo non ha restituito modelli utilizzabili");
antgRegisteredModels = models;
pi.registerProvider("antigravity", antgProviderConfig(models));
saveModelsCache(models, pid);
ctx.ui.setStatus("agy:refresh-models", "");
const lines = models.map((m) => ` ${m.id}${m.name}${m.reasoning ? " (thinking)" : ""}`).join("\n");
ctx.ui.notify(
`✅ Provider antigravity aggiornato: ${models.length} modelli (project ${pid})\n${lines}\n\n💾 Lista salvata in ${MODELS_CACHE_FILE} (persistente al riavvio).`,
"info",
);
} catch (e: any) {
ctx.ui.setStatus("agy:refresh-models", "");
ctx.ui.notify(`Errore aggiornamento modelli: ${e.message}`, "error");
}
},
});
// =========================================================================
// Comando: /agy:models-reset — rimuove la cache persistente dei modelli e
// ripristina la lista statica di default.
// =========================================================================
pi.registerCommand("agy:models-reset", {
description:
"Rimuove la cache persistente dei modelli (~/.config/agy-pi/models-cache.json) e ri-registra il provider con la lista di default.",
handler: async (_args, ctx) => {
let removed = false;
try {
removed = fs.existsSync(MODELS_CACHE_FILE);
fs.rmSync(MODELS_CACHE_FILE, { force: true });
} catch (e: any) {
ctx.ui.notify(`Errore rimozione cache modelli: ${e.message}`, "error");
return;
}
antgRegisteredModels = ANTG_PROVIDER_MODELS;
pi.registerProvider("antigravity", antgProviderConfig(ANTG_PROVIDER_MODELS));
ctx.ui.notify(
`♻️ ${removed ? "Cache rimossa" : "Nessuna cache presente"}: provider antigravity ripristinato a ${ANTG_PROVIDER_MODELS.length} modelli di default.`,
"info",
);
},
});
// =========================================================================
// 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, Ctrl+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);
const editorText = (ctx.ui.getEditorText?.() ?? "").trim();
const context = getConversationContext(ctx);
let transcript = "";
let finalText = "";
let needsSearch = false;
let searchHints: string[] = [];
// Audio e contesto passano sempre tramite Antigravity, indipendentemente
// dal modello di chat attivo nella sessione.
{
// =========================================================================
// Pipeline multimodale diretta tramite il gateway Antigravity.
// =========================================================================
ctx.ui.notify("Interpretazione vocale diretta con Antigravity...", "info");
try {
const voiceModelName = getConfig("voiceModel") || "gemini-3.7-flash-medium";
const directRes = await interpretAudioWithAntigravity(
optimized,
editorText,
context,
voiceModelName,
);
transcript = directRes.transcript || directRes.cleanPrompt;
finalText = directRes.cleanPrompt;
needsSearch = directRes.needsSearch;
searchHints = directRes.searchHints;
} catch (err: any) {
ctx.ui.notify(`Elaborazione diretta fallita: ${err.message}. Fallback su trascrizione STT...`, "warning");
}
}
// Fallback o modello non-Gemini: pipeline a 2 passaggi (STT + Briefing)
if (!finalText) {
ctx.ui.notify("Trascrizione in corso...", "info");
const tr = await transcribeWithAntigravity(optimized);
if (!tr.text) {
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify(`Trascrizione fallita: ${tr.error ?? "vuota"}`, "error");
playSound("cancel");
return;
}
transcript = tr.text;
ctx.ui.notify("Interpretazione con Antigravity...", "info");
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,
});
const briefing = extractVoiceBriefing(res.text);
finalText = briefing.cleanPrompt || res.text.trim() || transcript;
needsSearch = briefing.needsSearch;
searchHints = briefing.searchHints;
}
ctx.ui.setStatus("agy-rec", "");
// Guida per l'agente successivo: usa la ricerca web/Perplexity se necessario
if (needsSearch) {
const hints = searchHints.length ? ` Suggerimenti: ${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;
}
if (decision.action === "literal") {
// Non invia il piano proposto: inserisce la dettatura letterale
// nell'area del prompt (ripristinando l'eventuale testo editor consumato).
const literal = editorText ? `${editorText}\n\n${transcript}` : transcript;
try {
ctx.ui.pasteToEditor?.(literal);
} catch {
/* ignora */
}
ctx.ui.notify("Trascrizione letterale inserita nel prompt (non inviata)", "info");
playSound("done");
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("✅ Richiesta vocale inviata come prompt a pi", "info");
playSound("done");
}
pi.registerShortcut("f12", {
description: "Avvia/ferma registrazione microfono (max 2 min) e interpreta via Antigravity",
handler: async (ctx) => {
await handleRecordToggle(ctx);
},
});
pi.registerShortcut("ctrl+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.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");
},
});
// =========================================================================
// Comando: /agy:config — gestione configurazione persistente
// =========================================================================
const CONFIG_KEYS: { key: keyof AgyConfig; desc: string }[] = [
{ key: "sttMaxDuration", desc: "Durata max registrazione (secondi)" },
{ key: "voiceModel", desc: "Modello Antigravity per audio (default gemini-3.7-flash-medium)" },
{ 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: "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 = 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: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 model = getConfig("agyDefaultModel") || "(default agy)";
// 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("Modello attivo", model, "accent");
line("Pipeline audio", "Antigravity OAuth", "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" },
},
);
},
});
}