commit f12199fd2e38caf41429770f10e8521ed7737bc3 Author: dev Date: Sun Aug 9 19:57:02 2026 +0200 agy-pi: estensione pi per Antigravity CLI (agy) subagent multimodale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..205d54c --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# agy-pi + +Estensione per **pi** che integra **Google Antigravity CLI (`agy`)** come subagent multimodale. + +Anche se il modello di pi è text-only, questa estensione permette a pi di delegare a agy +(che usa **Gemini multimodale**) task che richiedono immagini, audio e video, oltre a +conversazioni di ragionamento multi-turno. + +## Capacità + +| Capacità | Tool | Esempio | +|---|---|---| +| Conversazione multi-turno | `agy` | `agy(prompt="Analizza questo problema...")` | +| Generazione immagini | `agy` | `agy(prompt="Genera un'immagine di un gatto cyberpunk", mode="image")` | +| Analisi immagini | `agy` | `agy(prompt="Descrivi l'immagine", filePath="/path/foto.jpg")` | +| Analisi audio | `agy` | `agy(prompt="Trascrivi l'audio", filePath="/path/voce.wav", yolo=true)` | +| Analisi video | `agy` | `agy(prompt="Descrivi il video", filePath="/path/clip.mp4", yolo=true)` | +| Gestione stato conversazione | `agy_conversation` | `agy_conversation(action="id" \| "list" \| "reset")` | + +## Installazione + +Requisito: `agy` installato e autenticato (una volta: `agy`, poi login OAuth nel browser). + +```bash +# da un repo git +pi install git:github.com//agy-pi + +# oppure da una cartella locale +pi install /percorso/a/agy-pi +``` + +Per provare senza installare: + +```bash +pi -e /percorso/a/agy-pi +``` + +## Uso + +### Come tool (chiamato automaticamente dal modello) + +Quando chiedi a pi di generare un'immagine, analizzare un file, o ragionare insieme a un +secondo agente, pi può chiamare il tool `agy`. Esempi di prompt: + +``` +Genera un'immagine di un paesaggio marziano al tramonto. +Analizza l'immagine /home/utente/foto.jpg e descrivila. +Trascrivi il file audio /home/utente/voce.wav. +Chiedi a agy di ragionare su questo problema e poi confronta la sua risposta con la tua. +``` + +### Come comando interattivo + +``` +/agy # invia un prompt a agy +/agy:new # forza una nuova conversazione +/agy:list # mostra la conversazione corrente +/agy:reset # azzera lo stato conversazione +``` + +## Parametri del tool `agy` + +| Parametro | Tipo | Descrizione | +|---|---|---| +| `prompt` | string (obbligatorio) | Il task/prompt per agy | +| `mode` | `chat` \| `image` \| `analyze` | Tipo di operazione (default `chat`) | +| `model` | string | Modello agy (es. `Gemini 3.1 Pro (High)`, `Claude Opus 4.6 (Thinking)`) | +| `effort` | `low` \| `medium` \| `high` | Livello di ragionamento | +| `newConversation` | boolean | Forza una nuova conversazione | +| `addDir` | string | Cartella da aggiungere al workspace agy | +| `filePath` | string | File (immagine/audio/video) da analizzare | +| `yolo` | boolean | `--dangerously-skip-permissions` (necessario per audio/video) | + +## Multi-turno + +L'estensione mantiene l'ID della conversazione agy in `~/.agy-chat/conversation_id`. +Ogni chiamata a `agy` continua la conversazione precedente, così agy ricorda i turni +precedenti (ragionamento multi-shot). Usa `newConversation: true` per ripartire da zero. + +## Note di sicurezza + +- `yolo: true` auto-approva tutti gli strumenti di agy. Usalo solo in ambienti fidati. +- Le estensioni pi girano con i permessi completi del sistema. Rivedi il codice prima di + installare pacchetti di terze parti. + +## Struttura + +``` +agy-pi/ +├── package.json # manifest pi +├── extensions/index.ts # estensione (tool + comandi) +├── bin/agy-chat.sh # wrapper bash standalone (opzionale) +└── README.md +``` diff --git a/bin/agy-chat.sh b/bin/agy-chat.sh new file mode 100755 index 0000000..334b395 --- /dev/null +++ b/bin/agy-chat.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# ============================================================================= +# agy-chat.sh — pi <-> agy multi-turn conversation wrapper +# +# Permette a pi (agente principale) di conversare avanti e indietro con agy +# (subagent), mantenendo la memoria della conversazione tra i turni. +# +# USO: +# ./agy-chat.sh "prompt" # avvia o continua la conversazione +# ./agy-chat.sh --new "prompt" # forza una nuova conversazione +# ./agy-chat.sh --model "Gemini 3.1 Pro (High)" "prompt" +# ./agy-chat.sh --effort high "prompt" +# ./agy-chat.sh --id # stampa l'ID conversazione corrente +# ./agy-chat.sh --list # elenca le conversazioni recenti +# ./agy-chat.sh --resume "prompt" # riprende una conversazione specifica +# ./agy-chat.sh --reset # azzera lo stato (parte da zero) +# +# VARIABILI: +# AGY_CHAT_STATE cartella di stato (default ~/.agy-chat) +# AGY_BIN percorso del binario agy (default: agy su PATH) +# ============================================================================= +set -uo pipefail + +STATE_DIR="${AGY_CHAT_STATE:-$HOME/.agy-chat}" +STATE_FILE="$STATE_DIR/conversation_id" +DB="${AGY_CHAT_DB:-$HOME/.gemini/antigravity-cli/conversation_summaries.db}" +CONV_DIR="${AGY_CHAT_CONV_DIR:-$HOME/.gemini/antigravity-cli/conversations}" +AGY_BIN="${AGY_BIN:-agy}" +mkdir -p "$STATE_DIR" + +# --- helper: ultimo conversation_id (dal file .db più recente in conversations/) --- +latest_id() { + # il nome del file .db nella cartella conversations è l'ID conversazione + ls -t "$CONV_DIR"/*.db 2>/dev/null | head -1 | xargs -r -n1 basename 2>/dev/null | sed 's/\.db$//' +} + +# --- helper: elenca conversazioni recenti ------------------------------------ +list_convs() { + if [[ ! -d "$CONV_DIR" ]] || [[ -z "$(ls -A "$CONV_DIR"/*.db 2>/dev/null)" ]]; then + echo "(nessuna conversazione trovata)" + return 0 + fi + for f in $(ls -t "$CONV_DIR"/*.db 2>/dev/null); do + id="$(basename "$f" .db)" + mtime="$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)" + echo "$id\t$mtime" + done +} + +# --- parse argomenti ---------------------------------------------------------- +NEW=0 +MODEL="" +EFFORT="" +RESUME_ID="" +PROMPT="" +ADD_DIRS=() +YOLO=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --new) NEW=1; shift ;; + --reset) rm -f "$STATE_FILE"; echo "stato azzerato"; exit 0 ;; + --id) if [[ -f "$STATE_FILE" ]]; then cat "$STATE_FILE"; else echo "(nessuna conversazione attiva)"; fi; exit 0 ;; + --list) list_convs; exit 0 ;; + --model) MODEL="$2"; shift 2 ;; + --effort) EFFORT="$2"; shift 2 ;; + --resume) RESUME_ID="$2"; shift 2 ;; + --add-dir) ADD_DIRS+=("$2"); shift 2 ;; + --yolo) YOLO=1; shift ;; + *) PROMPT="$PROMPT $1"; shift ;; + esac +done +PROMPT="$(echo "$PROMPT" | sed 's/^ *//;s/ *$//')" + +if [[ -z "$PROMPT" ]]; then + echo "uso: ./agy-chat.sh [--new] [--model NOME] [--effort low|medium|high] \"prompt\"" >&2 + exit 1 +fi + +# --- determina conversation_id da usare --------------------------------------- +CONV_ID="" +if [[ $NEW -eq 0 && -z "$RESUME_ID" && -f "$STATE_FILE" ]]; then + CONV_ID="$(cat "$STATE_FILE")" +elif [[ -n "$RESUME_ID" ]]; then + CONV_ID="$RESUME_ID" +fi + +# --- costruisci comando ------------------------------------------------------- +CMD=("$AGY_BIN" -p "$PROMPT") +for d in "${ADD_DIRS[@]}"; do + CMD+=(--add-dir "$d") +done +if [[ -n "$CONV_ID" ]]; then + CMD+=(--conversation "$CONV_ID") +fi +if [[ -n "$MODEL" ]]; then + CMD+=(--model "$MODEL") +fi +if [[ -n "$EFFORT" ]]; then + CMD+=(--effort "$EFFORT") +fi +if [[ $YOLO -eq 1 ]]; then + CMD+=(--dangerously-skip-permissions) +fi + +echo ">>> agy (conversation: ${CONV_ID:-nuova})" >&2 +OUTPUT="$("${CMD[@]}" 2>&1)" +RC=$? + +# --- stampa output ------------------------------------------------------------ +if [[ -n "$OUTPUT" ]]; then + echo "$OUTPUT" +fi + +# --- aggiorna stato con l'ultimo conversation_id ------------------------------ +if [[ $RC -eq 0 ]]; then + NEW_ID="$(latest_id)" + if [[ -n "$NEW_ID" ]]; then + echo "$NEW_ID" > "$STATE_FILE" + fi +else + # se fallisce per mancato login, avvisa + if echo "$OUTPUT" | grep -qiE "sign in|signin|authenticate|login|Authentication required|visit the URL"; then + echo "" >&2 + echo "⚠️ agy NON è autenticato. Devi fare il login UNA volta:" >&2 + echo " 1) apri un terminale e lancia: agy" >&2 + echo " 2) visita l'URL stampato, fai login con l'account Antigravity" >&2 + echo " 3) incolla il codice di autorizzazione nel terminale" >&2 + echo " Poi torna qui e riprova il wrapper." >&2 + fi +fi + +exit $RC diff --git a/extensions/index.ts b/extensions/index.ts new file mode 100644 index 0000000..cbc33bf --- /dev/null +++ b/extensions/index.ts @@ -0,0 +1,386 @@ +/** + * 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 : 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 { + 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(); + 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 ", + handler: async (args, ctx) => { + if (!args?.trim()) { + ctx.ui.notify("Uso: /agy ", "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"); + }, + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5acb58b --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "agy-pi", + "version": "0.1.0", + "description": "Integra Google Antigravity CLI (agy) come subagent multimodale dentro pi: conversazioni multi-turno, generazione e analisi di immagini/audio/video.", + "keywords": ["pi-package", "agy", "antigravity", "gemini", "multimodal", "subagent"], + "license": "MIT", + "type": "module", + "pi": { + "extensions": ["./extensions"] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + } +}