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