Il default di 2,5 s per connect+headers è troppo stretto su percorsi VPN/AP: misurati GET /v1/status 1,23 s e POST /v1/memories:search 1,98 s, talvolta oltre 2,5 s. In quelle condizioni il breaker si apriva pur con gateway raggiungibile e tutte le chiamate successive andavano in fast-fail per 2–10 minuti, spingendo di fatto ogni operazione sul solo fallback locale e lasciando l'outbox non sincronizzata. - extensions/shared.ts: connectTimeoutMs 2500 → 15_000 (default, fallback nel path di richiesta e commento), breakerTripAfter 2 → 3 - README.md: default aggiornati + motivazione nella tabella dei timeout - skills/qmem/SKILL.md: default aggiornato e sintassi CLI del reset breaker (`qmem-sqlite.mjs breaker --reset`) Verifica sul campo: con connectTimeoutMs=60000 l'outbox (18 record) è stata sincronizzata completamente e il breaker è rimasto chiuso.
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
/**
|
|
* 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 15000). */
|
|
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: 15_000,
|
|
breakerBaseMs: 120_000,
|
|
breakerMaxMs: 600_000,
|
|
breakerTripAfter: 3,
|
|
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<void> {
|
|
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 ?? 3);
|
|
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<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"X-API-Key": cfg.apiKey,
|
|
};
|
|
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
|
|
const connectMs = Math.max(200, opts?.connectTimeoutMs ?? cfg.connectTimeoutMs ?? 15_000);
|
|
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<void> {
|
|
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", "");
|
|
}
|
|
}
|
|
|