Files
pi-qmem/extensions/local-command.ts
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

126 lines
5.0 KiB
TypeScript

/**
* pi-qmem — comando /qmem:local: gestione dell'indice locale SQLite/FTS5.
*
* /qmem:local → stato (record, copertura, lag, duplicati)
* /qmem:local import → ricostruisce/aggiorna l'indice dalle sessioni pi
* /qmem:local find <query> → ricerca testuale locale (anche con gateway giù)
* /qmem:local enrich [--all] → arricchisce dal gateway (GET /v1/memories/{id})
* /qmem:local pull → pull incrementale dall'export del gateway
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
enrichFromGateway,
importFromSessions,
localDbPath,
localDbReport,
localSearch,
pullFromGatewayExport,
} from "./local-db.ts";
import { loadConfig } from "./shared.ts";
export function registerQmemLocal(pi: ExtensionAPI) {
pi.registerCommand("qmem:local", {
description: "Indice locale SQLite/FTS5: status | import | find <query> | enrich [--all] | pull",
handler: async (args, ctx) => {
const cfg = loadConfig();
const dbFile = localDbPath(cfg);
const [sub = "status", ...rest] = (args ?? "").trim().split(/\s+/);
try {
if (sub === "status") {
const r = await localDbReport({ dbFile });
if (!r.exists) {
ctx.ui.notify(`Indice locale assente (${dbFile}): esegui /qmem:local import`, "warning");
return;
}
const dup = r.duplicates.length ? ` | duplicati: ${r.duplicates.length} gruppi` : "";
ctx.ui.notify(
`Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}\n` +
`ultimo import: ${r.lastImport ?? "-"} | enrich: ${r.lastEnrich ?? "-"} | export: ${r.lastExport ?? "-"}\n` +
`DB: ${r.path}`,
"info",
);
return;
}
if (sub === "import") {
ctx.ui.setStatus("pi-qmem", "Import sessioni → indice locale...");
const stats = await importFromSessions({ dbFile });
const r = await localDbReport({ dbFile });
ctx.ui.setStatus("pi-qmem", "");
ctx.ui.notify(
`Import completato: ${stats.files} sessioni, store=${stats.store} correct=${stats.correct} get=${stats.get} search_hit=${stats.searchHits}${stats.records} record unici.\n` +
`Indice: ${r.total} record (${r.withText} con testo, ${r.active} attivi) in ${r.sizeKb} KB`,
"info",
);
return;
}
if (sub === "find") {
const query = rest.filter((a) => !a.startsWith("--")).join(" ").trim();
if (!query) {
ctx.ui.notify("Uso: /qmem:local find <query> [--all] [--kind K] [--project P]", "warning");
return;
}
const has = (f: string) => rest.includes(`--${f}`);
const val = (f: string) => {
const i = rest.indexOf(`--${f}`);
return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[i + 1] : undefined;
};
const hits = await localSearch(
{
query,
kind: val("kind"),
project_id: val("project"),
scope: val("scope"),
include_superseded: has("all"),
include_private: has("private"),
top_k: Number(val("top")) || 5,
},
{ dbFile },
);
if (!hits.length) {
ctx.ui.notify(`Nessun risultato locale per "${query}" (indice: ${dbFile})`, "warning");
return;
}
const lines = hits.map(
(h, i) =>
`${i + 1}. [${h.kind ?? "?"}/${h.scope ?? "?"}${h.project_id ? ` project=${h.project_id}` : ""}${h.match_mode === "or" ? " OR" : ""}${h.superseded_by ? " superseduto" : ""}] ${h.snippet}\n (id: ${h.memory_id}, creato: ${h.created_at ?? "?"})`,
);
ctx.ui.notify(`Indice locale (ricerca testuale, non neurale) — ${hits.length} risultati:\n${lines.join("\n")}`, "info");
return;
}
if (sub === "enrich") {
ctx.ui.setStatus("pi-qmem", "Arricchimento dal gateway...");
const stats = await enrichFromGateway(cfg, {
dbFile,
onlyIncomplete: !rest.includes("--all"),
limit: 1000,
});
ctx.ui.setStatus("pi-qmem", "");
ctx.ui.notify(
stats.ok
? `Arricchimento: ${stats.updated} record aggiornati (richiesti ${stats.requested}, falliti ${stats.failed})`
: `Arricchimento non possibile: gateway non raggiungibile (${stats.errors[0] ?? "errore di rete"})`,
stats.ok ? "info" : "warning",
);
return;
}
if (sub === "pull") {
ctx.ui.setStatus("pi-qmem", "Pull export dal gateway...");
const res = await pullFromGatewayExport(cfg, { dbFile });
ctx.ui.setStatus("pi-qmem", "");
ctx.ui.notify(
res.supported
? `Pull export: ${res.fetched} record in ${res.pages} pagine`
: `Pull export non disponibile: ${res.message ?? "endpoint assente sul gateway"}`,
res.supported ? "info" : "warning",
);
return;
}
ctx.ui.notify("Uso: /qmem:local [status|import|find <query>|enrich [--all]|pull]", "warning");
} catch (e) {
ctx.ui.setStatus("pi-qmem", "");
ctx.ui.notify(`Errore indice locale: ${e instanceof Error ? e.message : String(e)}`, "error");
}
},
});
}