/** * agentBrain.ts — Browser Memory System (Sessione 27) * * Tre store specializzati per la memoria contestuale dell'agente: * pageCache — pagine lette (evita rilettura della stessa URL) * errorLog — errori visti + fix applicati (evita di ripetere gli stessi errori) * docIndex — documenti indicizzati (recall semantico per path/tag/lingua) * * Architettura: * - Dexie standalone "agentBrainDB" (v1, separata da vfsDb v8 — zero conflitti) * - _db lazy: istanza Dexie creata al primo uso, non al caricamento del modulo * - In-memory cache come layer sincrono (risposta immediata) * - Write-behind su Dexie (fire-and-forget, non blocca il caller) * - LRU enforced in-memory (MAX_PAGES / MAX_ERRORS / MAX_DOCS) * - Zero dipendenze da React / EventRuntime / runtimeLogger (puro) * * Invarianti: * - MAI modificare vfsDb o i suoi schemi * - Safari-safe: nessun SharedArrayBuffer, nessun Worker * - Dexie write sempre fire-and-forget (non aspettare nei path critici) * - initBrain() va chiamato da main.tsx prima del mount React * - _db lazy: new AgentBrainDB() NON si esegue al caricamento del modulo */ import Dexie, { type Table } from "dexie"; // ─── Tipi pubblici ──────────────────────────────────────────────────────────── export interface PageEntry { url: string; // chiave primaria (normalizzata: senza #fragment) title: string; summary: string; // max 2000 chars domain: string; // hostname estratto da URL visitedAt: number; // timestamp ultima visita visits: number; // contatore visite size: number; // lunghezza del contenuto originale in chars } export interface ErrorEntry { hash: string; // chiave: djb2(normalizedMessage) message: string; // testo errore (normalizzato) context: string; // dove è accaduto (file, tool, step) fix?: string; // fix che ha funzionato (null se irrisolto) count: number; // quante volte visto firstSeen: number; lastSeen: number; lang?: string; // "typescript" | "python" | "javascript" | altro code?: string; // snippet sorgente che ha causato l'errore (max 2000 chars) resolved: boolean; } export interface DocEntry { path: string; // chiave: path file o URL documento title: string; summary: string; // max 1000 chars tags: string[]; // keywords estratte contentHash: string; // djb2 del contenuto (per change detection) indexedAt: number; lang?: string; // "typescript" | "python" | "markdown" | ... size: number; // lunghezza in chars } // ─── Tipo interfaccia DB (per dependency injection in test) ──────────────────── interface BrainDB { pageCache: { put: (e: PageEntry) => Promise; clear: () => Promise; toArray: () => Promise; }; errorLog: { put: (e: ErrorEntry) => Promise; clear: () => Promise; toArray: () => Promise; }; docIndex: { put: (e: DocEntry) => Promise; clear: () => Promise; toArray: () => Promise; }; } // ─── DB Dexie standalone (separata da vfsDb) ───────────────────────────────── class AgentBrainDB extends Dexie { pageCache!: Table; errorLog!: Table; docIndex!: Table; constructor() { super("agentBrainDB"); this.version(1).stores({ pageCache: "url, domain, visitedAt", errorLog: "hash, lastSeen, resolved, lang", docIndex: "path, indexedAt, lang", }); } } // ─── Lazy DB instance — NON inizializzato al caricamento del modulo ─────────── // Viene creato al primo uso (initBrain o prima write). // Questo evita conflitti ESM durante i test e avvio app più veloce. let _db: BrainDB | null = null; function getDb(): BrainDB { if (!_db) _db = new AgentBrainDB(); return _db; } /** * Dependency injection per i test. * Permette di passare un mock DB senza dover mockare il modulo Dexie. * @internal — solo per test unitari */ export function _setTestDb(mockDb: BrainDB): void { _db = mockDb; } // ─── LRU limits ─────────────────────────────────────────────────────────────── const MAX_PAGES = 200; const MAX_ERRORS = 300; const MAX_DOCS = 500; // ─── Utility ────────────────────────────────────────────────────────────────── /** djb2 hash — stabile, zero dipendenze */ function djb2(s: string): string { let h = 5381; for (let i = 0; i < s.length; i++) { h = ((h * 33) ^ s.charCodeAt(i)) >>> 0; } return h.toString(36); } /** Estrae hostname da URL (fallback all'URL stesso se non parsabile) */ function extractDomain(url: string): string { try { return new URL(url).hostname; } catch { return url.split("/")[0] ?? url; } } /** Normalizza URL: rimuove #fragment, trailing slash, normalizza http→https, ordina query params (S97-Bug6) */ function normalizeUrl(url: string): string { try { const u = new URL(url); u.hash = ""; return u.toString().replace(/\/$/, ""); } catch { return url.split("#")[0] ?? url; } } /** Normalizza messaggio errore: rimuove path assoluti e numeri di riga */ function normalizeError(msg: string): string { return msg .replace(/\/[\w/.-]+\.tsx?:\d+/g, "") .replace(/line \d+/gi, "line ?") .replace(/\d{4,}/g, "") .substring(0, 300) .trim(); } // ─── In-memory caches ───────────────────────────────────────────────────────── const _pages: Map = new Map(); const _errors: Map = new Map(); const _docs: Map = new Map(); /** Rimuove le entry più vecchie dal Map se supera il limite */ function _lruMap( map: Map, limit: number ): void { if (map.size <= limit) return; const sorted = [...map.entries()].sort(([, a], [, b]) => { const ta = a.visitedAt ?? a.lastSeen ?? a.indexedAt ?? 0; const tb = b.visitedAt ?? b.lastSeen ?? b.indexedAt ?? 0; return ta - tb; }); const toRemove = sorted.slice(0, map.size - limit); for (const [k] of toRemove) map.delete(k); } // ─── Boot init ──────────────────────────────────────────────────────────────── /** * Carica i tre store in-memory da Dexie. * Chiamare da main.tsx prima del mount React. */ export async function initBrain(): Promise { try { const db = getDb(); const [pages, errors, docs] = await Promise.all([ db.pageCache.toArray().catch(() => [] as PageEntry[]), db.errorLog.toArray().catch(() => [] as ErrorEntry[]), db.docIndex.toArray().catch(() => [] as DocEntry[]), ]); for (const p of pages) _pages.set(p.url, p); for (const e of errors) _errors.set(e.hash, e); for (const d of docs) _docs.set(d.path, d); } catch { /* non-fatal — i cache rimangono vuoti */ } } // ─── pageCache ──────────────────────────────────────────────────────────────── export const pageCache = { /** Salva o aggiorna una pagina letta */ set(url: string, data: { title: string; summary: string; size?: number }): PageEntry { const key = normalizeUrl(url); const now = Date.now(); const prev = _pages.get(key); const entry: PageEntry = { url: key, title: data.title.substring(0, 200), summary: data.summary.substring(0, 2000), domain: extractDomain(key), visitedAt: now, visits: (prev?.visits ?? 0) + 1, size: data.size ?? data.summary.length, }; _pages.set(key, entry); _lruMap(_pages, MAX_PAGES); getDb().pageCache.put(entry).catch(() => {}); return entry; }, /** Ritorna la entry se la pagina è già stata letta */ get(url: string): PageEntry | null { return _pages.get(normalizeUrl(url)) ?? null; }, /** L'agente ha già letto questa URL? */ has(url: string): boolean { return _pages.has(normalizeUrl(url)); }, /** Ritorna le pagine per dominio */ byDomain(d: string): PageEntry[] { return [..._pages.values()].filter(p => p.domain === d); }, /** Ultime N pagine visitate */ recent(n = 10): PageEntry[] { return [..._pages.values()] .sort((a, b) => b.visitedAt - a.visitedAt) .slice(0, n); }, /** Ricerca semplice per testo in title/summary */ search(query: string): PageEntry[] { const q = query.toLowerCase(); return [..._pages.values()].filter( p => p.title.toLowerCase().includes(q) || p.summary.toLowerCase().includes(q) ); }, size(): number { return _pages.size; }, clear(): void { _pages.clear(); getDb().pageCache.clear().catch(() => {}); }, }; // ─── errorLog ───────────────────────────────────────────────────────────────── export const errorLog = { /** Registra un errore. Se già visto, incrementa il contatore. */ record( message: string, context: string, lang?: string, code?: string, ): ErrorEntry { const normalized = normalizeError(message); const hash = djb2(normalized); const now = Date.now(); const prev = _errors.get(hash); const entry: ErrorEntry = { hash, message: message.substring(0, 500), context: context.substring(0, 200), fix: prev?.fix, count: (prev?.count ?? 0) + 1, firstSeen: prev?.firstSeen ?? now, lastSeen: now, lang, code: code ? code.substring(0, 2000) : prev?.code, resolved: prev?.resolved ?? false, }; _errors.set(hash, entry); _lruMap(_errors, MAX_ERRORS); getDb().errorLog.put(entry).catch(() => {}); return entry; }, /** Marca un errore come risolto con un fix */ markFixed(hash: string, fix: string): boolean { const entry = _errors.get(hash); if (!entry) return false; const updated: ErrorEntry = { ...entry, fix: fix.substring(0, 1000), resolved: true }; _errors.set(hash, updated); getDb().errorLog.put(updated).catch(() => {}); return true; }, /** Trova errori simili a un messaggio dato (stessa signature hash) */ findSimilar(message: string): ErrorEntry[] { const hash = djb2(normalizeError(message)); const exact = _errors.get(hash); if (exact) return [exact]; const prefix = message.toLowerCase().substring(0, 60); return [..._errors.values()].filter(e => e.message.toLowerCase().includes(prefix) || prefix.includes(e.message.toLowerCase().substring(0, 40)) ); }, /** Errori non ancora risolti */ unresolved(lang?: string): ErrorEntry[] { const all = [..._errors.values()].filter(e => !e.resolved); return lang ? all.filter(e => e.lang === lang) : all; }, /** Errori risolti con fix disponibili (per il prompt di contesto) */ resolvedWithFix(lang?: string): ErrorEntry[] { const all = [..._errors.values()].filter(e => e.resolved && e.fix); return lang ? all.filter(e => e.lang === lang) : all; }, /** Ultimi N errori per lastSeen */ recent(n = 10): ErrorEntry[] { return [..._errors.values()] .sort((a, b) => b.lastSeen - a.lastSeen) .slice(0, n); }, /** Hash di un messaggio errore (per corrispondere con markFixed) */ hashOf(message: string): string { return djb2(normalizeError(message)); }, size(): number { return _errors.size; }, clear(): void { _errors.clear(); getDb().errorLog.clear().catch(() => {}); }, }; // ─── docIndex ───────────────────────────────────────────────────────────────── export const docIndex = { /** Aggiunge o aggiorna un documento indicizzato */ add( path: string, data: { title: string; summary: string; tags?: string[]; content?: string; lang?: string } ): DocEntry { const now = Date.now(); const content = data.content ?? data.summary; const entry: DocEntry = { path, title: data.title.substring(0, 200), summary: data.summary.substring(0, 1000), tags: (data.tags ?? []).slice(0, 20), contentHash: djb2(content.substring(0, 5000)), indexedAt: now, lang: data.lang, size: content.length, }; _docs.set(path, entry); _lruMap(_docs, MAX_DOCS); getDb().docIndex.put(entry).catch(() => {}); return entry; }, /** Ritorna un documento indicizzato per path */ get(path: string): DocEntry | null { return _docs.get(path) ?? null; }, /** L'agente ha già indicizzato questo documento? */ has(path: string): boolean { return _docs.has(path); }, /** Il contenuto è cambiato rispetto all'indice? (change detection) */ isStale(path: string, content: string): boolean { const entry = _docs.get(path); if (!entry) return true; return entry.contentHash !== djb2(content.substring(0, 5000)); }, /** Cerca documenti per testo in title/summary/tags */ search(query: string): DocEntry[] { const q = query.toLowerCase(); return [..._docs.values()].filter( d => d.title.toLowerCase().includes(q) || d.summary.toLowerCase().includes(q) || d.tags.some(t => t.toLowerCase().includes(q)) ); }, /** Documenti per tag esatto */ byTag(tag: string): DocEntry[] { const t = tag.toLowerCase(); return [..._docs.values()].filter(d => d.tags.some(dt => dt.toLowerCase() === t)); }, /** Documenti per linguaggio */ byLang(lang: string): DocEntry[] { return [..._docs.values()].filter(d => d.lang === lang); }, /** Ultimi N documenti indicizzati */ recent(n = 10): DocEntry[] { return [..._docs.values()] .sort((a, b) => b.indexedAt - a.indexedAt) .slice(0, n); }, size(): number { return _docs.size; }, clear(): void { _docs.clear(); getDb().docIndex.clear().catch(() => {}); }, }; // ─── Export singolo per convenienza ─────────────────────────────────────────── export const agentBrain = { pageCache, errorLog, docIndex, initBrain }; export default agentBrain;