#!/usr/bin/env node /** * qmem-sqlite — CLI per l'indice locale di pi-qmem (SQLite + FTS5). * * Uso: * node scripts/qmem-sqlite.mjs status * node scripts/qmem-sqlite.mjs import [--db FILE] * node scripts/qmem-sqlite.mjs find "query" [--kind K] [--project P] [--scope S] * [--top N] [--all] [--private] [--exact] [--json] * node scripts/qmem-sqlite.mjs store --project P [--kind K] [--text "..."] [--queue-only] * node scripts/qmem-sqlite.mjs queue [--status queued|synced|duplicate|failed] * node scripts/qmem-sqlite.mjs flush [--limit N] * node scripts/qmem-sqlite.mjs breaker [--reset] * node scripts/qmem-sqlite.mjs enrich [--all] [--limit N] [--pace MS] * node scripts/qmem-sqlite.mjs pull [--limit N] * * Il DB di default è ~/.local/share/pi-qmem/qmem.sqlite (override: --db, * env QMEM_SQLITE, oppure `localDbPath` in ~/.config/pi-qmem/config.json). */ // import dinamico: permette di sopprimere il warning MODULE_TYPELESS_PACKAGE_JSON // (il package non dichiara "type":"module" per non cambiare la semantica del // manifest pi) e di degradare con grazia se node:sqlite non è disponibile. const originalEmitWarning = process.emitWarning; process.emitWarning = (warning, ...rest) => { const code = rest[0]?.code ?? (typeof rest[0] === "string" ? rest[0] : undefined) ?? warning?.code; if (code === "MODULE_TYPELESS_PACKAGE_JSON") return; return originalEmitWarning.call(process, warning, ...rest); }; const localDb = await import("../extensions/local-db.ts"); const { DEFAULT_DB_FILE, enrichFromGateway, flushQueue, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, queueList, queueStats, queueStore, sessionRoots, submitOrQueue } = localDb; const { breakerInfo, loadConfig, resetBreaker } = await import("../extensions/shared.ts"); process.emitWarning = originalEmitWarning; const argv = process.argv.slice(2); const cmd = argv[0] ?? "status"; function opt(name, fallback) { const i = argv.indexOf(`--${name}`); if (i === -1) return fallback; const v = argv[i + 1]; return v && !v.startsWith("--") ? v : true; } const has = (name) => argv.includes(`--${name}`); const dbFile = typeof opt("db", null) === "string" ? opt("db", null) : localDbPath(loadConfig()); const json = has("json"); function out(obj) { if (json) console.log(JSON.stringify(obj, null, 2)); else console.log(obj); } if (cmd === "status") { const r = await localDbReport({ dbFile }); if (json) { out(r); } else { console.log(`DB locale : ${r.path}${r.exists ? "" : " (assente — esegui: import)"}`); if (r.exists) { console.log(` dimensione : ${r.sizeKb} KB`); console.log(` record : ${r.total} (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati)`); console.log(` con project_id: ${r.withProject}/${r.total}`); console.log(` ultimo import : ${r.lastImport ?? "-"} enrich: ${r.lastEnrich ?? "-"} export: ${r.lastExport ?? "-"} flush: ${r.lastFlush ?? "-"}`); if (r.queued || r.syncedQueue || r.failedQueue || r.duplicateQueue) { console.log(` coda offline : ${r.queued} in attesa, ${r.syncedQueue} sincronizzati, ${r.duplicateQueue} duplicati, ${r.failedQueue} falliti${r.oldestQueued ? ` (più vecchio: ${r.oldestQueued})` : ""}`); if (r.queueLastError) console.log(` ultimo errore : ${r.queueLastError.slice(0, 120)}`); } if (r.pendingInIndex) console.log(` in indice : ${r.pendingInIndex} record marcati ⏳ (creati offline, non ancora sul gateway)`); if (r.deleted) console.log(` tombstone : ${r.deleted} record cancellati sul gateway (soft delete)`); { const br = breakerInfo(); console.log(` breaker : ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"} | fallimenti ${br.failures} | aperture ${br.trips}${br.lastError ? ` | ultimo errore: ${String(br.lastError).slice(0, 70)}` : ""}`); } console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`); if (r.duplicates.length) { console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`); } } } } else if (cmd === "import") { const roots = sessionRoots(); const stats = await importFromSessions({ dbFile }); const r = await localDbReport({ dbFile }); if (json) out({ stats, report: r }); else { console.log(`Import dalle sessioni (${roots.join(", ")})`); console.log(` file letti : ${stats.files} (${stats.lines} righe)`); console.log(` eventi : store=${stats.store} correct=${stats.correct} get=${stats.get} search_hit=${stats.searchHits} non_interpretati=${stats.unparsed}`); console.log(` record unici : ${stats.records} (scritti/aggiornati: ${stats.written})`); console.log(` DB : ${r.path} — ${r.total} record, ${r.sizeKb} KB, ${r.withText} con testo`); if (r.duplicates.length) console.log(` duplicati : ${r.duplicates.length} gruppi con testo identico`); } } else if (cmd === "find") { const query = argv[1] && !argv[1].startsWith("--") ? argv[1] : ""; if (!query) { console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--deleted] [--exact] [--json]'); process.exit(2); } const hits = await localSearch( { query, kind: typeof opt("kind", null) === "string" ? opt("kind", null) : undefined, project_id: typeof opt("project", null) === "string" ? opt("project", null) : undefined, scope: typeof opt("scope", null) === "string" ? opt("scope", null) : undefined, level: typeof opt("level", null) === "string" ? opt("level", null) : undefined, topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined, include_superseded: has("all"), include_private: has("private"), include_deleted: has("deleted"), exact: has("exact"), top_k: Number(opt("top", 5)) || 5, }, { dbFile }, ); if (json) { out(hits); } else { if (!hits.length) console.log(`Nessun risultato locale per "${query}" (indice: ${dbFile}).`); hits.forEach((h, i) => { console.log(`${i + 1}. [${h.kind ?? "?"}/${h.scope ?? "?"}${h.project_id ? ` project=${h.project_id}` : ""} rank=${Number(h.rank).toFixed(2)}${h.superseded_by ? " superseduto" : ""}] ${h.snippet}`); console.log(` (id: ${h.memory_id}, creato: ${h.created_at ?? "?"}, agente: ${h.agent_id ?? "?"}, fonti: ${h.sources ?? "?"})`); }); } } else if (cmd === "enrich") { const cfg = loadConfig(); const stats = await enrichFromGateway(cfg, { dbFile, onlyIncomplete: !has("all"), limit: Number(opt("limit", 1000)) || 1000, paceMs: Number(opt("pace", 600)), }); if (json) out(stats); else { console.log(`Arricchimento dal gateway (${cfg.url})`); console.log(` richiesti: ${stats.requested} ok: ${stats.ok} falliti: ${stats.failed} aggiornati: ${stats.updated}`); if (stats.errors.length) console.log(` errori : ${stats.errors.join(" | ")}`); if (stats.failed && !stats.ok) console.log(" (gateway non raggiungibile: riprova quando torna online)"); } } else if (cmd === "store") { // store con fallback offline: prova il gateway, altrimenti accoda const cfg = loadConfig(); const text = typeof opt("text", null) === "string" ? opt("text", null) : fs.readFileSync(String(opt("file", "/dev/stdin")), "utf8").trim(); const project = typeof opt("project", null) === "string" ? opt("project", null) : ""; if (!text || !project) { console.error('Uso: store --project P [--kind K] [--scope S] [--text "..."] | --file FILE [--queue-only]'); process.exit(2); } const payload = { text, project_id: project, kind: typeof opt("kind", null) === "string" ? opt("kind", null) : "fact", scope: typeof opt("scope", null) === "string" ? opt("scope", null) : "agent", agent_id: typeof opt("agent", null) === "string" ? opt("agent", null) : undefined, topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined, }; if (has("queue-only")) { const q = await queueStore(payload, { dbFile }); out(json ? q : `Accodato localmente: ${q.local_id} (in coda: ${q.queue_size})`); } else { const res = await submitOrQueue(cfg, payload, { dbFile }); if (json) out(res); else if (res.queued) out(`Gateway non raggiungibile (HTTP ${res.status}): accodato localmente ${res.local_id} (in coda: ${res.queue_size}). Flush: qmem-sqlite flush`); else if (res.remote_id) out(`Salvato sul gateway: ${res.remote_id}`); else out(`Errore HTTP ${res.status}: ${JSON.stringify(res.data)}`); } } else if (cmd === "queue") { const stats = await queueStats({ dbFile }); const items = await queueList({ dbFile, status: typeof opt("status", null) === "string" ? opt("status", null) : undefined, limit: Number(opt("limit", 20)) || 20 }); if (json) out({ stats, items }); else { console.log(`Coda offline (${dbFile})`); console.log(` in attesa: ${stats.queued} sincronizzati: ${stats.synced} duplicati: ${stats.duplicate} falliti: ${stats.failed}`); if (stats.oldestQueued) console.log(` più vecchio: ${stats.oldestQueued}`); if (stats.lastError) console.log(` ultimo errore: ${stats.lastError.slice(0, 140)}`); items.forEach((q, i) => console.log(` ${i + 1}. [${q.status}] ${q.local_id.slice(0, 8)} [${q.payload.kind ?? "fact"}/${q.payload.project_id}] ${String(q.payload.text).slice(0, 70)}`)); } } else if (cmd === "flush") { const cfg = loadConfig(); const res = await flushQueue(cfg, { dbFile, limit: Number(opt("limit", 200)) || 200, paceMs: Number(opt("pace", 300)) }); if (json) out(res); else { console.log(`Flush outbox verso ${cfg.url}`); console.log(` sincronizzati: ${res.synced} duplicati: ${res.duplicates} falliti: ${res.failed} ancora in coda: ${res.remaining}`); if (res.stopped) console.log(` fermato: ${res.stopped}`); if (res.errors.length) console.log(` errori: ${res.errors.join(" | ")}`); } } else if (cmd === "breaker") { if (has("reset")) { const br = resetBreaker(); out(json ? br : `Circuit breaker: chiuso (fallimenti ${br.failures})`); } else { const br = breakerInfo(); if (json) out(br); else { console.log(`Circuit breaker qmem (${br.file})`); console.log(` stato: ${br.open ? `APERTO — riprova tra ${Math.ceil(br.remainingMs / 1000)}s` : "chiuso"}`); console.log(` fallimenti consecutivi: ${br.failures} | aperture totali: ${br.trips}`); if (br.lastError) console.log(` ultimo errore: ${br.lastError}`); if (br.lastChange) console.log(` ultimo cambio: ${br.lastChange}`); console.log(" reset: qmem-sqlite breaker --reset"); } } } else if (cmd === "pull") { const cfg = loadConfig(); const res = await pullFromGatewayExport(cfg, { dbFile, limit: Number(opt("limit", 500)) || 500 }); if (json) out(res); else { console.log(`Pull export dal gateway (${cfg.url})`); console.log(` supportato: ${res.supported} pagine: ${res.pages} record: ${res.fetched}${res.message ? ` — ${res.message}` : ""}`); } } else { console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | breaker | enrich | pull`); process.exit(2); } void DEFAULT_DB_FILE;