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:
@@ -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`.
|
||||
|
||||
|
||||
+130
-7
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user