Files
pi-qmem/extensions/shared.ts
T
Matteo Benedetto 322b4cf446 feat(outbox): store offline con coda locale e sincronizzazione al ritorno della rete
Prima qmem_store falliva se il gateway non era raggiungibile: la conoscenza
andava persa. Ora il record entra in una coda locale persistente e viene
inviato automaticamente quando la connessione torna.

Core (extensions/local-db.ts):
- tabella `pending` (local_id, payload JSON, attempts, last_error, status,
  remote_id) + colonna `records.pending` (migrazione automatica dei DB esistenti)
- queueStore(): accoda e crea subito il placeholder locale ricercabile ()
- flushQueue(): POST /v1/memories con Idempotency-Key = local_id (retry senza
  duplicati), FIFO, pacing sotto il rate limit, timeout 12s per richiesta
- esiti: synced (il record locale adotta l'ID remoto, niente duplicati) ·
  duplicate (409: registra l'ID del match e NON sovrascrive il testo locale
  autorevole) · failed (4xx di validazione, non ritentato) · 0/429/5xx: resta in
  coda e il flush si ferma
- supersede offline: supersedes_id che punta a un local_id viene rimappato al
  remote_id al flush (se il genitore non è sincronizzato → failed esplicito)
- submitOrQueue(): online → gateway + indicizzazione locale; offline → coda
- maybeBackgroundFlush() (single-flight) e flushQueueIfPending() per session_start
- stato/report: queued/synced/duplicate/failed, più vecchio, ultimo errore,
  last_flush, record pendenti in indice

Estensione:
- qmem_store: gateway giù → accoda e risponde con id locale, dimensione coda e
  spiegazione (details.queued/local_id/queue_size)
- fallback offline di session_start: flush in background (non blocca l'avvio)
- /qmem:local queue|flush; status con la coda; marker " in coda" nei risultati
  locali di qmem_search/qmem_get
- regole e skill: un record in coda NON è ancora nella memoria condivisa

CLI: store [--queue-only], queue, flush (+ status con la coda).
Test: scripts/test-local.mjs ora copre anche outbox → 24 controlli (flush con
2 sync + 1 duplicato 409 + 1 fallito 422, Idempotency-Key, rimappatura del
supersede, ricerca del record con l'ID remoto dopo il sync).

Verifiche: 24/24 test superati; demo reale su DB temporaneo: store accodato,
queue con local_id, flush con gateway giù → "fermato: HTTP 0" e voce che resta
in coda con l'errore registrato.
2026-09-13 17:29:09 +02:00

182 lines
6.2 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;
timeoutMs?: 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,
correctMinScore: 0.6,
localFallback: true,
offlineQueue: true,
};
// Retry su errori transitori (429/5xx/timeout): backoff esponenziale + jitter
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 500;
function sleep(ms: number): Promise<void> {
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<string, string> = {
"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<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", "");
}
}