diff --git a/README.md b/README.md index fcfe953..82b209b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ Il comando `/agy:status` apre un overlay TUI con lo stato dell'estensione: Chiudi con `Enter` o `Esc`. +### Context Injection (Fase 2) — `pi → agy` + +Quando il tool `agy` (o altri con l'opzione `injectContext`) invia un prompt a +Gemini, l'estensione **inietta automaticamente** un blocco di contesto prima +della richiesta utente, seguendo le best practice di context engineering: + +``` + ← cwd, OS, data/ora, git branch + file modificati + ← ultimi messaggi utente pi (compatti, non il transcript) + ← archivio fatti chiave (memory.json, auto-aggiornato) + ← risultati ricerca web (Strada A, se pertinente) +-------------------------------- +[ RICHIESTA UTENTE ] ← sempre per ultima (anti lost-in-the-middle) +``` + +- **Tag XML** e richiesta per ultima (evidenze “Lost in the Middle”, TACL 2024) +- **Token cap** default ~1500 (chiave `contextTokens`) +- **Memoria durevole** automatica: oltre una soglia di turni, i punti chiave + vengono condensati in `~/.config/agy-pi/memory.json` invece di rigirare tutto +- **Ricerca web** (Strada A): con `webSearch=auto|on` l'estensione cerca con + l'API Gemini (`googleSearch` grounding) e inietta i risultati nel `` + | Chiave | Default | Descrizione | |---|---|---| | `geminiApiKey` | — | Chiave API Google Gemini (STT/TTS) | @@ -85,6 +107,9 @@ Chiudi con `Enter` o `Esc`. | `agyBin` | `agy` | Path del binario agy | | `agyDefaultModel` | — | Modello predefinito per le chiamate agy | | `agyTimeoutMs` | `180000` | Timeout esecuzione agy (ms) | +| `contextInject` | `true` | Iniezione contesto nel prompt agy | +| `contextTokens` | `1500` | Token cap per il contesto iniettato | +| `webSearch` | `auto` | Ricerca web Strada A: `auto` \| `on` \| `off` | Le variabili d'ambiente (`GEMINI_API_KEY`, `AGY_STT_BACKEND`, ecc.) hanno priorità sul file di config quando impostate. diff --git a/extensions/index.ts b/extensions/index.ts index 9158258..12971d0 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -49,6 +49,10 @@ interface AgyConfig { agyBin?: string; agyDefaultModel?: string; agyTimeoutMs?: number; + // Fase 2 — Context Injection + contextInject?: boolean; // on|off + contextTokens?: number; // token cap per il contesto iniettato + webSearch?: string; // auto|on|off — Strada A: pi cerca + inietta } const CONFIG_DEFAULTS: AgyConfig = { @@ -60,6 +64,9 @@ const CONFIG_DEFAULTS: AgyConfig = { ttsNotify: true, ttsModel: "gemini-2.5-flash-preview-tts", agyTimeoutMs: 180_000, + contextInject: true, + contextTokens: 1500, + webSearch: "auto", }; function loadConfig(): AgyConfig { @@ -89,8 +96,8 @@ function getConfig(key: keyof AgyConfig): string | undefined { function setConfig(key: keyof AgyConfig, value: string) { const cfg = loadConfig(); - const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs"]; - const boolKeys: (keyof AgyConfig)[] = ["ttsNotify"]; + const numKeys: (keyof AgyConfig)[] = ["sttMaxDuration", "agyTimeoutMs", "contextTokens"]; + const boolKeys: (keyof AgyConfig)[] = ["ttsNotify", "contextInject"]; if (numKeys.includes(key)) { (cfg as any)[key] = Number(value); } else if (boolKeys.includes(key)) { @@ -256,10 +263,270 @@ interface AgyExecOptions { timeoutMs?: number; outputDir?: string; signal?: AbortSignal; + contextBlock?: string; // Fase 2: contesto iniettato prima della richiesta +} + +// =========================================================================== +// Fase 2 — Context Injection (pi → agy) +// Best practice: tag XML, richiesta per ultima (anti lost-in-the-middle), +// prefisso stabile/cache, token cap, memoria durevole automatica, niente segreti. +// ========================================================================= +const MEMORY_FILE = path.join(CONFIG_DIR, "memory.json"); + +interface DurableMemory { + goal?: string; + decisions: string[]; + conventions: string[]; + openQuestions: string[]; + updatedAt: string; +} + +function loadMemory(): DurableMemory { + try { + const raw = JSON.parse(fs.readFileSync(MEMORY_FILE, "utf8")); + return { + decisions: Array.isArray(raw.decisions) ? raw.decisions : [], + conventions: Array.isArray(raw.conventions) ? raw.conventions : [], + openQuestions: Array.isArray(raw.openQuestions) ? raw.openQuestions : [], + goal: typeof raw.goal === "string" ? raw.goal : undefined, + updatedAt: raw.updatedAt ?? "", + }; + } catch { + return { decisions: [], conventions: [], openQuestions: [], updatedAt: "" }; + } +} + +function saveMemory(mem: DurableMemory) { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync( + MEMORY_FILE, + JSON.stringify({ ...mem, updatedAt: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); + } catch { + /* ignora */ + } +} + +// Auto-update: quando la conversazione pi cresce oltre la soglia, condensa i +// punti chiave recenti in durable facts invece di rigirare tutto il transcript. +function autoUpdateMemory(ctx: any, force = false) { + try { + const mem = loadMemory(); + const entries = ctx?.sessionManager?.getEntries?.() ?? []; + if (force || entries.length > 24) { + const userTexts: string[] = []; + for (const e of entries) { + if (e.type === "message" && e.message?.role === "user") { + const c = e.message.content; + const text = + typeof c === "string" + ? c + : Array.isArray(c) + ? c.map((b: any) => b.text ?? "").join(" ") + : ""; + if (text && text.length > 20) userTexts.push(text.slice(0, 160)); + } + } + if (!mem.goal && userTexts.length) mem.goal = userTexts[0]; + // ultima decisione/azione chiave (ultimo user prompt significativo) + if (userTexts.length > 1) { + const last = userTexts[userTexts.length - 1]; + const lastShort = last.length > 100 ? last.slice(0, 100) : last; + if (!mem.decisions.includes(lastShort)) { + mem.decisions.push(lastShort); + if (mem.decisions.length > 8) mem.decisions.shift(); + } + } + saveMemory(mem); + } + } catch { + /* ignora */ + } +} + +function getGitInfo(cwd: string): { branch: string; dirty: boolean; modified: string[] } { + try { + const branch = + execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf8", + }) + .trim() || "(nessun branch)"; + const porcelain = execFileSync("git", ["-C", cwd, "status", "--porcelain"], { + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); + return { + branch, + dirty: porcelain.length > 0, + modified: porcelain.slice(0, 8).map((l) => l.slice(0, 70)), + }; + } catch { + return { branch: "(no git)", dirty: false, modified: [] }; + } +} + +// Estrae lo stato recente della conversazione pi (ultimi N messaggi utente) +// e lo compatta — NON il transcript grezzo (lost-in-the-middle, token bloat). +function extractSessionState(ctx: any): string { + try { + const entries = ctx?.sessionManager?.getEntries?.() ?? []; + const userTexts: string[] = []; + for (let i = entries.length - 1; i >= 0 && userTexts.length < 3; i--) { + const e = entries[i]; + if (e.type === "message" && e.message?.role === "user") { + const c = e.message.content; + const text = + typeof c === "string" + ? c + : Array.isArray(c) + ? c.map((b: any) => b.text ?? "").join(" ") + : ""; + if (text) userTexts.push(text.replace(/\s+/g, " ").trim().slice(0, 200)); + } + } + return userTexts.reverse().join("\n"); + } catch { + return ""; + } +} + +// Cerca sul web tramite Gemini API (googleSearch grounding) e restituisce i +// risultati iniettabili. È la Strada A: l'estensione cerca, poi inietta. +async function webSearchGemini(query: string, signal?: AbortSignal): Promise { + let key = getConfig("geminiApiKey") ?? process.env.GEMINI_API_KEY ?? ""; + if (!key) { + try { + key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim(); + } catch { + /* ignora */ + } + } + if (!key) return ""; + const model = process.env.AGY_GEMINI_MODEL ?? "gemini-3.5-flash"; + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, + { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ googleSearch: {} }], + }), + }, + ); + if (!res.ok) return ""; + const data: any = await res.json(); + const text = data?.candidates?.[0]?.content?.parts?.map((p: any) => p.text ?? "").join(" ") ?? ""; + return text.trim().slice(0, 600); + } catch { + return ""; + } +} + +function needsWebSearch(prompt: string): boolean { + const mode = getConfig("webSearch") ?? "auto"; + if (mode === "off") return false; + if (mode === "on") return true; + // auto: domande informative/tecniche che beneficiano di dati aggiornati + return /cerca|ricerca|latest|ultim|aggiorna|versione|documentaz|news|novit|differenza|confronta|quanto |come funzion|quando|perché|cos'è|chi è|release|prezzo|alternativ/i.test( + prompt, + ); +} + +const estTokens = (s: string) => Math.ceil(s.length / 4); +function truncToTokens(s: string, limit: number): string { + if (estTokens(s) <= limit) return s; + return s.slice(0, limit * 4); +} + +// Assemblea del blocco di contesto con tag XML e token cap (~1500). +// La richiesta utente va SEMPRE dopo (anti lost-in-the-middle). +async function buildContextBlock( + ctx: any, + prompt: string, + signal?: AbortSignal, +): Promise { + if (getConfig("contextInject") === "off") return ""; + const budget = Number(getConfig("contextTokens") ?? 1500) || 1500; + const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + const git = getGitInfo(cwd); + const now = new Date().toISOString(); + const os = `${process.platform} ${process.arch}`; + + const parts: { label: string; xml: string; body: string }[] = []; + + // --- --- + const envBody = [ + `cwd: ${cwd}`, + `os: ${os}`, + `time: ${now}`, + `git_branch: ${git.branch}${git.dirty ? " (dirty)" : ""}`, + git.modified.length ? `modified: ${git.modified.join(" | ")}` : "", + ] + .filter(Boolean) + .join("\n"); + parts.push({ label: "env", xml: "environment_snapshot", body: envBody }); + + // --- --- + const sessionState = extractSessionState(ctx); + if (sessionState) { + parts.push({ label: "session", xml: "session_state", body: sessionState }); + } + + // --- --- + const mem = loadMemory(); + const memLines = [ + mem.goal ? `goal: ${mem.goal.slice(0, 120)}` : "", + mem.decisions.length ? `decisions: ${mem.decisions.join(" | ")}` : "", + mem.conventions.length ? `conventions: ${mem.conventions.join(" | ")}` : "", + mem.openQuestions.length ? `open_questions: ${mem.openQuestions.join(" | ")}` : "", + ].filter(Boolean); + if (memLines.length) { + parts.push({ label: "memory", xml: "durable_memory", body: memLines.join("\n") }); + } + + // --- (Strada A) --- + if (needsWebSearch(prompt)) { + const web = await webSearchGemini(prompt.slice(0, 300), signal); + if (web) parts.push({ label: "web", xml: "web_context", body: web }); + } + + // Token cap: budget totale ~1500. Assegna in modo proporzionale. + const blocks: string[] = []; + let used = 0; + const envBudget = Math.min(300, budget); + const sessionBudget = Math.min(500, budget); + const memBudget = Math.min(400, budget); + const webBudget = Math.min(300, budget); + const budgets: Record = { + env: envBudget, + session: sessionBudget, + memory: memBudget, + web: webBudget, + }; + for (const p of parts) { + const cap = budgets[p.label] ?? 200; + if (used >= budget) break; + const body = truncToTokens(p.body, cap); + if (!body) continue; + blocks.push(`<${p.xml}> +${body} +`); + used += estTokens(body) + 6; + } + if (!blocks.length) return ""; + return `[CONTEXT_AGENTE] Contesto ambiente e stato corrente per la risposta. Usa solo se pertinente; la richiesta dell'utente è sotto. +${blocks.join("\n\n")}`; } async function executeAgy(opts: AgyExecOptions) { - const args: string[] = ["-p", opts.prompt]; + const finalPrompt = opts.contextBlock ? `${opts.contextBlock}\n\n${opts.prompt}` : opts.prompt; + const args: string[] = ["-p", finalPrompt]; // add-dir per i file const dirs = new Set(); @@ -845,10 +1112,22 @@ export default function agyExtension(pi: ExtensionAPI) { addDir: Type.Optional(Type.String({ description: "Cartella da aggiungere al workspace agy." })), filePath: Type.Optional(Type.String({ description: "File (immagine/audio/video) da analizzare." })), yolo: Type.Optional(Type.Boolean({ description: "true per --dangerously-skip-permissions." })), + injectContext: Type.Optional( + Type.Boolean({ description: "Inietta contesto ambiente/sessione/web nel prompt (default true)." }), + ), + webSearch: Type.Optional( + Type.Boolean({ description: "Forza/non forzare la ricerca web (override di webSearch config)." }), + ), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const p = params as any; toolUpdate(onUpdate, `agy: ${p.mode === "image" ? "generazione immagine" : "elaborazione"}...`); + // Fase 2: costruzione contesto iniettato (ambiente + sessione + memoria + web) + const wantInject = p.injectContext ?? true; + const prevWs = getConfig("webSearch"); + if (typeof p.webSearch === "boolean") setConfig("webSearch", p.webSearch ? "on" : "off"); + const contextBlock = wantInject ? await buildContextBlock(ctx, p.prompt, signal) : ""; + if (typeof p.webSearch === "boolean" && prevWs) setConfig("webSearch", prevWs); const res = await executeAgy({ prompt: p.prompt, mode: p.mode, @@ -859,6 +1138,7 @@ export default function agyExtension(pi: ExtensionAPI) { filePaths: p.filePath ? [p.filePath] : [], yolo: p.yolo, signal, + contextBlock, }); return { content: [{ type: "text", text: res.text }], @@ -1591,6 +1871,9 @@ export default function agyExtension(pi: ExtensionAPI) { { key: "agyBin", desc: "Path del binario agy" }, { key: "agyDefaultModel", desc: "Modello predefinito per le chiamate agy" }, { key: "agyTimeoutMs", desc: "Timeout esecuzione agy (ms)" }, + { 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" }, ]; pi.registerCommand("agy:config", {