Spaces:
Sleeping
Sleeping
| /** | |
| * 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<unknown>; clear: () => Promise<void>; toArray: () => Promise<PageEntry[]>; }; | |
| errorLog: { put: (e: ErrorEntry) => Promise<unknown>; clear: () => Promise<void>; toArray: () => Promise<ErrorEntry[]>; }; | |
| docIndex: { put: (e: DocEntry) => Promise<unknown>; clear: () => Promise<void>; toArray: () => Promise<DocEntry[]>; }; | |
| } | |
| // ─── DB Dexie standalone (separata da vfsDb) ───────────────────────────────── | |
| class AgentBrainDB extends Dexie { | |
| pageCache!: Table<PageEntry, string>; | |
| errorLog!: Table<ErrorEntry, string>; | |
| docIndex!: Table<DocEntry, string>; | |
| 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, "<file>") | |
| .replace(/line \d+/gi, "line ?") | |
| .replace(/\d{4,}/g, "<num>") | |
| .substring(0, 300) | |
| .trim(); | |
| } | |
| // ─── In-memory caches ───────────────────────────────────────────────────────── | |
| const _pages: Map<string, PageEntry> = new Map(); | |
| const _errors: Map<string, ErrorEntry> = new Map(); | |
| const _docs: Map<string, DocEntry> = new Map(); | |
| /** Rimuove le entry più vecchie dal Map se supera il limite */ | |
| function _lruMap<V extends { visitedAt?: number; lastSeen?: number; indexedAt?: number }>( | |
| map: Map<string, V>, 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<void> { | |
| 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; | |