/** * pi-qmem — memoria centralizzata e condivisa per agenti AI (estensione pi). * * Espone quattro tool: * - qmem_store → salva un record di memoria (nessun LLM in scrittura) * - qmem_search → ricerca semantica con filtri * - qmem_get → recupero deterministico di un record per UUID * - qmem_correct → supersede di una memoria falsa o superata * - qmem_meta → panoramica scope/kind/progetti/agenti * * Il gateway (FastAPI su brain.vpn:8082) usa una chiave condivisa con accesso * COMPLETO in lettura e scrittura all'intera conoscenza: qualsiasi agente può * consultare e aggiungere informazioni liberamente. L'agent_id è solo metadata * di provenienza, non un meccanismo di isolamento. * * Config: ~/.config/pi-qmem/config.json * { "url": "https://qmem.enne2.net", "apiKey": "..." } * * Comando: /qmem:config — menu interattivo (URL, API key, test connessione) */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-qmem"); const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); export interface MemoryConfig { url: string; apiKey: string; /** Budget (ms) per la risposta DOPO gli header: distingue l'elaborazione lunga (default 30000). */ timeoutMs?: number; /** Timeout (ms) per connect+headers: oltre questo il gateway è "non raggiungibile" (default 2500). */ connectTimeoutMs?: number; /** Attesa base del circuit breaker dopo un fallimento definitivo (default 120000 = 2 min). */ breakerBaseMs?: number; /** Tetto dell'escalation del breaker (default 600000 = 10 min). */ breakerMaxMs?: number; /** Fallimenti ambigui consecutivi (body lento, 5xx) prima di aprire il breaker (default 2). */ breakerTripAfter?: number; correctMinScore?: number; /** Percorso del DB SQLite locale (default ~/.local/share/pi-qmem/qmem.sqlite). */ localDbPath?: string; /** Usa l'indice locale come fallback quando il gateway non risponde (default true). */ localFallback?: boolean; /** Accoda i record in locale quando il gateway non è raggiungibile (default true). */ offlineQueue?: boolean; } const CONFIG_DEFAULTS: MemoryConfig = { url: "https://qmem.enne2.net", apiKey: "", timeoutMs: 30_000, connectTimeoutMs: 2500, breakerBaseMs: 120_000, breakerMaxMs: 600_000, breakerTripAfter: 2, correctMinScore: 0.6, localFallback: true, offlineQueue: true, }; // Retry su errori transitori del SERVER (429/5xx/timeout del body): backoff + jitter. // Nessun retry sulle connessioni fallite: il breaker copre l'intervallo successivo. const MAX_RETRIES = 3; const RETRY_BASE_MS = 500; function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } // --------------------------------------------------------------------------- // Circuit breaker persistente (fast-fail quando il gateway è irraggiungibile) // --------------------------------------------------------------------------- // Stato in un file JSON dedicato (non nella config, non nel DB): sopravvive a // /reload, a nuove sessioni e ai processi CLI, e non richiede node:sqlite. // - fallimento DEFINITIVO (connessione rifiutata/DNS/timeout di connect) → apre // subito per `breakerBaseMs` (default 2 min), con escalation esponenziale; // - fallimento AMBIGUO (body lento, HTTP 5xx) → apre dopo `breakerTripAfter`; // - un successo (o `resetBreaker()`) lo richiude. // Mentre è aperto nessuna richiesta tocca la rete: i tool passano direttamente // al fallback locale (SQLite/FTS5) e l'outbox accoda senza attese. const BREAKER_FILE = process.env.QMEM_BREAKER_FILE ?? path.join(os.homedir(), ".local", "share", "pi-qmem", "breaker.json"); interface BreakerState { openUntil: number; failures: number; trips?: number; lastError?: string; lastChange?: string; /** Endpoint a cui si riferisce lo stato: cambiando URL il breaker riparte chiuso. */ url?: string; } let breakerCache: BreakerState | null = null; function loadBreaker(): BreakerState { if (breakerCache) return breakerCache; try { breakerCache = { openUntil: 0, failures: 0, ...JSON.parse(fs.readFileSync(BREAKER_FILE, "utf8")) }; } catch { breakerCache = { openUntil: 0, failures: 0 }; } return breakerCache; } function saveBreaker(state: BreakerState): void { breakerCache = state; try { fs.mkdirSync(path.dirname(BREAKER_FILE), { recursive: true }); fs.writeFileSync(BREAKER_FILE, JSON.stringify(state, null, 2)); } catch { /* stato solo in memoria */ } } export interface BreakerInfo { open: boolean; remainingMs: number; failures: number; trips: number; lastError?: string; lastChange?: string; url?: string; file: string; } export function breakerInfo(): BreakerInfo { const s = loadBreaker(); return { open: Math.max(0, s.openUntil - Date.now()) > 0, remainingMs: Math.max(0, s.openUntil - Date.now()), failures: s.failures ?? 0, trips: s.trips ?? 0, lastError: s.lastError, lastChange: s.lastChange, url: s.url, file: BREAKER_FILE, }; } export function breakerIsOpen(): boolean { return breakerInfo().open; } /** Registra un fallimento; con `definitive` (connessione) apre immediatamente. */ export function tripBreaker(reason: string, definitive: boolean, cfg?: MemoryConfig): BreakerInfo { const s = loadBreaker(); const tripAfter = Math.max(1, cfg?.breakerTripAfter ?? CONFIG_DEFAULTS.breakerTripAfter ?? 2); s.failures = definitive ? Math.max((s.failures ?? 0) + 1, tripAfter) : (s.failures ?? 0) + 1; s.lastError = reason; s.lastChange = new Date().toISOString(); if (cfg?.url) s.url = cfg.url; if (s.failures >= tripAfter) { const base = cfg?.breakerBaseMs ?? CONFIG_DEFAULTS.breakerBaseMs ?? 120_000; const max = cfg?.breakerMaxMs ?? CONFIG_DEFAULTS.breakerMaxMs ?? 600_000; const step = Math.max(0, s.failures - tripAfter); const wait = Math.min(base * 2 ** step, max); s.openUntil = Date.now() + wait; s.trips = (s.trips ?? 0) + 1; } saveBreaker(s); return breakerInfo(); } /** Richiude il breaker (successo, o reset manuale). */ export function resetBreaker(): BreakerInfo { const s = loadBreaker(); if (!s.failures && !s.openUntil && !s.trips) return breakerInfo(); saveBreaker({ openUntil: 0, failures: 0, trips: 0, lastChange: new Date().toISOString(), url: s.url }); return breakerInfo(); } export function loadConfig(): MemoryConfig { try { return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) }; } catch { return { ...CONFIG_DEFAULTS }; } } export function saveConfig(cfg: MemoryConfig) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }); } catch { /* ignora */ } } // --------------------------------------------------------------------------- // Identità della macchina: rilevata dinamicamente e iniettata nelle regole. // Ogni installazione si auto-descrive (hostname + OS + arch) senza configurazione. // --------------------------------------------------------------------------- export function detectMachine(): string { const hostname = os.hostname(); let osName = "OS sconosciuto"; try { const m = fs.readFileSync("/etc/os-release", "utf8").match(/^PRETTY_NAME="?([^"\n]+)"?/m); if (m) osName = m[1]; } catch { /* os-release non leggibile: resta il default */ } return `${hostname} (${osName}, ${os.arch()})`; } export const MACHINE = detectMachine(); export async function gatewayRequest( cfg: MemoryConfig, method: string, route: string, body?: unknown, signal?: AbortSignal, idempotencyKey?: string, opts?: { timeoutMs?: number; connectTimeoutMs?: number }, ): Promise<{ ok: boolean; status: number; data: any; breaker?: BreakerInfo }> { // Cambio di endpoint (es. da front a nodo diretto): lo stato non è più valido // per questo gateway → si riparte chiusi. { const st = loadBreaker(); if (st.url && st.url !== cfg.url) { saveBreaker({ openUntil: 0, failures: 0, trips: st.trips ?? 0, lastChange: new Date().toISOString(), url: cfg.url }); } else if (!st.url) { saveBreaker({ ...st, url: cfg.url }); } } // Fast-fail: con il breaker aperto nessuna richiesta di rete (tempo ~0). const open = breakerInfo(); if (open.open) { return { ok: false, status: 0, data: { error: "gateway_unreachable", breaker_open: true, retry_in_ms: Math.round(open.remainingMs), detail: open.lastError ?? "errore precedente", hint: "fallback locale attivo; /qmem:local breaker reset per forzare un tentativo", }, breaker: open, }; } const headers: Record = { "Content-Type": "application/json", "X-API-Key": cfg.apiKey, }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const connectMs = Math.max(200, opts?.connectTimeoutMs ?? cfg.connectTimeoutMs ?? 2500); const bodyMs = Math.max(500, opts?.timeoutMs ?? cfg.timeoutMs ?? 30_000); const url = `${cfg.url}${route}`; let lastError: unknown = null; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const t0 = Date.now(); const ac = new AbortController(); let phase: "connect" | "body" = "connect"; const onAbort = () => ac.abort(signal?.reason ?? new Error("aborted")); if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); } // Fase 1: nessuna risposta (header) entro connectMs → gateway non raggiungibile let timer = setTimeout(() => ac.abort(new Error(`nessuna risposta entro ${connectMs}ms`)), connectMs); try { const res = await fetch(url, { method, signal: ac.signal, headers, body: body ? JSON.stringify(body) : undefined, }); // Header ricevuti: da qui il tempo è "elaborazione", con un budget separato phase = "body"; clearTimeout(timer); const remaining = Math.max(1000, bodyMs - (Date.now() - t0)); timer = setTimeout(() => ac.abort(new Error(`risposta non completata entro ${remaining}ms`)), remaining); let data: any = {}; let parseError: string | undefined; try { data = await res.json(); } catch (e) { parseError = e instanceof Error ? e.message : String(e); } clearTimeout(timer); if (parseError && ac.signal.aborted) { // header arrivati ma body non completato entro il budget: NON è un // successo vuoto (prima veniva restituito {ok:true, data:{}}), è un // fallimento di elaborazione → il chiamante passa al fallback locale. const br = tripBreaker(`risposta non completata: ${parseError}`, false, cfg); return { ok: false, status: 0, data: { error: "timeout_body", detail: parseError, budget_ms: remaining, url }, breaker: br, }; } if (res.ok) { resetBreaker(); return { ok: true, status: res.status, data, breaker: breakerInfo() }; } if (res.status === 429 || res.status >= 500) { // Server raggiungibile ma in difficoltà: fallimento ambiguo, retry con Retry-After if (attempt < MAX_RETRIES) { const retryAfter = res.headers.get("retry-after"); const delay = retryAfter ? Math.min(Number(retryAfter) * 1000 || RETRY_BASE_MS, 10_000) : RETRY_BASE_MS * 2 ** attempt + Math.random() * 200; await sleep(delay); continue; } const br = tripBreaker(`HTTP ${res.status} da ${url}`, false, cfg); return { ok: false, status: res.status, data, breaker: br }; } // 4xx applicativo (401/404/409/422): il server risponde → breaker chiuso resetBreaker(); return { ok: false, status: res.status, data, breaker: breakerInfo() }; } catch (e) { clearTimeout(timer); if (signal?.aborted) throw e; // annullamento utente (Esc): propaga const msg = e instanceof Error ? e.message : String(e); if (phase === "connect") { // Connessione fallita o nessuna risposta: definitivo → breaker subito aperto, // nessun retry (era la causa delle attese di ~30s x4). const br = tripBreaker(`gateway non raggiungibile: ${msg}`, true, cfg); return { ok: false, status: 0, data: { error: "gateway_unreachable", detail: msg, connect_timeout_ms: connectMs, url }, breaker: br, }; } // Header ricevuti ma body lento/interrotto: ambiguo (potrebbe essere un rerank pesante) lastError = msg; const br = tripBreaker(`risposta lenta: ${msg}`, false, cfg); return { ok: false, status: 0, data: { error: "timeout_body", detail: msg, budget_ms: bodyMs, url }, breaker: br, }; } finally { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); } } return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") }, breaker: breakerInfo() }; } // --------------------------------------------------------------------------- // Test connessione: verifica URL (status) e validità chiave (search minima) // --------------------------------------------------------------------------- export async function testConnection(ctx: any, cfg: MemoryConfig): Promise { ctx.ui.setStatus("pi-qmem", "Test connessione al gateway..."); try { const res = await fetch(`${cfg.url}/v1/status`, { signal: AbortSignal.timeout(8000), }); if (!res.ok) { ctx.ui.notify(`❌ Gateway non raggiungibile: HTTP ${res.status}`, "error"); return; } const data = await res.json(); if (!cfg.apiKey) { ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key mancante`, "warning"); return; } const r2 = await fetch(`${cfg.url}/v1/memories:search`, { method: "POST", signal: AbortSignal.timeout(8000), headers: { "Content-Type": "application/json", "X-API-Key": cfg.apiKey }, body: JSON.stringify({ query: "test", top_k: 1 }), }); if (r2.status === 401) { ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key non valida`, "warning"); } else if (r2.ok) { ctx.ui.notify(`✅ Connessione OK: ${data.points ?? "?"} punti in memoria, chiave valida`, "info"); } else { ctx.ui.notify(`⚠️ Gateway OK ma errore ${r2.status}`, "warning"); } } catch { ctx.ui.notify(`❌ Gateway non raggiungibile su ${cfg.url}`, "error"); } finally { ctx.ui.setStatus("pi-qmem", ""); } }