diff --git a/README.md b/README.md index 82b209b..c13268f 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ della richiesta utente, seguendo le best practice di context engineering: | `contextInject` | `true` | Iniezione contesto nel prompt agy | | `contextTokens` | `1500` | Token cap per il contesto iniettato | | `webSearch` | `auto` | Ricerca web Strada A: `auto` \| `on` \| `off` | +| `vocalPlanningMode` | `true` | Piano + conferma in overlay dopo il vocale | Le variabili d'ambiente (`GEMINI_API_KEY`, `AGY_STT_BACKEND`, ecc.) hanno priorità sul file di config quando impostate. @@ -122,6 +123,32 @@ Il flusso: registra → taglia il silenzio → **trascrive con la Gemini API dir interpreta con Gemini usando il contesto della conversazione → inserisce il risultato come prompt su pi. +Con `vocalPlanningMode=true` (default) il flusso è **a 2 fasi con overlay TUI**: +dopo la trascrizione, l'estensione genera un **piano** e mostra un popup di +conferma con la trascrizione e il piano proposto: + +``` +🎙️ Conferma vocale + Trascrizione: "Aggiorna i docs..." + 📋 Piano proposto: ... + Enter esegui • Esc annulla • E modifica • F12 registra di nuovo +``` + +- **Enter** → esegue il piano (invia il risultato a pi) +- **Esc** → annulla, nessuna azione +- **E** → modifica il testo del piano manualmente +- **F12** → registra di nuovo + +Questa modalità evita che l'interpretazione vocale avvii autonomamente loop +agentici o modifiche a sorpresa: la direttiva di Gemini è "produci solo il piano, +non eseguire nulla", e l'esecuzione parte solo dopo la tua conferma. Per tornare +all'esecuzione diretta, imposta `vocalPlanningMode=false` con +`/agy:config set vocalPlanningMode false`. + +Il testo scritto nel campo editor prima di avviare la registrazione viene letto +(`getEditorText`) e **combinato con la trascrizione audio** nel piano, poi +l'editor viene svuotato per evitare reinvii duplicati. + **Requisito**: la key Gemini API in `~/.agy-chat/gemini-key` (chmod 600) o nella variabile d'ambiente `GEMINI_API_KEY`. diff --git a/extensions/index.ts b/extensions/index.ts index 5060160..671f874 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -15,7 +15,7 @@ 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 { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; const execFileAsync = promisify(execFile); @@ -53,6 +53,7 @@ interface AgyConfig { contextInject?: boolean; // on|off contextTokens?: number; // token cap per il contesto iniettato webSearch?: string; // auto|on|off — Strada A: pi cerca + inietta + vocalPlanningMode?: boolean; // Opzione 4: piano + conferma prima di eseguire } const CONFIG_DEFAULTS: AgyConfig = { @@ -67,6 +68,7 @@ const CONFIG_DEFAULTS: AgyConfig = { contextInject: true, contextTokens: 1500, webSearch: "auto", + vocalPlanningMode: true, }; function loadConfig(): AgyConfig { @@ -97,7 +99,7 @@ function getConfig(key: keyof AgyConfig): string | undefined { function setConfig(key: keyof AgyConfig, value: string) { const cfg = loadConfig(); const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs", "contextTokens"]; - const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject"]; + const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject", "vocalPlanningMode"]; if (numKeys.includes(key)) { (cfg as any)[key] = Number(value); } else if (boolKeys.includes(key)) { @@ -980,6 +982,110 @@ function getConversationContext(ctx: any, maxEntries = 8): string { } } +// =========================================================================== +// Opzione 4 — Workflow vocale a 2 fasi: overlay TUI con trascrizione + piano +// e conferma utente prima dell'esecuzione (Enter esegui, Esc annulla, E modifica, +// F12 registra di nuovo). Riusa l'infrastruttura overlay di /agy:key e /agy:status. +// ========================================================================= +interface VoicePlanDecision { + action: "send" | "cancel" | "record"; + text: string; +} + +async function showVoicePlanOverlay( + ctx: ExtensionContext, + transcript: string, + plan: string, +): Promise { + const { Container, Text, matchesKey, Key } = await import("@earendil-works/pi-tui"); + const { DynamicBorder } = await import("@earendil-works/pi-coding-agent"); + + return ctx.ui.custom( + (tui, theme, _keybindings, done) => { + let text = plan; + let editing = false; + const container = new Container(); + + const render = () => { + container.clear(); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild( + new Text(theme.fg("accent", theme.bold("🎙️ Conferma vocale")), 1, 1), + ); + container.addChild(new Text("", 0, 0)); + container.addChild(new Text(theme.fg("dim", "Trascrizione:"), 1, 0)); + container.addChild(new Text(theme.fg("text", transcript.slice(0, 250)), 1, 0)); + container.addChild(new Text("", 0, 0)); + container.addChild(new Text(theme.fg("accent", "📋 Piano proposto:"), 1, 0)); + container.addChild(new Text(theme.fg("text", text), 1, 0)); + container.addChild(new Text("", 0, 0)); + container.addChild( + new Text( + theme.fg( + "dim", + editing + ? "✏️ Modifica: digitando cambia il testo • Enter applica • Esc annulla" + : "Enter esegui • Esc annulla • E modifica • F12 registra di nuovo", + ), + 1, + 0, + ), + ); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + }; + render(); + + return { + render: (w) => { + render(); + return container.render(w); + }, + invalidate: () => container.invalidate(), + handleInput: (data) => { + if (editing) { + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) { + editing = false; + tui.requestRender(); + return; + } + if (matchesKey(data, Key.backspace)) { + text = text.slice(0, -1); + tui.requestRender(); + return; + } + if (data.length === 1 && data.charCodeAt(0) >= 32) { + text += data; + tui.requestRender(); + } + return; + } + if (matchesKey(data, Key.enter)) { + done({ action: "send", text }); + return; + } + if (matchesKey(data, Key.escape)) { + done({ action: "cancel", text }); + return; + } + if (matchesKey(data, "e") || data === "E") { + editing = true; + tui.requestRender(); + return; + } + if (matchesKey(data, Key.f12)) { + done({ action: "record", text }); + return; + } + }, + }; + }, + { + overlay: true, + overlayOptions: { width: "60%", minWidth: 60, anchor: "center" }, + }, + ); +} + // Trascrizione via server locale ai.enne2.net (gemma4:E4B supporta audio) async function transcribeWithEnne2(file: string): Promise { const baseUrl = getConfig("sttUrl") ?? "https://ai.enne2.net"; @@ -1758,7 +1864,7 @@ export default function agyExtension(pi: ExtensionAPI) { // e lo combina con la trascrizione audio (comportamento "multimodale"). const editorText = (ctx.ui.getEditorText?.() ?? "").trim(); - // Interpreta con Gemini (testo) usando il contesto della conversazione + // Interpreta con Gemini (testo) — direttiva plan-first: solo piano, no esecuzione ctx.ui.notify("Interpretazione con Gemini...", "info"); const context = getConversationContext(ctx); const res = await executeAgy({ @@ -1768,17 +1874,17 @@ export default function agyExtension(pi: ExtensionAPI) { ? `\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 la trascrizione corretta e una breve interpretazione/risposta che integri sia la voce sia il testo scritto dell'utente.`, + `\n\nProduce SOLO: 1) la trascrizione corretta, 2) un piano d'azione sintetico.` + + `\nIMPORTANTE: NON eseguire alcuno strumento o azione. Attendi la conferma dell'utente.`, stateless: true, model: "Gemini 3.6 Flash (Medium)", yolo: true, }); ctx.ui.setStatus("agy-rec", ""); - const finalText = res.text.trim() || transcript; + let finalText = res.text.trim() || transcript; - // Il testo dell'editor è stato consumato nel messaggio vocale: lo svuota - // per evitare che venga reinviato due volte. + // Il testo dell'editor è stato consumato: lo svuota per evitare reinvii duplicati. if (editorText) { try { ctx.ui.setEditorText?.(""); @@ -1787,6 +1893,22 @@ export default function agyExtension(pi: ExtensionAPI) { } } + // Opzione 4: piano + conferma in overlay TUI prima di eseguire (se abilitata) + const planningMode = getConfig("vocalPlanningMode") ?? "true"; + if (planningMode !== "false") { + const decision = await showVoicePlanOverlay(ctx, transcript, finalText); + if (decision.action === "cancel") { + ctx.ui.notify("Vocale annullato — nessuna azione eseguita.", "info"); + playSound("cancel"); + return; + } + if (decision.action === "record") { + ctx.ui.notify("Registra di nuovo con F12.", "info"); + return; + } + finalText = decision.text; + } + // Inserisci il risultato come prompt su pi if (ctx.isIdle()) { pi.sendUserMessage(finalText); @@ -1935,6 +2057,7 @@ export default function agyExtension(pi: ExtensionAPI) { { key: "contextInject", desc: "Iniezione contesto nel prompt agy: true | false" }, { key: "contextTokens", desc: "Token cap per il contesto iniettato (default 1500)" }, { key: "webSearch", desc: "Ricerca web Strada A: auto | on | off" }, + { key: "vocalPlanningMode", desc: "Opzione 4: piano+conferma dopo il vocale (true|false)" }, ]; pi.registerCommand("agy:config", {