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 |
|
| `contextInject` | `true` | Iniezione contesto nel prompt agy |
|
||||||
| `contextTokens` | `1500` | Token cap per il contesto iniettato |
|
| `contextTokens` | `1500` | Token cap per il contesto iniettato |
|
||||||
| `webSearch` | `auto` | Ricerca web Strada A: `auto` \| `on` \| `off` |
|
| `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
|
Le variabili d'ambiente (`GEMINI_API_KEY`, `AGY_STT_BACKEND`, ecc.) hanno
|
||||||
priorità sul file di config quando impostate.
|
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
|
interpreta con Gemini usando il contesto della conversazione → inserisce il
|
||||||
risultato come prompt su pi.
|
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
|
**Requisito**: la key Gemini API in `~/.agy-chat/gemini-key` (chmod 600) o nella
|
||||||
variabile d'ambiente `GEMINI_API_KEY`.
|
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 os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { promisify } from "node:util";
|
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";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -53,6 +53,7 @@ interface AgyConfig {
|
|||||||
contextInject?: boolean; // on|off
|
contextInject?: boolean; // on|off
|
||||||
contextTokens?: number; // token cap per il contesto iniettato
|
contextTokens?: number; // token cap per il contesto iniettato
|
||||||
webSearch?: string; // auto|on|off — Strada A: pi cerca + inietta
|
webSearch?: string; // auto|on|off — Strada A: pi cerca + inietta
|
||||||
|
vocalPlanningMode?: boolean; // Opzione 4: piano + conferma prima di eseguire
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_DEFAULTS: AgyConfig = {
|
const CONFIG_DEFAULTS: AgyConfig = {
|
||||||
@@ -67,6 +68,7 @@ const CONFIG_DEFAULTS: AgyConfig = {
|
|||||||
contextInject: true,
|
contextInject: true,
|
||||||
contextTokens: 1500,
|
contextTokens: 1500,
|
||||||
webSearch: "auto",
|
webSearch: "auto",
|
||||||
|
vocalPlanningMode: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadConfig(): AgyConfig {
|
function loadConfig(): AgyConfig {
|
||||||
@@ -97,7 +99,7 @@ function getConfig(key: keyof AgyConfig): string | undefined {
|
|||||||
function setConfig(key: keyof AgyConfig, value: string) {
|
function setConfig(key: keyof AgyConfig, value: string) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs", "contextTokens"];
|
const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs", "contextTokens"];
|
||||||
const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject"];
|
const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject", "vocalPlanningMode"];
|
||||||
if (numKeys.includes(key)) {
|
if (numKeys.includes(key)) {
|
||||||
(cfg as any)[key] = Number(value);
|
(cfg as any)[key] = Number(value);
|
||||||
} else if (boolKeys.includes(key)) {
|
} 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)
|
// Trascrizione via server locale ai.enne2.net (gemma4:E4B supporta audio)
|
||||||
async function transcribeWithEnne2(file: string): Promise<TranscriptResult> {
|
async function transcribeWithEnne2(file: string): Promise<TranscriptResult> {
|
||||||
const baseUrl = getConfig("sttUrl") ?? "https://ai.enne2.net";
|
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").
|
// e lo combina con la trascrizione audio (comportamento "multimodale").
|
||||||
const editorText = (ctx.ui.getEditorText?.() ?? "").trim();
|
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");
|
ctx.ui.notify("Interpretazione con Gemini...", "info");
|
||||||
const context = getConversationContext(ctx);
|
const context = getConversationContext(ctx);
|
||||||
const res = await executeAgy({
|
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\nTesto scritto dall'utente nel campo di input (da combinare con la voce):\n${editorText}`
|
||||||
: "") +
|
: "") +
|
||||||
`\n\nContesto della conversazione:\n${context || "(nessuno)"}` +
|
`\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,
|
stateless: true,
|
||||||
model: "Gemini 3.6 Flash (Medium)",
|
model: "Gemini 3.6 Flash (Medium)",
|
||||||
yolo: true,
|
yolo: true,
|
||||||
});
|
});
|
||||||
ctx.ui.setStatus("agy-rec", "");
|
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
|
// Il testo dell'editor è stato consumato: lo svuota per evitare reinvii duplicati.
|
||||||
// per evitare che venga reinviato due volte.
|
|
||||||
if (editorText) {
|
if (editorText) {
|
||||||
try {
|
try {
|
||||||
ctx.ui.setEditorText?.("");
|
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
|
// Inserisci il risultato come prompt su pi
|
||||||
if (ctx.isIdle()) {
|
if (ctx.isIdle()) {
|
||||||
pi.sendUserMessage(finalText);
|
pi.sendUserMessage(finalText);
|
||||||
@@ -1935,6 +2057,7 @@ export default function agyExtension(pi: ExtensionAPI) {
|
|||||||
{ key: "contextInject", desc: "Iniezione contesto nel prompt agy: true | false" },
|
{ key: "contextInject", desc: "Iniezione contesto nel prompt agy: true | false" },
|
||||||
{ key: "contextTokens", desc: "Token cap per il contesto iniettato (default 1500)" },
|
{ key: "contextTokens", desc: "Token cap per il contesto iniettato (default 1500)" },
|
||||||
{ key: "webSearch", desc: "Ricerca web Strada A: auto | on | off" },
|
{ 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", {
|
pi.registerCommand("agy:config", {
|
||||||
|
|||||||
Reference in New Issue
Block a user