Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, StoredMemoryEntry>; | |
| type Listener = () => void; | |
| // ─── In-memory cache ────────────────────────────────────────────────────────── | |
| let _cache: MemStore = {}; | |
| let _writeCount = 0; | |
| let _summarizing = false; // GAP 4: prevent concurrent summarization | |
| const _listeners = new Set<Listener>(); | |
| // ─── 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<string | null> { | |
| 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<StoredMemoryEntry[]> { | |
| 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<void> { | |
| 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<void> { | |
| // 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<string | null> { | |
| 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<void> { | |
| 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; | |