/** * agentMemory.ts — Agent Memory Store (Sessione 15: localStorage → Dexie) * * Strategia: * - In-memory cache (_cache) come layer sincrono per i caller esistenti * - Dexie (agentMemoryDb) come persistence primaria (write-behind, async) * - localStorage come fallback read-once al boot → poi rimosso (migrazione one-shot) * - Backend sync (fire-and-forget) invariato * * API pubblica invariata → zero regressioni nei caller. * * Invarianti: * - Mai chiamare localStorage nel render path React * - MAX_ENTRIES=500 LRU enforced su Dexie ogni 50 writes (non bloccante) * - GAP 4: auto-summarization via Gemini Flash quando entries > SUMMARIZE_THRESHOLD=400 * - initMemory() va chiamato da main.tsx prima del mount (async, una volta) */ import { agentMemoryDb, type StoredMemoryEntry } from "@/lib/vfsDb"; import { makeTimedSignal } from "./agentLoop/networkConstants"; // iOS-safe timeout import { getEmbedding } from "@/lib/agent/semanticEmbeddings"; import { syncMemoryToCloud, deleteMemoryFromCloud, clearMemoryFromCloud, hydrateMemoryFromCloud, subscribeToMemoryChanges, isMemoryCloudEnabled, } from "@/lib/memoryCloud"; import { summarizeOldEntries, SUMMARIZE_THRESHOLD, SUMMARIZE_BATCH, } from "@/lib/agent/memorySummarizer"; const LS_KEY = "agente-memory"; const MIGRATE_KEY = "agente-memory-migrated-v6"; const MAX_ENTRIES = 500; const PRUNE_EVERY = 50; // prune Dexie ogni N writes export type { StoredMemoryEntry as MemoryEntry }; type MemStore = Record; type Listener = () => void; // ─── In-memory cache ────────────────────────────────────────────────────────── let _cache: MemStore = {}; let _writeCount = 0; let _summarizing = false; // GAP 4: prevent concurrent summarization const _listeners = new Set(); // ─── Backend config ─────────────────────────────────────────────────────────── const BACKEND = (import.meta.env.VITE_BACKEND_URL ?? "").replace(/\/$/, ""); // ─── Notify listeners ───────────────────────────────────────────────────────── function _notify(): void { _listeners.forEach(fn => fn()); } // ─── Write-behind: persiste su Dexie (fire-and-forget) ─────────────────────── function _persist(entry: StoredMemoryEntry): void { agentMemoryDb.put(entry).catch(() => {}); _writeCount++; // LRU prune ogni PRUNE_EVERY writes (non bloccante) if (_writeCount % PRUNE_EVERY === 0) { _pruneLRU(); } } function _persistDelete(key: string): void { agentMemoryDb.delete(key).catch(() => {}); } /** GAP 4: prune via Gemini summarization (se sopra soglia) o LRU classica */ function _pruneLRU(): void { const entries = Object.values(_cache).sort((a, b) => a.updatedAt - b.updatedAt); if (entries.length <= MAX_ENTRIES) return; // GAP 4: se sopra soglia summarization e nessun job già in corso → comprimi if (entries.length > SUMMARIZE_THRESHOLD && !_summarizing) { _summarizing = true; summarizeOldEntries(entries, SUMMARIZE_BATCH) .then(result => { if (result) { // Elimina entry originali dalla cache e da Dexie for (const e of result.replaced) { delete _cache[e.key]; agentMemoryDb.delete(e.key).catch(() => {}); deleteMemoryFromCloud(e.key).catch(() => {}); } // Aggiungi il riassunto come nuova entry di memoria const summaryEntry: StoredMemoryEntry = { key: result.key, value: result.summary, category: "summary", createdAt: Date.now(), updatedAt: Date.now(), }; _cache[summaryEntry.key] = summaryEntry; agentMemoryDb.put(summaryEntry).catch(() => {}); syncMemoryToCloud(summaryEntry).catch(() => {}); _notify(); } else { // Gemini non disponibile → fallback LRU classica const toRemove = entries.slice(0, entries.length - MAX_ENTRIES); for (const e of toRemove) { delete _cache[e.key]; agentMemoryDb.delete(e.key).catch(() => {}); deleteMemoryFromCloud(e.key).catch(() => {}); } } }) .catch(() => { // Fallback LRU classica su errore const toRemove = entries.slice(0, entries.length - MAX_ENTRIES); for (const e of toRemove) { delete _cache[e.key]; agentMemoryDb.delete(e.key).catch(() => {}); } }) .finally(() => { _summarizing = false; }); agentMemoryDb.pruneToLimit(MAX_ENTRIES).catch(() => {}); return; } // Sotto soglia summarization → LRU classica const toRemove = entries.slice(0, entries.length - MAX_ENTRIES); for (const e of toRemove) { delete _cache[e.key]; agentMemoryDb.delete(e.key).catch(() => {}); } agentMemoryDb.pruneToLimit(MAX_ENTRIES).catch(() => {}); } // ─── Backend sync helpers ────────────────────────────────────────────────────── async function backendGet(key: string): Promise { if (!BACKEND) return null; try { const res = await fetch(`${BACKEND}/api/memory/agent/${encodeURIComponent(key)}`, { signal: makeTimedSignal(4000), }); if (!res.ok) return null; const data = await res.json() as { value?: string }; return data.value ?? null; } catch { return null; } } async function backendLoadAll(): Promise { if (!BACKEND) return []; try { const res = await fetch(`${BACKEND}/api/memory/agent`, { signal: makeTimedSignal(6000) }); if (!res.ok) return []; const data = await res.json() as { entries?: StoredMemoryEntry[] }; return data.entries ?? []; } catch { return []; } } // ─── Boot initialization (chiamato da main.tsx) ──────────────────────────────── /** * Carica la memoria da Dexie nella cache in-memory. * Se Dexie è vuoto + localStorage ha dati (prima volta) → migra one-shot. * NON blocca il render — chiamato in main.tsx con await prima del mount React. */ export async function initMemory(): Promise { try { // 1. Carica da Dexie const dexieEntries = await agentMemoryDb.getAll(); if (dexieEntries.length > 0) { // Dexie ha dati → usa quelli come source of truth for (const e of dexieEntries) { _cache[e.key] = e; } } else { // Dexie vuoto → controlla localStorage (migrazione one-shot) const alreadyMigrated = localStorage.getItem(MIGRATE_KEY); if (!alreadyMigrated) { try { const raw = localStorage.getItem(LS_KEY); if (raw) { const lsData = JSON.parse(raw) as MemStore; const entries = Object.values(lsData); if (entries.length > 0) { // Migra → Dexie await agentMemoryDb.putMany(entries); for (const e of entries) { _cache[e.key] = e; } } } } catch { /* non-fatal */ } // Marca migrazione completata e rimuovi da localStorage try { localStorage.setItem(MIGRATE_KEY, "1"); localStorage.removeItem(LS_KEY); } catch { /* non-fatal */ } } } } catch { // Fallback ultimo: leggi localStorage se Dexie non disponibile try { const raw = localStorage.getItem(LS_KEY); if (raw) { _cache = JSON.parse(raw) as MemStore; } } catch { /* non-fatal */ } } // GAP 3: hydrate from Supabase and merge (cloud vince se più recente) try { const cloudEntries = await hydrateMemoryFromCloud(); let merged = 0; for (const e of cloudEntries) { const local = _cache[e.key]; if (!local || e.updatedAt > local.updatedAt) { _cache[e.key] = e; agentMemoryDb.put(e).catch(() => {}); merged++; } } if (merged > 0) _notify(); } catch { /* non-fatal */ } // GAP 3: subscribe to Realtime changes from other devices subscribeToMemoryChanges({ onUpsert: (e) => { const local = _cache[e.key]; if (!local || e.updatedAt > local.updatedAt) { _cache[e.key] = e; agentMemoryDb.put(e).catch(() => {}); _notify(); } }, onDelete: (key) => { if (_cache[key]) { delete _cache[key]; agentMemoryDb.delete(key).catch(() => {}); _notify(); } }, }); // S140: backfill embedding per entry caricate da Dexie senza vettore. // Fire-and-forget: parte in background dopo 3s per non competere con LLM al boot. _backfillEmbeddings(); } // ─── Backfill embeddings (S140) ─────────────────────────────────────────────── // Calcola embedding per le entry caricate da Dexie che non ce l'hanno ancora. // Gira in background, batch da 5 con pausa 1.2s tra batch (rate limit Gemini free). // Con 50 entry: ~12s totali. Con 500 entry: ~2min. Completamente trasparente. async function _backfillEmbeddings(): Promise { // Aspetta 3s dopo il boot: non compete con richieste LLM iniziali await new Promise(r => setTimeout(r, 3_000)); const entries = Object.values(_cache).filter(e => !e.embedding && e.value.length > 0); if (!entries.length) return; const BATCH = 5; for (let i = 0; i < entries.length; i += BATCH) { const batch = entries.slice(i, i + BATCH); await Promise.all( batch.map(async (e) => { const vec = await getEmbedding(e.value).catch(() => null); if (vec && _cache[e.key]) { const updated: StoredMemoryEntry = { ..._cache[e.key], embedding: vec }; _cache[e.key] = updated; agentMemoryDb.put(updated).catch(() => {}); } }) ); // Pausa tra batch: evita burst su Gemini free tier (1500 req/min) if (i + BATCH < entries.length) { await new Promise(r => setTimeout(r, 1_200)); } } } // ─── Public API (invariata) ──────────────────────────────────────────────────── // ─── S166-Fix5: re-trigger backfill quando viene aggiunto token Gemini ───────── /** Avvia (o ri-avvia) il backfill degli embedding per entry senza vettore. * Chiamare quando l'utente aggiunge un token Gemini durante la sessione. */ export function triggerEmbeddingBackfill(): void { const needsBackfill = Object.values(_cache).some(e => !e.embedding && e.value.length > 0); if (needsBackfill) _backfillEmbeddings().catch(() => {}); } /** * S460: Trigger esplicito post-bulk-save (MemoryUpdater.ts). * Controlla la pressione della memoria e avvia summarization se sopra soglia. * Fire-and-forget — non blocca mai il chiamante. */ export function triggerMemoryPrune(): void { const count = Object.keys(_cache).length; if (count > SUMMARIZE_THRESHOLD) { _pruneLRU(); } } export const agentMemory = { set(key: string, value: string, category = "general"): StoredMemoryEntry { const now = Date.now(); const e: StoredMemoryEntry = { key, value, category, createdAt: _cache[key]?.createdAt ?? now, updatedAt: now, }; _cache[key] = e; _persist(e); _notify(); // S85: fire-and-forget embedding (Gemini text-embedding-004) getEmbedding(value).then(vec => { if (vec && _cache[key]) { const withEmbed: StoredMemoryEntry = { ..._cache[key], embedding: vec }; _cache[key] = withEmbed; agentMemoryDb.put(withEmbed).catch(() => {}); } }).catch(() => {}); // S281-FIX-MEMORY_CONFLICT-5: singolo write Supabase (diretto dal frontend). // backendSet rimosso: causava double-write su stessa riga agent_memory → // race condition last-write-wins. Backend resta solo fallback di LETTURA. syncMemoryToCloud(e).catch(() => {}); return e; }, get(key: string): string | null { return _cache[key]?.value ?? null; }, // Async get: in-memory first, then backend fallback async getAsync(key: string): Promise { const cached = _cache[key]?.value ?? null; if (cached !== null) return cached; const remote = await backendGet(key); if (remote !== null) { agentMemory.set(key, remote); return remote; } return null; }, list(category?: string): StoredMemoryEntry[] { const all = Object.values(_cache); return category ? all.filter(e => e.category === category) : all; }, delete(key: string): boolean { if (!_cache[key]) return false; delete _cache[key]; _persistDelete(key); deleteMemoryFromCloud(key).catch(() => {}); _notify(); return true; }, clear(): void { _cache = {}; agentMemoryDb.clear().catch(() => {}); clearMemoryFromCloud().catch(() => {}); try { localStorage.removeItem(LS_KEY); } catch { /* non-fatal */ } _notify(); }, subscribe(fn: Listener): () => void { _listeners.add(fn); return () => { _listeners.delete(fn); }; }, // S281-FIX-MEMORY_CONFLICT-5: syncFromBackend è fallback puro. // Se Supabase è attivo, il frontend è già idratato da hydrateMemoryFromCloud. // Il backend viene consultato solo quando Supabase non è disponibile. async syncFromBackend(): Promise { if (isMemoryCloudEnabled) return; // Supabase attivo → nessun roundtrip backend const entries = await backendLoadAll(); if (!entries.length) return; for (const e of entries) { const local = _cache[e.key]; if (!local || e.updatedAt > local.updatedAt) { _cache[e.key] = e; _persist(e); } } _notify(); }, }; export default agentMemory;