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.
This commit is contained in:
Matteo Benedetto
2026-09-13 15:53:56 +02:00
parent 1b293efb2c
commit 1832562a7f
12 changed files with 1327 additions and 36 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/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;
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Test dell'indice locale qmem (SQLite/FTS5) e del fallback offline.
*
* Isola tutto in una HOME temporanea:
* - sessioni pi sintetiche (store / correct / search / get)
* - config con gateway "black hole" (127.0.0.1:9 → connessione rifiutata)
* - stub HTTP locale per testare enrich e pull
*
* Uso: node scripts/test-local.mjs (esce != 0 se un controllo fallisce)
*/
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.dirname(HERE);
const PI_BASE =
process.env.PI_BASE ??
"/home/enne2/.local/share/pi-node/node-v22.23.2-linux-x64/lib/node_modules/@earendil-works/pi-coding-agent";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "qmem-test-"));
const HOME = path.join(TMP, "home");
const SESS_DIR = path.join(HOME, ".pi", "agent", "sessions", "--tmp--");
const DB = path.join(HOME, "qmem.sqlite");
const CONFIG = path.join(HOME, ".config", "pi-qmem", "config.json");
const ID_A = "11111111-1111-4111-8111-111111111111"; // store, attivo
const ID_B = "22222222-2222-4222-8222-222222222222"; // store poi superseduto
const ID_C = "33333333-3333-4333-8333-333333333333"; // correzione (nuovo)
const ID_D = "44444444-4444-4444-8444-444444444444"; // visto in qmem_search (senza project_id)
const ID_E = "55555555-5555-4555-8555-555555555555"; // visto in qmem_get
fs.mkdirSync(SESS_DIR, { recursive: true });
fs.mkdirSync(path.dirname(CONFIG), { recursive: true });
// ---------------------------------------------------------------- sessioni sintetiche
const entry = (obj) => JSON.stringify(obj);
const call = (id, name, args, ts) => entry({ type: "message", id, timestamp: ts, message: { role: "assistant", content: [{ type: "toolCall", id: `c-${id}`, name, arguments: args }] } });
const result = (id, name, text, details, ts) => entry({ type: "message", id, timestamp: ts, message: { role: "toolResult", toolCallId: `c-${id}`, toolName: name, content: [{ type: "text", text }], details } });
const T = "2026-09-01T10:00:0";
const lines = [
entry({ type: "session", version: 3, id: "synthetic", timestamp: `${T}0.000Z`, cwd: "/tmp" }),
// 1) store attivo
call("m1", "qmem_store", { text: "Il fallback locale usa SQLite FTS5 con tokenizer unicode61 per la ricerca testuale offline.", kind: "decision", project_id: "test-project", scope: "agent", agent_id: "tester" }, `${T}1.000Z`),
result("m1", "qmem_store", `Memoria salvata: ${ID_A} (decision, scope agent)`, { memory_id: ID_A, created_at: `${T}1.000Z` }, `${T}1.100Z`),
// 2) store che verrà corretto
call("m2", "qmem_store", { text: "Il gateway remoto è sempre raggiungibile via VPN.", kind: "fact", project_id: "test-project", scope: "agent" }, `${T}2.000Z`),
result("m2", "qmem_store", `Memoria salvata: ${ID_B} (fact, scope agent)`, { memory_id: ID_B, created_at: `${T}2.000Z` }, `${T}2.100Z`),
// 3) correzione: il gateway remoto NON è sempre raggiungibile
call("m3", "qmem_correct", { memory_id: ID_B, text: "Il gateway remoto NON è sempre raggiungibile: serve un fallback locale per la ricerca.", reason: "verificato outage" }, `${T}3.000Z`),
result("m3", "qmem_correct", `Correzione applicata: nuovo record ${ID_C} supersede ${ID_B}.`, { new_id: ID_C, superseded_id: ID_B, reparented: 0 }, `${T}3.100Z`),
// 4) risultato di ricerca (record creato altrove, senza project_id nel rendering)
call("m4", "qmem_search", { query: "backup qdrant snapshot" }, `${T}4.000Z`),
result("m4", "qmem_search", `1. [fact/org score=0.71] Backup giornaliero: snapshot Qdrant + rsync in /home/enne2/archive/backups\n (id: ${ID_D}, agente: pi, creato: 2026-08-16T06:00:00Z)`, { hits: 1 }, `${T}4.100Z`),
// 5) qmem_get
call("m5", "qmem_get", { memory_id: ID_E }, `${T}5.000Z`),
result("m5", "qmem_get", `memory_id: ${ID_E}\n[episode/agent conf=medium] | project: infra-security | agente: shared, creato: 2026-08-14T09:00:00Z\n\nRollback del firewall: ripristinare la regola precedente e verificare con nmap.`, { memory_id: ID_E, kind: "episode", scope: "agent", project_id: "infra-security" }, `${T}5.100Z`),
];
fs.writeFileSync(path.join(SESS_DIR, "2026-09-01T10-00-00-000Z_test.jsonl"), lines.join("\n") + "\n");
// ---------------------------------------------------------------- config black-hole
fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, localDbPath: DB }, null, 2), { mode: 0o600 });
const env = { ...process.env, HOME, QMEM_SQLITE: DB, QMEM_SESSIONS_DIR: path.join(HOME, ".pi", "agent", "sessions") };
// isolamento anche per il processo di test: l'estensione caricata in-process
// deve leggere la config/DB di test (non quelli reali dell'utente)
process.env.HOME = HOME;
process.env.QMEM_SQLITE = DB;
process.env.QMEM_SESSIONS_DIR = path.join(HOME, ".pi", "agent", "sessions");
const results = [];
const check = (name, ok, info = "") => {
results.push({ name, ok, info });
console.log(`${ok ? "✅" : "❌"} ${name}${info ? `${info}` : ""}`);
};
// ---------------------------------------------------------------- 1) CLI import
const runCli = (args) => spawnSync("node", [path.join(HERE, "qmem-sqlite.mjs"), ...args], { env, encoding: "utf8" });
// async: spawnSync bloccherebbe l'event loop e lo stub in-process non risponderebbe
const runCliAsync = (args) =>
new Promise((resolve) => {
const child = spawn("node", [path.join(HERE, "qmem-sqlite.mjs"), ...args], { env });
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", () => resolve(out));
});
const imp = runCli(["import", "--json"]);
let impJson = null;
try {
impJson = JSON.parse(imp.stdout);
} catch {
/* fallback su output testuale */
}
check("CLI import dai sessioni sintetiche", imp.status === 0 && impJson?.stats?.records >= 5, `record=${impJson?.stats?.records} store=${impJson?.stats?.store} correct=${impJson?.stats?.correct} get=${impJson?.stats?.get} search=${impJson?.stats?.searchHits}`);
// ---------------------------------------------------------------- 2) CLI find
const findOut = runCli(["find", "sqlite", "--json"]);
const hits = JSON.parse(findOut.stdout || "[]");
check("ricerca lessicale trova il record attivo", hits.some((h) => h.memory_id === ID_A), `${hits.length} hit`);
const findAll = JSON.parse(runCli(["find", "raggiungibile", "--all", "--json"]).stdout || "[]");
check("--all include i superseduti", findAll.some((h) => h.memory_id === ID_B || h.memory_id === ID_C), `${findAll.length} hit`);
const findActive = JSON.parse(runCli(["find", "raggiungibile", "--json"]).stdout || "[]");
check("default esclude i superseduti", !findActive.some((h) => h.memory_id === ID_B), `${findActive.length} hit`);
const projectFilter = JSON.parse(runCli(["find", "sqlite", "--project", "test-project", "--json"]).stdout || "[]");
check("filtro --project funziona", projectFilter.length === 1 && projectFilter[0].memory_id === ID_A);
const orMode = JSON.parse(runCli(["find", "firewall sqlite", "--json"]).stdout || "[]"); // termini non co-occorrenti → AND=0 → OR
check("ripiego OR su match parziale", orMode.length > 0 && orMode[0].match_mode === "or", `${orMode.length} hit`);
// ---------------------------------------------------------------- 3) estensione: fallback offline
const { createJiti } = await import(`${PI_BASE}/node_modules/jiti/lib/jiti.mjs`);
const jiti = createJiti(import.meta.url, {
interopDefault: true,
alias: {
"@earendil-works/pi-coding-agent": PI_BASE,
"@earendil-works/pi-tui": path.join(PI_BASE, "node_modules/@earendil-works/pi-tui"),
"@earendil-works/pi-ai": path.join(PI_BASE, "node_modules/@earendil-works/pi-ai"),
"@earendil-works/pi-agent-core": path.join(PI_BASE, "node_modules/@earendil-works/pi-agent-core"),
typebox: path.join(PI_BASE, "node_modules/typebox/build/index.mjs"),
},
});
const mod = await jiti.import(path.join(REPO, "extensions/index.ts"));
const tools = new Map();
const commands = new Map();
(mod.default ?? mod)({
on() {},
registerTool: (t) => tools.set(t.name, t),
registerCommand: (n, d) => commands.set(n, d),
registerShortcut() {},
registerFlag() {},
appendEntry() {},
});
check("estensione caricata (6 tool + comandi)", tools.size >= 6 && commands.has("qmem:local"), `tool=${[...tools.keys()].join(",")} cmd=${[...commands.keys()].join(",")}`);
const notices = [];
const ctx = {
mode: "print",
hasUI: false,
cwd: REPO,
ui: { notify: (m) => notices.push(m), setStatus() {}, select: async () => null, input: async () => null, confirm: async () => false, custom: () => ({}) },
sessionManager: { getSessionId: () => "test", getEntries: () => [], getSessionFile: () => undefined },
};
const search = await tools.get("qmem_search").execute("t1", { query: "sqlite fts5" }, undefined, undefined, ctx);
check("qmem_search → fallback locale con gateway giù", search.details?.fallback === "local_sqlite" && search.details?.hits > 0, `hits=${search.details?.hits} status=${search.details?.gateway_status}`);
check("risultato locale etichettato come testuale", /INDICE LOCALE/i.test(search.content[0].text) && /non neurale/i.test(search.content[0].text) && /sqlite/i.test(search.content[0].text));
const getRes = await tools.get("qmem_get").execute("t2", { memory_id: ID_E }, undefined, undefined, ctx);
if (process.env.DEBUG_GET) console.log("GET RESULT:", JSON.stringify(getRes, null, 1).slice(0, 900));
check("qmem_get → fallback locale per UUID", getRes.details?.fallback === "local_sqlite" && /nmap/.test(getRes.content[0].text));
const storeRes = await tools.get("qmem_store").execute("t3", { text: "prova", project_id: "test-project" }, undefined, undefined, ctx);
check("qmem_store avvisa che il record NON è salvato", /NON è stato salvato/i.test(storeRes.content[0].text));
// ---------------------------------------------------------------- 4) arricchimento dal gateway (stub)
const server = createServer((req, res) => {
if (req.url?.startsWith("/v1/memories:export")) {
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "non trovato" }));
return;
}
if (req.url === `/v1/memories/${ID_D}`) {
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({ memory_id: ID_D, text: "Backup giornaliero: snapshot Qdrant + rsync in /home/enne2/archive/backups (verificato)", kind: "fact", scope: "org", project_id: "infra-security", agent_id: "pi", created_at: "2026-08-16T06:00:00Z", private: true }),
);
return;
}
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "Memoria non trovata" }));
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const stub = `http://127.0.0.1:${server.address().port}`;
fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, localDbPath: DB }, null, 2), { mode: 0o600 });
const enrichOut = await runCliAsync(["enrich", "--json"]);
let enrich = { stdout: enrichOut };
let enrichJson = null;
try {
enrichJson = JSON.parse(enrich.stdout);
} catch {
/* ignore */
}
check("enrich dal gateway aggiorna i campi mancanti", (enrichJson?.updated ?? 0) >= 1, `requested=${enrichJson?.requested} updated=${enrichJson?.updated}`);
const after = runCli(["status", "--json"]);
const afterJson = JSON.parse(after.stdout || "{}");
check("project_id/private arricchiti nel DB", afterJson.withProject > 0, `con project_id: ${afterJson.withProject}/${afterJson.total}, privati: ${afterJson.private}`);
const pullOut = await runCliAsync(["pull", "--json"]);
const pull = { stdout: pullOut };
const pullJson = JSON.parse(pull.stdout || "{}");
check("pull segnala endpoint export assente", pullJson.supported === false, pullJson.message ?? "");
server.close();
// ---------------------------------------------------------------- report
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} controlli superati — HOME di test: ${HOME}`);
process.exit(failed.length ? 1 : 0);