Files
pi-qmem/scripts/qmem-sqlite.mjs
T
Matteo Benedetto 1832562a7f feat: indice locale SQLite/FTS5 + fallback testuale offline per qmem
Il gateway remoto non è sempre raggiungibile (VPN/nodi giù): finora le ricerche
fallivano e la conoscenza non era consultabile. Ora l'estensione mantiene un
indice locale testuale e vi degrada automaticamente.

Core (extensions/local-db.ts):
- schema SQLite con FTS5 (unicode61 remove_diacritics 2), trigger di sync,
  tabella meta per cursori/stato; usa node:sqlite (Node >= 22.5, nessuna
  dipendenza esterna), con soppressione del warning "experimental"
- import idempotente dalle sessioni pi (tutte le directory di progetto):
  qmem_store/qmem_correct (ID + testo integrale), qmem_get (payload completo),
  qmem_search (record osservati, anche creati da altri agenti)
- merge senza regressioni: le osservazioni povere (es. search senza
  project_id) non azzerano i campi già noti; superseded_by monotono
- ricerca FTS5 con filtri (kind/project/scope/level/topic), esclusione di
  superseduti e privati, ranking bm25, snippet, ripiego AND -> OR dichiarato
- enrich dal gateway (GET /v1/memories/{id}, pacing < rate limit, timeout 8s
  per richiesta, stop al primo guasto) e pull da /v1/memories:export (endpoint
  lato gateway previsto: se assente lo segnala senza errore)
- localGet per il recupero puntuale offline

Estensione:
- qmem_search: su 0/429/5xx degrada all'indice locale, risultati etichettati
  "INDICE LOCALE, ricerca testuale non neurale" + details.fallback=local_sqlite
- qmem_get: fallback locale per UUID
- qmem_store: avviso esplicito che il record NON è salvato (nessuna coda)
- rendering arricchito con project_id e flag privato (anche per il gateway)
- comando /qmem:local status|import|find|enrich|pull
- regole e skill aggiornate: quando si usa l'indice locale non applicare le
  soglie 0.45/0.60 (sono semantiche)

CLI standalone (stesso core): scripts/qmem-sqlite.mjs status|import|find|
enrich|pull (+ --json). Test: scripts/test-local.mjs (14 controlli, HOME
temporanea, sessioni sintetiche, gateway black-hole e stub HTTP).

Verifiche: 14/14 test superati; import reale 185 sessioni -> 1046 record unici
(1032 con testo, 986 attivi, 60 superseduti, 672 con project_id, 20 gruppi di
duplicati) in 2,8 MB; enrich con gateway giù si ferma in ~16s con messaggio
chiaro invece di restare appeso.
2026-09-13 15:53:56 +02:00

137 lines
6.1 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 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, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, sessionRoots } = 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 ?? "-"}`);
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] [--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"),
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 === "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 | enrich | pull`);
process.exit(2);
}
void DEFAULT_DB_FILE;