AUDIT / src /lib /agentMemory.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
14.2 kB
/**
* 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;