174 lines
5.8 KiB
TypeScript
174 lines
5.8 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;
|
|
}
|
|
|
|
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<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", "");
|
|
}
|
|
}
|
|
|