feat(Opzione4): workflow vocale a 2 fasi con overlay TUI + vocalPlanningMode

- Dopo la trascrizione F12, agy genera un PIANO (direttiva plan-first: 'produci
  solo il piano, NON eseguire nulla, attendi conferma') invece di avviare un
  loop agentico autonomo.
- Con vocalPlanningMode=true (default) un overlay TUI (ctx.ui.custom) mostra
  trascrizione + piano proposto e attende la conferma dell'utente:
    Enter esegui | Esc annulla | E modifica | F12 registra di nuovo
- E entra in modalità modifica del testo del piano; F12 ri-avvia la registrazione.
- vocalPlanningMode=false ripristina l'esecuzione diretta (autonomia piena).
- Nuova chiave config vocalPlanningMode (bool).
- README aggiornato.
This commit is contained in:
2026-08-11 00:08:09 +02:00
parent d357bc58f5
commit db36e78555
2 changed files with 157 additions and 7 deletions
+130 -7
View File
@@ -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<VoicePlanDecision> {
const { Container, Text, matchesKey, Key } = await import("@earendil-works/pi-tui");
const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
return ctx.ui.custom<VoicePlanDecision>(
(tui, theme, _keybindings, done) => {
let text = plan;
let editing = false;
const container = new Container();
const render = () => {
container.clear();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new Text(theme.fg("accent", theme.bold("🎙️ Conferma vocale")), 1, 1),
);
container.addChild(new Text("", 0, 0));
container.addChild(new Text(theme.fg("dim", "Trascrizione:"), 1, 0));
container.addChild(new Text(theme.fg("text", transcript.slice(0, 250)), 1, 0));
container.addChild(new Text("", 0, 0));
container.addChild(new Text(theme.fg("accent", "📋 Piano proposto:"), 1, 0));
container.addChild(new Text(theme.fg("text", text), 1, 0));
container.addChild(new Text("", 0, 0));
container.addChild(
new Text(
theme.fg(
"dim",
editing
? "✏️ Modifica: digitando cambia il testo • Enter applica • Esc annulla"
: "Enter esegui • Esc annulla • E modifica • 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<TranscriptResult> {
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", {