/** * memorySummarizer.ts — Auto-summarization della memoria agente (S87 — GAP 4) * * Quando la memoria supera la soglia (400 entry), le 100 più vecchie vengono * compresse in un riassunto compatto via Gemini Flash, poi eliminate. * Il riassunto entra nella memoria come categoria "summary". * * Design: * - Un solo job alla volta (_summarizing flag) * - Fire-and-forget: non blocca mai il render path * - Graceful fallback: se Gemini fallisce -> LRU delete classica * - Safari-safe: fetch standard, no Worker * - Il riassunto e in italiano (stessa lingua del sistema) * * Modello: gemini-2.5-flash-lite (S325: era gemini-2.5-flash, lite = 15 RPM/1000 RPD gratuiti, piu veloce per compressione) */ import { getGeminiToken } from "@/lib/providerChain"; import type { StoredMemoryEntry } from "@/lib/vfsDb"; import { makeTimedSignal } from "@/lib/agentLoop/networkConstants"; // Loop-15: iOS-safe export const SUMMARIZE_THRESHOLD = 200; // S325: era 400 — riassume piu spesso, memoria piu compatta export const SUMMARIZE_BATCH = 100; // quante entry comprimere per volta // -- Gemini generateContent (non-streaming) ----------------------------------- const GEMINI_GEN_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"; // S325: era gemini-2.5-flash interface GeminiResponse { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; }>; } async function callGemini(prompt: string): Promise { const token = getGeminiToken(); if (!token) return null; try { const res = await fetch(`${GEMINI_GEN_URL}?key=${token}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { temperature: 0.1, maxOutputTokens: 900 }, }), signal: makeTimedSignal(15_000), }); if (!res.ok) return null; const data = await res.json() as GeminiResponse; return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null; } catch { return null; } } // -- Prompt builder ----------------------------------------------------------- function buildPrompt(entries: StoredMemoryEntry[]): string { const lines = entries .map(e => `[${e.category}] ${e.key}: ${e.value.slice(0, 300)}`) .join("\n"); return [ "Sei un assistente che comprime la memoria a lungo termine di un agente AI.", `Di seguito ci sono ${entries.length} ricordi da condensare in un riassunto compatto.`, "", "RICORDI DA COMPRIMERE:", lines, "", "REGOLE:", "- Scrivi un riassunto strutturato in bullet point (max 25 righe)", "- Mantieni SOLO le informazioni utili per future conversazioni (preferenze, fatti, nomi, date importanti)", "- Scarta dati meteo, news, prezzi (gia filtrati per staleness)", "- Usa italiano conciso", "- Inizia direttamente con i bullet, senza preambolo", "", "RIASSUNTO:", ].join("\n"); } // -- Main export -------------------------------------------------------------- export interface SummaryResult { summary: string; key: string; replaced: StoredMemoryEntry[]; } /** * Comprime le N entry piu vecchie in un riassunto via Gemini Flash. * Ritorna null se Gemini non e disponibile o fallisce (il caller fa LRU delete). */ export async function summarizeOldEntries( entries: StoredMemoryEntry[], batchSize = SUMMARIZE_BATCH, ): Promise { const candidates = entries .filter(e => e.category !== "summary") .sort((a, b) => a.updatedAt - b.updatedAt) .slice(0, batchSize); if (candidates.length < 10) return null; const prompt = buildPrompt(candidates); const summary = await callGemini(prompt); if (!summary || summary.length < 50) return null; const key = `summary:${Date.now()}`; return { summary, key, replaced: candidates }; } /** Stima quante token consuma un batch di entry (approssimata, 4 char = 1 token). */ export function estimateBatchTokens(entries: StoredMemoryEntry[]): number { return entries.reduce((acc, e) => acc + (e.key.length + e.value.length) / 4, 0); }