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