/** * 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; timeoutMs?: number; correctMinScore?: number; } const CONFIG_DEFAULTS: MemoryConfig = { url: "https://qmem.enne2.net", apiKey: "", timeoutMs: 30_000, correctMinScore: 0.6, }; // Retry su errori transitori (429/5xx/timeout): backoff esponenziale + jitter const MAX_RETRIES = 3; const RETRY_BASE_MS = 500; function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } 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, ): Promise<{ ok: boolean; status: number; data: any }> { const headers: Record = { "Content-Type": "application/json", "X-API-Key": cfg.apiKey, }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; let lastError: unknown = null; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { // timeout fallback: se pi non fornisce un signal, usa timeoutMs dalla config const timeoutSignal = signal ?? AbortSignal.timeout(cfg.timeoutMs ?? 30_000); try { const res = await fetch(`${cfg.url}${route}`, { method, signal: timeoutSignal, headers, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); // retry solo su errori transitori (429/5xx), rispettando Retry-After if ((res.status === 429 || res.status >= 500) && 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; } return { ok: res.ok, status: res.status, data }; } catch (e) { // annullamento utente: propaga, non ritentare if (e instanceof Error && e.name === "AbortError") throw e; // errore di rete/timeout: retry con backoff lastError = e; if (attempt < MAX_RETRIES) { await sleep(RETRY_BASE_MS * 2 ** attempt + Math.random() * 200); continue; } } } return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") } }; } // --------------------------------------------------------------------------- // 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", ""); } }