1405 lines
50 KiB
TypeScript
1405 lines
50 KiB
TypeScript
/**
|
|
* agy-pi — Google Antigravity CLI (agy) come subagent multimodale dentro pi.
|
|
*
|
|
* Espone un set completo di strumenti specializzati per generazione ed editing
|
|
* di immagini, analisi di file, trascrizione audio, analisi video e gestione
|
|
* conversazione. Ogni strumento costruisce un prompt ottimizzato secondo le
|
|
* best practice di Gemini/Nano Banana (Keep+Change+Add+Render, un cambiamento
|
|
* per turno, ri-attacco dell'immagine base, ecc.).
|
|
*
|
|
* L'agente (pi) decide quale strumento usare in base al task.
|
|
*/
|
|
|
|
import { execFile, spawn } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "typebox";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurazione
|
|
// ---------------------------------------------------------------------------
|
|
const AGY_CHAT_DIR = path.join(os.homedir(), ".agy-chat");
|
|
const STATE_FILE = path.join(AGY_CHAT_DIR, "conversation_id");
|
|
const CONV_DIR = path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
|
|
const DEFAULT_TIMEOUT_MS = 180_000; // 3 min
|
|
const IMAGE_TIMEOUT_MS = 300_000; // 5 min
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurazione persistente (~/.config/agy-pi/config.json)
|
|
// ---------------------------------------------------------------------------
|
|
const CONFIG_DIR = path.join(os.homedir(), ".config", "agy-pi");
|
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
|
|
interface AgyConfig {
|
|
geminiApiKey?: string;
|
|
enne2ApiKey?: string;
|
|
sttBackend?: string; // gemini | enne2
|
|
sttUrl?: string;
|
|
sttModel?: string;
|
|
sttMaxDuration?: number; // secondi
|
|
ttsBackend?: string; // gemini | enne2
|
|
ttsNotify?: boolean;
|
|
ttsModel?: string;
|
|
agyBin?: string;
|
|
agyDefaultModel?: string;
|
|
agyTimeoutMs?: number;
|
|
}
|
|
|
|
const CONFIG_DEFAULTS: AgyConfig = {
|
|
sttBackend: "gemini",
|
|
sttUrl: "https://ai.enne2.net",
|
|
sttModel: "gemma4:E4B",
|
|
sttMaxDuration: 120,
|
|
ttsBackend: "gemini",
|
|
ttsNotify: true,
|
|
ttsModel: "gemini-2.5-flash-preview-tts",
|
|
agyTimeoutMs: 180_000,
|
|
};
|
|
|
|
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"];
|
|
const boolKeys: (keyof AgyConfig)[] = ["ttsNotify"];
|
|
if (numKeys.includes(key)) {
|
|
(cfg as any)[key] = Number(value);
|
|
} else if (boolKeys.includes(key)) {
|
|
(cfg as any)[key] = value === "true" || value === "1" || value === "yes";
|
|
} else {
|
|
(cfg as any)[key] = value;
|
|
}
|
|
saveConfig(cfg);
|
|
}
|
|
|
|
function resetConfig() {
|
|
try {
|
|
fs.rmSync(CONFIG_FILE, { force: true });
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: trovare il binario agy
|
|
// ---------------------------------------------------------------------------
|
|
function findAgy(): string {
|
|
const candidates = [
|
|
getConfig("agyBin"),
|
|
process.env.AGY_BIN,
|
|
path.join(os.homedir(), ".local", "bin", "agy"),
|
|
"agy",
|
|
].filter(Boolean) as string[];
|
|
for (const c of candidates) {
|
|
if (c === "agy") return c;
|
|
if (fs.existsSync(c)) return c;
|
|
}
|
|
return "agy";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: ultimo conversation_id
|
|
// ---------------------------------------------------------------------------
|
|
function getLatestConversationId(): string | null {
|
|
try {
|
|
if (!fs.existsSync(CONV_DIR)) return null;
|
|
const files = fs
|
|
.readdirSync(CONV_DIR)
|
|
.filter((f) => f.endsWith(".db"))
|
|
.map((f) => path.join(CONV_DIR, f))
|
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
if (files.length === 0) return null;
|
|
return path.basename(files[0], ".db");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readState(): string | null {
|
|
try {
|
|
return fs.readFileSync(STATE_FILE, "utf8").trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeState(id: string) {
|
|
try {
|
|
fs.mkdirSync(AGY_CHAT_DIR, { recursive: true });
|
|
fs.writeFileSync(STATE_FILE, id, "utf8");
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
|
|
function resetState() {
|
|
try {
|
|
fs.rmSync(STATE_FILE, { force: true });
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: eseguire agy
|
|
// ---------------------------------------------------------------------------
|
|
interface RunResult {
|
|
output: string;
|
|
error: string | null;
|
|
exitCode: number;
|
|
}
|
|
|
|
async function runAgy(
|
|
args: string[],
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
): Promise<RunResult> {
|
|
const bin = findAgy();
|
|
// --output-format json: agy non scrive nulla su stdout quando è piped/redirected
|
|
// (bug #76). Il formato json emette un oggetto con la risposta in .response.
|
|
const fullArgs = [...args, "--output-format", "json"];
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(bin, fullArgs, {
|
|
timeout: timeoutMs,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
signal,
|
|
env: { ...process.env, PATH: `${path.join(os.homedir(), ".local", "bin")}:${process.env.PATH ?? ""}` },
|
|
});
|
|
// estrai la risposta dal JSON
|
|
let output = stdout;
|
|
try {
|
|
const parsed = JSON.parse(stdout);
|
|
if (parsed && typeof parsed.response === "string") {
|
|
output = parsed.response;
|
|
}
|
|
} catch {
|
|
/* non-JSON: usa stdout grezzo */
|
|
}
|
|
return { output, error: stderr || null, exitCode: 0 };
|
|
} catch (err: any) {
|
|
const code = typeof err.code === "number" ? err.code : 1;
|
|
return {
|
|
output: err.stdout || "",
|
|
error: err.stderr || err.message || String(err),
|
|
exitCode: code,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: estrarre il percorso immagine dall'output
|
|
// ---------------------------------------------------------------------------
|
|
function extractImagePath(text: string): string | undefined {
|
|
const m =
|
|
text.match(/IMAGE_PATH:\s*(\S+)/i) ||
|
|
text.match(/(\/[^\s]+\.(?:png|jpe?g|webp|gif))/i);
|
|
return m ? m[1] : undefined;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: copiare l'immagine generata in una cartella di output
|
|
// ---------------------------------------------------------------------------
|
|
function copyImage(src: string | undefined, outputDir?: string): string | undefined {
|
|
if (!src || !outputDir) return src;
|
|
try {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const dest = path.join(outputDir, path.basename(src));
|
|
fs.copyFileSync(src, dest);
|
|
return dest;
|
|
} catch {
|
|
return src;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: esecuzione comune di un tool agy
|
|
// ---------------------------------------------------------------------------
|
|
interface AgyExecOptions {
|
|
prompt: string;
|
|
mode?: "chat" | "image" | "analyze";
|
|
model?: string;
|
|
effort?: "low" | "medium" | "high";
|
|
newConversation?: boolean;
|
|
stateless?: boolean; // non continua la conversazione (ri-attacca l'immagine base)
|
|
addDirs?: string[];
|
|
filePaths?: string[];
|
|
yolo?: boolean;
|
|
timeoutMs?: number;
|
|
outputDir?: string;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
async function executeAgy(opts: AgyExecOptions) {
|
|
const args: string[] = ["-p", opts.prompt];
|
|
|
|
// add-dir per i file
|
|
const dirs = new Set<string>();
|
|
for (const d of opts.addDirs ?? []) if (d) dirs.add(d);
|
|
for (const f of opts.filePaths ?? []) if (f) dirs.add(path.dirname(f));
|
|
for (const d of dirs) args.push("--add-dir", d);
|
|
|
|
// conversazione
|
|
const useState = !opts.stateless && !opts.newConversation;
|
|
let convId: string | null = null;
|
|
if (opts.newConversation) convId = null;
|
|
else if (opts.stateless) convId = null;
|
|
else convId = readState();
|
|
if (convId) args.push("--conversation", convId);
|
|
|
|
const model = opts.model ?? getConfig("agyDefaultModel");
|
|
if (model) args.push("--model", model);
|
|
if (opts.effort) args.push("--effort", opts.effort);
|
|
if (opts.yolo) args.push("--dangerously-skip-permissions");
|
|
|
|
const timeout =
|
|
opts.timeoutMs ??
|
|
Number(getConfig("agyTimeoutMs") ?? DEFAULT_TIMEOUT_MS) ??
|
|
(opts.mode === "image" ? IMAGE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
|
|
const r = await runAgy(args, timeout, opts.signal);
|
|
|
|
// aggiorna stato conversazione (solo se non stateless)
|
|
if (r.exitCode === 0 && !opts.stateless) {
|
|
const latest = getLatestConversationId();
|
|
if (latest) writeState(latest);
|
|
}
|
|
|
|
let text = r.output.trim();
|
|
if (r.error) {
|
|
const err = r.error
|
|
.split("\n")
|
|
.filter((l) => !/logging before google\.Init/i.test(l))
|
|
.join("\n")
|
|
.trim();
|
|
if (err && !text) text = err;
|
|
}
|
|
|
|
const imagePath = copyImage(extractImagePath(text), opts.outputDir);
|
|
if (imagePath && opts.outputDir) {
|
|
text += `\n\n[immagine copiata in: ${imagePath}]`;
|
|
}
|
|
|
|
return {
|
|
text,
|
|
imagePath,
|
|
conversationId: convId,
|
|
exitCode: r.exitCode,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: costruire il suffisso "IMAGE_PATH" per i tool immagine
|
|
// ---------------------------------------------------------------------------
|
|
const IMG_SUFFIX =
|
|
"\n\nAlla fine della risposta, scrivi su una riga esattamente: IMAGE_PATH: <percorso assoluto dell'immagine generata>";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registrazione microfono (F12) e trascrizione
|
|
// ---------------------------------------------------------------------------
|
|
const MAX_RECORD_MS = 120_000; // 2 min
|
|
|
|
interface Recording {
|
|
proc: ReturnType<typeof spawn>;
|
|
file: string;
|
|
}
|
|
|
|
let recording: Recording | null = null;
|
|
|
|
function startRecording(): string {
|
|
const maxDur = Number(getConfig("sttMaxDuration") ?? 120);
|
|
const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`);
|
|
const proc = spawn(
|
|
"ffmpeg",
|
|
["-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-t", String(maxDur), file],
|
|
{ stdio: "ignore" },
|
|
);
|
|
recording = { proc, file };
|
|
// se ffmpeg termina da solo (timeout 2 min), azzera lo stato
|
|
proc.on("exit", () => {
|
|
if (recording && recording.proc === proc) recording = null;
|
|
});
|
|
return file;
|
|
}
|
|
|
|
function stopRecording(): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
if (!recording) return resolve(null);
|
|
const { proc, file } = recording;
|
|
recording = null;
|
|
let done = false;
|
|
const finish = () => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve(file);
|
|
}
|
|
};
|
|
proc.on("exit", finish);
|
|
proc.kill("SIGINT"); // ffmpeg finalizza il file
|
|
setTimeout(finish, 3000); // fallback
|
|
});
|
|
}
|
|
|
|
// Ottimizza l'audio: taglia il silenzio iniziale/finale e converte in WAV 16kHz mono
|
|
async function optimizeAudio(input: string): Promise<string> {
|
|
const output = input.replace(/\.wav$/, "-opt.wav");
|
|
try {
|
|
await execFileAsync(
|
|
"ffmpeg",
|
|
[
|
|
"-y",
|
|
"-i",
|
|
input,
|
|
"-af",
|
|
"silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.5,areverse,silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.5,areverse",
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"16000",
|
|
output,
|
|
],
|
|
{ timeout: 30_000 },
|
|
);
|
|
return output;
|
|
} catch {
|
|
return input; // fallback al file originale
|
|
}
|
|
}
|
|
|
|
// Trascrizione affidabile e veloce via Gemini API diretta
|
|
// (agy CLI non supporta audio; la API supporta audio/wav nativamente)
|
|
async function transcribeWithGeminiAPI(file: string): Promise<string> {
|
|
// key da config file o env var
|
|
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
|
|
if (!key) {
|
|
try {
|
|
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
if (!key) return "";
|
|
|
|
const model = process.env.AGY_GEMINI_MODEL ?? "gemini-3.5-flash";
|
|
try {
|
|
// Comprimi in MP3 per evitare HTTP 413 (payload troppo grande) su registrazioni lunghe
|
|
// (WAV non compresso supera facilmente i 20MB; MP3 è ~19x più piccolo)
|
|
let audioFile = file;
|
|
let mimeType = "audio/wav";
|
|
if (file.toLowerCase().endsWith(".wav")) {
|
|
const mp3 = file.replace(/\.wav$/i, ".mp3");
|
|
try {
|
|
await execFileAsync(
|
|
"ffmpeg",
|
|
["-y", "-i", file, "-c:a", "libmp3lame", "-q:a", "4", mp3],
|
|
{ timeout: 30_000 },
|
|
);
|
|
audioFile = mp3;
|
|
mimeType = "audio/mpeg";
|
|
} catch {
|
|
/* fallback al WAV originale */
|
|
}
|
|
}
|
|
const b64 = fs.readFileSync(audioFile).toString("base64");
|
|
const body = {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{ text: "Trascrivi fedelmente il contenuto di questo audio in italiano. Restituisci SOLO la trascrizione testuale." },
|
|
{ inline_data: { mime_type: mimeType, data: b64 } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(60_000),
|
|
},
|
|
);
|
|
if (!res.ok) return "";
|
|
const data: any = await res.json();
|
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
// Trascrizione via server locale ai.enne2.net (gemma4:E4B supporta audio)
|
|
async function transcribeWithEnne2(file: string): Promise<string> {
|
|
const baseUrl = getConfig("sttUrl") ?? "https://ai.enne2.net";
|
|
const model = getConfig("sttModel") ?? "gemma4:E4B";
|
|
const apiKey = getConfig("enne2ApiKey") ?? process.env.ENNE2_API_KEY ?? "";
|
|
try {
|
|
const b64 = fs.readFileSync(file).toString("base64");
|
|
const body = {
|
|
model,
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Trascrivi fedelmente il parlato in questo audio. Rispondi solo con la trascrizione." },
|
|
{ type: "input_audio", input_audio: { data: b64, format: "wav" } },
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 2000,
|
|
};
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(90_000),
|
|
});
|
|
if (!res.ok) return "";
|
|
const data: any = await res.json();
|
|
return data?.choices?.[0]?.message?.content?.trim() ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Dispatcher: sceglie il backend di trascrizione (gemini | enne2)
|
|
async function transcribeAudio(file: string): Promise<string> {
|
|
const backend = getConfig("sttBackend") ?? "gemini";
|
|
if (backend === "enne2" || backend === "local") {
|
|
return transcribeWithEnne2(file);
|
|
}
|
|
return transcribeWithGeminiAPI(file);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TTS via Gemini API (nessun engine esterno)
|
|
// ---------------------------------------------------------------------------
|
|
async function ttsSpeak(text: string, outputDir?: string): Promise<string | null> {
|
|
let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? "";
|
|
if (!key) {
|
|
try {
|
|
key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim();
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
if (!key) return null;
|
|
|
|
const model = getConfig("ttsModel") ?? "gemini-2.5-flash-preview-tts";
|
|
try {
|
|
const body = {
|
|
contents: [{ parts: [{ text }] }],
|
|
generationConfig: { responseModalities: ["AUDIO"] },
|
|
};
|
|
const res = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(60_000),
|
|
},
|
|
);
|
|
if (!res.ok) return null;
|
|
const data: any = await res.json();
|
|
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
|
const audioPart = parts.find((p: any) => p.inlineData);
|
|
if (!audioPart) return null;
|
|
|
|
// salva PCM raw (16-bit, 24kHz) e converti in WAV
|
|
const pcmFile = path.join(os.tmpdir(), `tts-${Date.now()}.pcm`);
|
|
fs.writeFileSync(pcmFile, Buffer.from(audioPart.inlineData.data, "base64"));
|
|
const wavFile = pcmFile.replace(".pcm", ".wav");
|
|
await execFileAsync(
|
|
"ffmpeg",
|
|
["-y", "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", pcmFile, wavFile],
|
|
{ timeout: 30_000 },
|
|
);
|
|
fs.rmSync(pcmFile, { force: true });
|
|
|
|
// copia in outputDir se richiesto
|
|
let finalFile = wavFile;
|
|
if (outputDir) {
|
|
try {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const dest = path.join(outputDir, `tts-${Date.now()}.wav`);
|
|
fs.copyFileSync(wavFile, dest);
|
|
finalFile = dest;
|
|
} catch {
|
|
/* ignora */
|
|
}
|
|
}
|
|
|
|
// riproduci (fire-and-forget)
|
|
execFileAsync("paplay", [finalFile], { timeout: 60_000 }).catch(() => {});
|
|
|
|
return finalFile;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: onUpdate nel formato corretto (oggetto con content, non stringa)
|
|
// ---------------------------------------------------------------------------
|
|
function toolUpdate(onUpdate: any, text: string) {
|
|
onUpdate?.({ content: [{ type: "text", text }] });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Estensione
|
|
// ---------------------------------------------------------------------------
|
|
export default function agyExtension(pi: ExtensionAPI) {
|
|
// =========================================================================
|
|
// TOOL: agy — generico (chat / image / analyze)
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy",
|
|
label: "agy (Antigravity subagent)",
|
|
description:
|
|
"Delega un task generico a Google Antigravity CLI (agy), che usa Gemini multimodale. " +
|
|
"Utile per conversazioni di ragionamento multi-turno, generazione immagini e analisi di file. " +
|
|
"Per task specializzati usa agy_generate, agy_edit, agy_inpaint, agy_style_transfer, agy_compose, " +
|
|
"agy_analyze, agy_transcribe, agy_video.",
|
|
parameters: Type.Object({
|
|
prompt: Type.String({ description: "Il task/prompt da dare a agy." }),
|
|
mode: Type.Optional(
|
|
Type.Union([Type.Literal("chat"), Type.Literal("image"), Type.Literal("analyze")], {
|
|
description: "chat (default) | image | analyze",
|
|
}),
|
|
),
|
|
model: Type.Optional(Type.String({ description: "Modello agy (es. 'Gemini 3.1 Pro (High)')." })),
|
|
effort: Type.Optional(
|
|
Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]),
|
|
),
|
|
newConversation: Type.Optional(Type.Boolean({ description: "true per forzare una nuova conversazione." })),
|
|
addDir: Type.Optional(Type.String({ description: "Cartella da aggiungere al workspace agy." })),
|
|
filePath: Type.Optional(Type.String({ description: "File (immagine/audio/video) da analizzare." })),
|
|
yolo: Type.Optional(Type.Boolean({ description: "true per --dangerously-skip-permissions." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
toolUpdate(onUpdate, `agy: ${p.mode === "image" ? "generazione immagine" : "elaborazione"}...`);
|
|
const res = await executeAgy({
|
|
prompt: p.prompt,
|
|
mode: p.mode,
|
|
model: p.model,
|
|
effort: p.effort,
|
|
newConversation: p.newConversation,
|
|
addDirs: p.addDir ? [p.addDir] : [],
|
|
filePaths: p.filePath ? [p.filePath] : [],
|
|
yolo: p.yolo,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { conversationId: res.conversationId, exitCode: res.exitCode, imagePath: res.imagePath, model: p.model },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_generate — generazione immagine strutturata
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_generate",
|
|
label: "agy generate image",
|
|
description:
|
|
"Genera un'immagine da zero con parametri strutturati (soggetto, azione, luogo, composizione, stile, luce, formato). " +
|
|
"Usa la formula narrativa [Soggetto]+[Azione]+[Luogo]+[Composizione]+[Stile] per il massimo controllo.",
|
|
parameters: Type.Object({
|
|
subject: Type.String({ description: "Soggetto: chi/cosa è nell'immagine. Sii specifico." }),
|
|
action: Type.Optional(Type.String({ description: "Azione: cosa sta succedendo." })),
|
|
location: Type.Optional(Type.String({ description: "Luogo/contesto/sfondo." })),
|
|
composition: Type.Optional(
|
|
Type.String({ description: "Composizione: inquadratura (es. 'close-up', 'wide shot', 'low-angle')." }),
|
|
),
|
|
style: Type.Optional(Type.String({ description: "Stile: estetica (es. 'fotorealistico', 'watercolor', 'film noir')." })),
|
|
lighting: Type.Optional(Type.String({ description: "Illuminazione (es. 'golden hour', 'softbox', 'neon')." })),
|
|
aspectRatio: Type.Optional(
|
|
Type.String({ description: "Formato (es. '1:1', '16:9', '9:16', '4:3', '21:9')." }),
|
|
),
|
|
text: Type.Optional(Type.String({ description: "Testo da includere nell'immagine (tra virgolette)." })),
|
|
negative: Type.Optional(Type.String({ description: "Cosa evitare, in framing positivo (es. 'nessun testo')." })),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine generata." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const parts = [`Genera un'immagine: ${p.subject}`];
|
|
if (p.action) parts.push(`Azione: ${p.action}`);
|
|
if (p.location) parts.push(`Luogo/contesto: ${p.location}`);
|
|
if (p.composition) parts.push(`Composizione: ${p.composition}`);
|
|
if (p.style) parts.push(`Stile: ${p.style}`);
|
|
if (p.lighting) parts.push(`Illuminazione: ${p.lighting}`);
|
|
if (p.aspectRatio) parts.push(`Formato/aspect ratio: ${p.aspectRatio}`);
|
|
if (p.text) parts.push(`Includi il testo "${p.text}" nell'immagine.`);
|
|
if (p.negative) parts.push(`Evita: ${p.negative}.`);
|
|
const prompt = parts.join(". ") + IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_generate: generazione immagine...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
outputDir: p.outputDir,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_edit — editing controllato (Keep + Change + Add + Render)
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_edit",
|
|
label: "agy edit image",
|
|
description:
|
|
"Modifica un'immagine esistente in modo controllato usando la formula Keep+Change+Add+Render. " +
|
|
"Specifica cosa mantenere invariato, cosa cambiare, cosa aggiungere e il target di resa. " +
|
|
"Un solo cambiamento per turno per il massimo controllo.",
|
|
parameters: Type.Object({
|
|
baseImage: Type.String({ description: "Percorso dell'immagine base da modificare." }),
|
|
keep: Type.String({ description: "Cosa mantenere invariato (es. 'soggetto, posa, illuminazione, composizione')." }),
|
|
change: Type.String({ description: "Cosa cambiare (es. 'il colore del divano in blu navy')." }),
|
|
add: Type.Optional(Type.String({ description: "Cosa aggiungere (es. 'un vaso sul tavolo')." })),
|
|
render: Type.Optional(
|
|
Type.String({ description: "Target di resa (es. 'foto premium', 'stile editoriale', 'formato 4:5')." }),
|
|
),
|
|
preserveAspectRatio: Type.Optional(
|
|
Type.Boolean({ description: "true per non cambiare l'aspect ratio dell'input." }),
|
|
),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const keep = p.preserveAspectRatio ? `${p.keep}. Non cambiare l'aspect ratio dell'immagine di input.` : p.keep;
|
|
let prompt = `Usando l'immagine al percorso ${p.baseImage} come base, mantieni ${keep} invariato. Cambia ${p.change}.`;
|
|
if (p.add) prompt += ` Aggiungi ${p.add}.`;
|
|
if (p.render) prompt += ` Render come ${p.render}.`;
|
|
prompt += IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_edit: modifica immagine...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.baseImage],
|
|
outputDir: p.outputDir,
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_inpaint — editing di una zona specifica (semantic masking)
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_inpaint",
|
|
label: "agy inpaint (edit zona specifica)",
|
|
description:
|
|
"Modifica SOLO una parte specifica dell'immagine lasciando il resto intatto (inpainting / semantic masking). " +
|
|
"Definisci l'elemento target e la sostituzione; tutto il resto resta identico.",
|
|
parameters: Type.Object({
|
|
baseImage: Type.String({ description: "Percorso dell'immagine base." }),
|
|
target: Type.String({ description: "L'elemento specifico da modificare (es. 'la maglietta del soggetto')." }),
|
|
replacement: Type.String({ description: "La nuova descrizione dell'elemento (es. 'una maglietta rossa')." }),
|
|
keepRest: Type.Optional(
|
|
Type.String({ description: "Cosa mantenere identico (default: tutto il resto)." }),
|
|
),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const keep = p.keepRest ?? "tutto il resto";
|
|
const prompt =
|
|
`Usando l'immagine al percorso ${p.baseImage}, cambia SOLO ${p.target} in ${p.replacement}. ` +
|
|
`Mantieni ${keep} esattamente identico, preservando stile, illuminazione e composizione originali.` +
|
|
IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_inpaint: modifica zona specifica...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.baseImage],
|
|
outputDir: p.outputDir,
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_style_transfer — applica uno stile preservando il contenuto
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_style_transfer",
|
|
label: "agy style transfer",
|
|
description:
|
|
"Applica uno stile artistico a un'immagine preservando il contenuto e la composizione originali " +
|
|
"(style transfer). Es. trasformare una foto in un dipinto Van Gogh.",
|
|
parameters: Type.Object({
|
|
baseImage: Type.String({ description: "Percorso dell'immagine base." }),
|
|
style: Type.String({ description: "Lo stile da applicare (es. 'pittura Van Gogh', 'architectural drawing', 'film noir')." }),
|
|
preserve: Type.Optional(
|
|
Type.String({ description: "Cosa preservare (default: 'la composizione originale')." }),
|
|
),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const preserve = p.preserve ?? "la composizione originale";
|
|
const prompt =
|
|
`Usando l'immagine al percorso ${p.baseImage}, trasforma il contenuto nello stile di ${p.style}. ` +
|
|
`Preserva ${preserve} ma renderizzala con lo stile richiesto.` +
|
|
IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_style_transfer: applica stile...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.baseImage],
|
|
outputDir: p.outputDir,
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_compose — combina più immagini
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_compose",
|
|
label: "agy compose (combina immagini)",
|
|
description:
|
|
"Combina più immagini in una nuova composizione (multi-image composition / fusion). " +
|
|
"Specifica il ruolo di ciascuna immagine e l'istruzione di fusione.",
|
|
parameters: Type.Object({
|
|
images: Type.Array(Type.String({ description: "Percorsi delle immagini da combinare." }), {
|
|
description: "Lista di percorsi immagine (fino a ~6-14).",
|
|
}),
|
|
instruction: Type.String({
|
|
description: "Istruzione di fusione: cosa prendere da ciascuna immagine e come combinarle.",
|
|
}),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const refs = (p.images as string[]).map((img, i) => `Immagine ${i + 1}: ${img}`).join("\n");
|
|
const prompt =
|
|
`Combina le seguenti immagini in una nuova composizione:\n${refs}\n\n` +
|
|
`Istruzione: ${p.instruction}. Specifica il ruolo di ciascuna immagine.` +
|
|
IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_compose: combina immagini...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: p.images,
|
|
outputDir: p.outputDir,
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, images: p.images, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_character — consistenza personaggio
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_character",
|
|
label: "agy character consistency",
|
|
description:
|
|
"Mantiene la consistenza di un personaggio/oggetto attraverso più generazioni o edit. " +
|
|
"Usa un'immagine di riferimento e un token di consistenza per preservare le caratteristiche.",
|
|
parameters: Type.Object({
|
|
referenceImage: Type.String({ description: "Percorso dell'immagine di riferimento del personaggio." }),
|
|
name: Type.String({ description: "Nome/token del personaggio (es. 'Maya-giacca-blu')." }),
|
|
features: Type.String({ description: "Caratteristiche immutabili da preservare (es. 'cicatrice sopracciglio sinistro')." }),
|
|
task: Type.String({ description: "Cosa fare con il personaggio (es. 'mettilo in una scena notturna')." }),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine risultante." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const prompt =
|
|
`Usando l'immagine al percorso ${p.referenceImage} come riferimento del personaggio '${p.name}', ` +
|
|
`${p.task}. Mantieni le caratteristiche del personaggio identiche: ${p.features}. ` +
|
|
`Usa il token '${p.name}' per riferirti al personaggio.` +
|
|
IMG_SUFFIX;
|
|
|
|
toolUpdate(onUpdate, "agy_character: mantieni consistenza personaggio...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "image",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.referenceImage],
|
|
outputDir: p.outputDir,
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { imagePath: res.imagePath, referenceImage: p.referenceImage, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_analyze — analisi di un file (immagine/audio/video)
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_analyze",
|
|
label: "agy analyze file",
|
|
description:
|
|
"Analizza un file (immagine, audio o video) con Gemini multimodale. " +
|
|
"Per immagini: descrizione, OCR, analisi. Per audio: contenuto, trascrizione. Per video: scene, contenuto.",
|
|
parameters: Type.Object({
|
|
filePath: Type.String({ description: "Percorso del file da analizzare." }),
|
|
question: Type.Optional(Type.String({ description: "Domanda specifica sull'analisi." })),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
yolo: Type.Optional(Type.Boolean({ description: "true per --dangerously-skip-permissions (necessario per audio/video)." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const prompt = p.question
|
|
? `Analizza il file al percorso ${p.filePath}. ${p.question}`
|
|
: `Analizza il file al percorso ${p.filePath} e descrivilo in dettaglio.`;
|
|
|
|
toolUpdate(onUpdate, "agy_analyze: analisi file...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "analyze",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.filePath],
|
|
yolo: p.yolo,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { filePath: p.filePath, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_transcribe — trascrizione audio (via Gemini API diretta)
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_transcribe",
|
|
label: "agy transcribe audio",
|
|
description:
|
|
"Trascrive il contenuto di un file audio (voce, discorso) in testo usando la Gemini API diretta " +
|
|
"(veloce e affidabile; agy CLI non supporta audio). Richiede la key Gemini in ~/.agy-chat/gemini-key o GEMINI_API_KEY.",
|
|
parameters: Type.Object({
|
|
filePath: Type.String({ description: "Percorso del file audio (wav, mp3, m4a, ecc.)." }),
|
|
language: Type.Optional(Type.String({ description: "Lingua del contenuto (es. 'italiano', 'english')." })),
|
|
model: Type.Optional(Type.String({ description: "Modello Gemini (default: gemini-3.5-flash)." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
toolUpdate(onUpdate, "agy_transcribe: trascrizione audio...");
|
|
const transcript = await transcribeAudio(p.filePath);
|
|
if (!transcript) {
|
|
return {
|
|
content: [{ type: "text", text: "Trascrizione fallita: key Gemini mancante o errore API." }],
|
|
details: { filePath: p.filePath },
|
|
isError: true,
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: transcript }],
|
|
details: { filePath: p.filePath, model: p.model ?? "gemini-3.5-flash" },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_video — analisi video
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_video",
|
|
label: "agy analyze video",
|
|
description:
|
|
"Analizza un file video: descrive scene, contenuto, codec, risoluzione, tracce audio. " +
|
|
"Richiede --yolo (auto-approva gli strumenti).",
|
|
parameters: Type.Object({
|
|
filePath: Type.String({ description: "Percorso del file video (mp4, mov, ecc.)." }),
|
|
question: Type.Optional(Type.String({ description: "Domanda specifica sul video." })),
|
|
model: Type.Optional(Type.String({ description: "Modello agy." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
const prompt = p.question
|
|
? `Analizza il video al percorso ${p.filePath}. ${p.question}`
|
|
: `Analizza il video al percorso ${p.filePath}: descrivi cosa mostra, codec, risoluzione e se contiene audio.`;
|
|
|
|
toolUpdate(onUpdate, "agy_video: analisi video...");
|
|
const res = await executeAgy({
|
|
prompt,
|
|
mode: "analyze",
|
|
model: p.model,
|
|
stateless: true,
|
|
filePaths: [p.filePath],
|
|
yolo: true,
|
|
signal,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: res.text }],
|
|
details: { filePath: p.filePath, exitCode: res.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_tts — text-to-speech via Gemini API
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_tts",
|
|
label: "agy TTS (text to speech)",
|
|
description:
|
|
"Converte un testo in audio (TTS) usando la Gemini API (gemini-2.5-flash-preview-tts) e lo riproduce. " +
|
|
"Utile per notifiche vocali, sintesi parlate e aggiornamenti di stato. " +
|
|
"Richiede la key Gemini in ~/.agy-chat/gemini-key o GEMINI_API_KEY.",
|
|
parameters: Type.Object({
|
|
text: Type.String({ description: "Il testo da pronunciare." }),
|
|
play: Type.Optional(Type.Boolean({ description: "true per riprodurre l'audio (default: true)." })),
|
|
outputDir: Type.Optional(Type.String({ description: "Cartella dove salvare il file audio." })),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const p = params as any;
|
|
toolUpdate(onUpdate, "agy_tts: sintesi vocale...");
|
|
return ttsSpeak(p.text, p.outputDir).then((file) => {
|
|
if (!file) {
|
|
return {
|
|
content: [{ type: "text", text: "TTS fallito: key Gemini mancante o errore API." }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `Audio TTS generato: ${file}` }],
|
|
details: { audioFile: file },
|
|
};
|
|
});
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_models — elenca i modelli disponibili
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_models",
|
|
label: "agy list models",
|
|
description: "Elenca i modelli disponibili per agy (Gemini, Claude, ecc.).",
|
|
parameters: Type.Object({}),
|
|
async execute(toolCallId, params, signal) {
|
|
const r = await runAgy(["models"], 30_000, signal);
|
|
return {
|
|
content: [{ type: "text", text: r.output.trim() || r.error || "(nessun output)" }],
|
|
details: { exitCode: r.exitCode },
|
|
};
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// TOOL: agy_conversation — gestione stato conversazione
|
|
// =========================================================================
|
|
pi.registerTool({
|
|
name: "agy_conversation",
|
|
label: "agy conversation state",
|
|
description:
|
|
"Gestisce lo stato della conversazione agy: mostra l'ID corrente, elenca le conversazioni, o azzera lo stato.",
|
|
parameters: Type.Object({
|
|
action: Type.Union(
|
|
[Type.Literal("id"), Type.Literal("list"), Type.Literal("reset")],
|
|
{ description: "id | list | reset" },
|
|
),
|
|
}),
|
|
async execute(toolCallId, params) {
|
|
const action = (params as { action: string }).action;
|
|
if (action === "id") {
|
|
return { content: [{ type: "text", text: readState() ?? "(nessuna conversazione attiva)" }], details: {} };
|
|
}
|
|
if (action === "reset") {
|
|
resetState();
|
|
return { content: [{ type: "text", text: "Stato conversazione azzerato." }], details: {} };
|
|
}
|
|
try {
|
|
if (!fs.existsSync(CONV_DIR)) {
|
|
return { content: [{ type: "text", text: "(nessuna conversazione trovata)" }], details: {} };
|
|
}
|
|
const files = fs
|
|
.readdirSync(CONV_DIR)
|
|
.filter((f) => f.endsWith(".db"))
|
|
.map((f) => path.join(CONV_DIR, f))
|
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
|
.slice(0, 15);
|
|
const lines = files.map((f) => {
|
|
const id = path.basename(f, ".db");
|
|
const mtime = new Date(fs.statSync(f).mtimeMs).toISOString().replace("T", " ").slice(0, 19);
|
|
return `${id}\t${mtime}`;
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: lines.join("\n") || "(nessuna conversazione trovata)" }],
|
|
details: {},
|
|
};
|
|
} catch (e: any) {
|
|
return { content: [{ type: "text", text: `Errore: ${e.message}` }], details: {}, isError: true };
|
|
}
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// Comandi interattivi
|
|
// =========================================================================
|
|
pi.registerCommand("agy", {
|
|
description: "Invia un prompt a agy (subagent Antigravity). Uso: /agy <prompt>",
|
|
handler: async (args, ctx) => {
|
|
if (!args?.trim()) {
|
|
ctx.ui.notify("Uso: /agy <prompt>", "error");
|
|
return;
|
|
}
|
|
ctx.ui.setStatus("agy", "agy: elaborazione...");
|
|
const res = await executeAgy({ prompt: args.trim() });
|
|
ctx.ui.setStatus("agy", "");
|
|
ctx.ui.notify(res.text || "(nessun output)", "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("agy:new", {
|
|
description: "Forza una nuova conversazione agy",
|
|
handler: async (_args, ctx) => {
|
|
resetState();
|
|
ctx.ui.notify("Nuova conversazione agy pronta.", "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("agy:list", {
|
|
description: "Elenca le conversazioni agy",
|
|
handler: async (_args, ctx) => {
|
|
const id = readState();
|
|
ctx.ui.notify(`Conversazione corrente: ${id ?? "(nessuna)"}`, "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("agy:reset", {
|
|
description: "Azzera lo stato conversazione agy",
|
|
handler: async (_args, ctx) => {
|
|
resetState();
|
|
ctx.ui.notify("Stato conversazione azzerato.", "info");
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// Registrazione microfono (F12) + trascrizione via Gemini + prompt su pi
|
|
// =========================================================================
|
|
async function handleRecordToggle(ctx: any) {
|
|
if (!recording) {
|
|
startRecording();
|
|
ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, max 2 min)", "info");
|
|
ctx.ui.setStatus("agy-rec", "🔴 REGISTRAZIONE...");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.setStatus("agy-rec", "⏹️ Finalizzazione...");
|
|
const file = await stopRecording();
|
|
if (!file) {
|
|
ctx.ui.setStatus("agy-rec", "");
|
|
ctx.ui.notify("Nessuna registrazione attiva", "warning");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify("Registrazione fermata, ottimizzazione audio...", "info");
|
|
const optimized = await optimizeAudio(file);
|
|
|
|
ctx.ui.notify("Trascrizione in corso...", "info");
|
|
const transcript = await transcribeAudio(optimized);
|
|
if (!transcript) {
|
|
ctx.ui.setStatus("agy-rec", "");
|
|
ctx.ui.notify("Trascrizione vuota o errore (key Gemini mancante?)", "error");
|
|
return;
|
|
}
|
|
|
|
// Interpreta con Gemini (testo) usando il contesto della conversazione
|
|
ctx.ui.notify("Interpretazione con Gemini...", "info");
|
|
const context = getConversationContext(ctx);
|
|
const res = await executeAgy({
|
|
prompt:
|
|
`Ecco la trascrizione di un messaggio vocale dell'utente:\n\n${transcript}` +
|
|
`\n\nContesto della conversazione:\n${context || "(nessuno)"}` +
|
|
`\n\nRestituisci la trascrizione corretta e una breve interpretazione/risposta.`,
|
|
stateless: true,
|
|
model: "Gemini 3.6 Flash (Medium)",
|
|
yolo: true,
|
|
});
|
|
ctx.ui.setStatus("agy-rec", "");
|
|
|
|
const finalText = res.text.trim() || transcript;
|
|
|
|
// Inserisci il risultato come prompt su pi
|
|
if (ctx.isIdle()) {
|
|
pi.sendUserMessage(finalText);
|
|
} else {
|
|
pi.sendUserMessage(finalText, { deliverAs: "followUp" });
|
|
}
|
|
ctx.ui.notify("✅ Trascrizione inviata come prompt a pi", "info");
|
|
|
|
// notifica vocale (config ttsNotify o AGY_TTS_NOTIFY=0 per disattivare)
|
|
const ttsNotify = getConfig("ttsNotify") ?? "true";
|
|
if (ttsNotify !== "false" && process.env.AGY_TTS_NOTIFY !== "0") {
|
|
ttsSpeak("Trascrizione completata e inviata.").catch(() => {});
|
|
}
|
|
}
|
|
|
|
pi.registerShortcut("f12", {
|
|
description: "Avvia/ferma registrazione microfono (max 2 min) e trascrive via Gemini",
|
|
handler: async (ctx) => {
|
|
await handleRecordToggle(ctx);
|
|
},
|
|
});
|
|
|
|
pi.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:speak", {
|
|
description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak <testo>",
|
|
handler: async (args, ctx) => {
|
|
if (!args?.trim()) {
|
|
ctx.ui.notify("Uso: /agy:speak <testo>", "error");
|
|
return;
|
|
}
|
|
ctx.ui.setStatus("agy-tts", "🔊 sintesi vocale...");
|
|
const file = await ttsSpeak(args.trim());
|
|
ctx.ui.setStatus("agy-tts", "");
|
|
if (file) ctx.ui.notify(`🔊 Audio: ${file}`, "info");
|
|
else ctx.ui.notify("TTS fallito (key Gemini mancante?)", "error");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("agy:vocal", {
|
|
description: "Attiva/disattiva il feedback vocale TTS. Uso: /agy:vocal [on|off|status]",
|
|
handler: async (args, ctx) => {
|
|
const arg = (args ?? "").trim().toLowerCase();
|
|
const current = getConfig("ttsNotify") ?? "true";
|
|
|
|
if (arg === "on") {
|
|
setConfig("ttsNotify", "true");
|
|
ctx.ui.notify("🔊 Feedback vocale ATTIVATO", "info");
|
|
ttsSpeak("Feedback vocale attivato.").catch(() => {});
|
|
return;
|
|
}
|
|
if (arg === "off") {
|
|
setConfig("ttsNotify", "false");
|
|
ctx.ui.notify("🔇 Feedback vocale DISATTIVATO", "info");
|
|
return;
|
|
}
|
|
if (arg === "status" || !arg) {
|
|
const on = current !== "false";
|
|
ctx.ui.notify(on ? "🔊 Feedback vocale: ATTIVO" : "🔇 Feedback vocale: DISATTIVO", "info");
|
|
return;
|
|
}
|
|
|
|
// toggle
|
|
if (current === "false") {
|
|
setConfig("ttsNotify", "true");
|
|
ctx.ui.notify("🔊 Feedback vocale ATTIVATO", "info");
|
|
ttsSpeak("Feedback vocale attivato.").catch(() => {});
|
|
} else {
|
|
setConfig("ttsNotify", "false");
|
|
ctx.ui.notify("🔇 Feedback vocale DISATTIVATO", "info");
|
|
}
|
|
},
|
|
});
|
|
|
|
// =========================================================================
|
|
// Comando: /agy:config — gestione configurazione persistente
|
|
// =========================================================================
|
|
const CONFIG_KEYS: { key: keyof AgyConfig; desc: string }[] = [
|
|
{ key: "geminiApiKey", desc: "Chiave API Google Gemini (STT/TTS)" },
|
|
{ key: "enne2ApiKey", desc: "Token per il server proxy ai.enne2.net (opzionale)" },
|
|
{ key: "sttBackend", desc: "Backend trascrizione: gemini | enne2" },
|
|
{ key: "sttUrl", desc: "URL base backend enne2" },
|
|
{ key: "sttModel", desc: "Modello STT backend enne2" },
|
|
{ key: "sttMaxDuration", desc: "Durata max registrazione (secondi)" },
|
|
{ key: "ttsBackend", desc: "Backend TTS: gemini | enne2" },
|
|
{ key: "ttsNotify", desc: "Notifiche vocali automatiche: true | false" },
|
|
{ key: "ttsModel", desc: "Modello TTS Gemini" },
|
|
{ key: "agyBin", desc: "Path del binario agy" },
|
|
{ key: "agyDefaultModel", desc: "Modello predefinito per le chiamate agy" },
|
|
{ key: "agyTimeoutMs", desc: "Timeout esecuzione agy (ms)" },
|
|
];
|
|
|
|
pi.registerCommand("agy:config", {
|
|
description:
|
|
"Gestisce la configurazione dell'estensione. Uso: /agy:config [get|set|reset] [chiave] [valore]",
|
|
handler: async (args, ctx) => {
|
|
const parts = (args ?? "").trim().split(/\s+/);
|
|
const action = parts[0] ?? "";
|
|
const key = parts[1] as keyof AgyConfig | undefined;
|
|
const value = parts.slice(2).join(" ");
|
|
|
|
// /agy:config — elenca tutto
|
|
if (!action) {
|
|
const cfg = loadConfig();
|
|
const lines = CONFIG_KEYS.map(({ key: k, desc }) => {
|
|
const v = (cfg as any)[k];
|
|
const masked =
|
|
k === "geminiApiKey" || k === "enne2ApiKey"
|
|
? v
|
|
? `${String(v).slice(0, 4)}...${String(v).slice(-4)}`
|
|
: "(non impostata)"
|
|
: v ?? "(non impostata)";
|
|
return `${k} = ${masked} — ${desc}`;
|
|
});
|
|
ctx.ui.notify(`Config agy-pi (${CONFIG_FILE}):\n${lines.join("\n")}`, "info");
|
|
return;
|
|
}
|
|
|
|
// /agy:config get <key>
|
|
if (action === "get" && key) {
|
|
const v = getConfig(key);
|
|
ctx.ui.notify(`${key} = ${v ?? "(non impostata)"}`, "info");
|
|
return;
|
|
}
|
|
|
|
// /agy:config set <key> <value>
|
|
if (action === "set" && key && value) {
|
|
if (!CONFIG_KEYS.some((c) => c.key === key)) {
|
|
ctx.ui.notify(`Chiave sconosciuta: ${key}`, "error");
|
|
return;
|
|
}
|
|
setConfig(key, value);
|
|
ctx.ui.notify(`✅ ${key} impostato. File: ${CONFIG_FILE}`, "info");
|
|
return;
|
|
}
|
|
|
|
// /agy:config reset
|
|
if (action === "reset") {
|
|
resetConfig();
|
|
ctx.ui.notify("Configurazione azzerata (default ripristinati).", "info");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify(
|
|
"Uso: /agy:config | /agy:config get <chiave> | /agy:config set <chiave> <valore> | /agy:config reset",
|
|
"warning",
|
|
);
|
|
},
|
|
});
|
|
}
|