Files
pi-qmem/extensions/tools/store.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

141 lines
5.2 KiB
TypeScript

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { gatewayRequest, loadConfig } from "../shared";
export function registerQmemStore(pi: ExtensionAPI) {
pi.registerTool({
name: "qmem_store",
label: "Qmem memory store",
description:
"Store one compact, high-signal memory record. project_id is required; do not store raw transcripts.",
parameters: Type.Object({
text: Type.String({ description: "Compact memory content." }),
kind: Type.Optional(
Type.Union(
[Type.Literal("decision"), Type.Literal("fact"), Type.Literal("episode"), Type.Literal("preference")],
{ description: "Record kind; default fact." },
),
),
agent_id: Type.Optional(Type.String({ description: "Writer provenance." })),
project_id: Type.String({
description: "Required project ID, kebab-case.",
}),
scope: Type.Optional(
Type.Union([Type.Literal("agent"), Type.Literal("project"), Type.Literal("org")], {
description: "Scope; default agent.",
}),
),
confidence: Type.Optional(
Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")], {
description: "Confidence; default medium.",
}),
),
source: Type.Optional(Type.String({ description: "Source label." })),
expires_at: Type.Optional(
Type.String({
description: "ISO 8601 expiry; omit for permanent records.",
}),
),
supersedes_id: Type.Optional(Type.String({ description: "UUID replaced by this record." })),
supersede_reason: Type.Optional(Type.String({ description: "Reason for replacement." })),
parent_id: Type.Optional(Type.String({ description: "Parent UUID." })),
level: Type.Optional(
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
description: "Hierarchy level.",
}),
),
topic: Type.Optional(Type.String({ description: "Hierarchy topic ID." })),
private: Type.Optional(
Type.Boolean({
description: "Hide from standard search; use only for sensitive data.",
}),
),
links: Type.Optional(
Type.Array(
Type.Object({
target_id: Type.String({ description: "Target UUID." }),
predicate: Type.Optional(Type.String({ description: "Relation type." })),
weight: Type.Optional(Type.Number({ description: "Relation weight; default 1.0." })),
}),
{ description: "Related records." },
),
),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const cfg = loadConfig();
if (!cfg.apiKey) {
return {
content: [{ type: "text", text: "Config mancante: esegui /qmem:config per impostare url e apiKey." }],
details: { error: "missing_config" },
};
}
const p = params as any;
onUpdate?.({ content: [{ type: "text", text: "qmem: salvataggio record..." }] });
// Rilevamento duplicati: cerca prima di salvare (soglia alta, non blocca)
let dupes: any[] = [];
const dupCheck = await gatewayRequest(
cfg,
"POST",
"/v1/memories:search",
{ query: p.text, project_id: p.project_id, top_k: 3, min_score: 0.92, include_superseded: false },
signal,
);
if (dupCheck.ok) dupes = dupCheck.data.results ?? [];
// Idempotency: stessa key per tutta l'operazione (e per eventuali retry)
const idemKey = crypto.randomUUID();
const { ok, status, data } = await gatewayRequest(
cfg,
"POST",
"/v1/memories",
{
text: p.text,
kind: p.kind ?? "fact",
agent_id: p.agent_id,
project_id: p.project_id,
scope: p.scope ?? "agent",
source: p.source,
confidence: p.confidence ?? "medium",
expires_at: p.expires_at,
supersedes_id: p.supersedes_id,
supersede_reason: p.supersede_reason,
parent_id: p.parent_id,
private: p.private ?? false,
level: p.level,
topic: p.topic,
links: p.links,
},
signal,
idemKey,
);
if (!ok) {
const down = status === 0 || status >= 500 || status === 429;
return {
content: [
{
type: "text",
text:
`Errore ${status}: ${JSON.stringify(data)}` +
(down
? "\n⚠️ Il record NON è stato salvato: nessuna coda locale (il gateway è la fonte di verità). Riprova quando è raggiungibile, oppure annota il contenuto e usa /qmem:local import per l'indice testuale."
: ""),
},
],
details: { error: "gateway_error", status },
};
}
const dupWarning =
dupes.length > 0
? "\n⚠️ Possibili duplicati (score >= 0.92, stesso project_id):\n" +
dupes.map((d: any) => ` - ${d.memory_id} (score ${d.score}): ${String(d.text).slice(0, 100)}`).join("\n") +
"\nValuta se il nuovo record è davvero necessario o se conviene qmem_correct sul duplicato."
: "";
const extra = `${p.level ? `, level ${p.level}` : ""}${p.topic ? `, topic ${p.topic}` : ""}${p.parent_id ? ` — parent ${p.parent_id}` : ""}`;
return {
content: [{ type: "text", text: `Memoria salvata: ${data.memory_id} (${p.kind ?? "fact"}, scope ${p.scope ?? "agent"}${extra})${p.supersedes_id ? ` — supersede ${p.supersedes_id}` : ""}${dupWarning}` }],
details: { memory_id: data.memory_id, created_at: data.created_at, duplicates: dupes.length, parent_id: p.parent_id, level: p.level, topic: p.topic },
};
},
});
}