Prepara il client al gateway 2.12.0 (export + soft delete), mantenendo la compatibilità con la 2.11.0 in produzione fino al redeploy. - tombstone: colonna `deleted_at` (+ migrazione), monotona nel merge, esclusa dalle ricerche locali di default; `--deleted` in CLI e /qmem:local find; conteggio nel report/status; marker 🗑 nei risultati di qmem_get - `pull` usa `include_deleted=true` e mappa l'intero payload dell'export (incluso deleted_at), così il mirror impara le cancellazioni - rate limit: pace del flush 600 ms (100 req/min < 120/min del gateway) e messaggio dedicato su 429 (prima 300-400 ms → possibile 429 con code grandi) - `qmem_get`: fallback sull'indice locale anche sul 404 (un id in coda non è ancora sul gateway) con etichetta "non presente sul gateway" - test: 27 controlli (nuovi: pull con tombstone, esclusione/visibilità tombstone, ricerca del record esportato) Verificato con la suite locale completa: 27/27.
195 lines
9.9 KiB
JavaScript
195 lines
9.9 KiB
JavaScript
#!/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 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 { loadConfig } = 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)`);
|
|
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 === "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 | enrich | pull`);
|
|
process.exit(2);
|
|
}
|
|
|
|
void DEFAULT_DB_FILE;
|