feat(sync): tombstone, pull dall'export, ritocchi flush e fallback su 404

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.
This commit is contained in:
Matteo Benedetto
2026-09-13 17:53:59 +02:00
parent d509778448
commit d1985514e1
6 changed files with 92 additions and 24 deletions
+5 -2
View File
@@ -77,9 +77,12 @@ testualmente la conoscenza **senza gateway, senza modelli, senza dipendenze**:
validazione). Le correzioni che puntano a un record ancora locale vengono validazione). Le correzioni che puntano a un record ancora locale vengono
rimappate all'ID remoto al flush. Trigger: `session_start` (background, non rimappate all'ID remoto al flush. Trigger: `session_start` (background, non
blocca l'avvio), dopo uno store riuscito, o `/qmem:local flush` blocca l'avvio), dopo uno store riuscito, o `/qmem:local flush`
- **Arricchimento**: quando il gateway torna online, `enrich` completa - **Arricchimento e pull**: quando il gateway torna online, `enrich` completa
testo/`project_id`/`private`/stato supersede via `GET /v1/memories/{id}`, e testo/`project_id`/`private`/stato supersede via `GET /v1/memories/{id}`, e
`pull` sincronizza dall'`export` (endpoint previsto lato gateway) `pull` sincronizza dall'**export paginato** (`GET /v1/memories:export`,
disponibile dal gateway **2.12.0** insieme al **soft delete**): i **tombstone**
(`deleted_at`) arrivano col record e vengono esclusi dall'indice locale
(visibili con `/qmem:local find --deleted`)
Comandi (TUI) e CLI standalone: Comandi (TUI) e CLI standalone:
+2 -1
View File
@@ -60,7 +60,7 @@ export function registerQmemLocal(pi: ExtensionAPI) {
if (sub === "find") { if (sub === "find") {
const query = rest.filter((a) => !a.startsWith("--")).join(" ").trim(); const query = rest.filter((a) => !a.startsWith("--")).join(" ").trim();
if (!query) { if (!query) {
ctx.ui.notify("Uso: /qmem:local find <query> [--all] [--kind K] [--project P]", "warning"); ctx.ui.notify("Uso: /qmem:local find <query> [--all] [--deleted] [--kind K] [--project P]", "warning");
return; return;
} }
const has = (f: string) => rest.includes(`--${f}`); const has = (f: string) => rest.includes(`--${f}`);
@@ -76,6 +76,7 @@ export function registerQmemLocal(pi: ExtensionAPI) {
scope: val("scope"), scope: val("scope"),
include_superseded: has("all"), include_superseded: has("all"),
include_private: has("private"), include_private: has("private"),
include_deleted: has("deleted"),
top_k: Number(val("top")) || 5, top_k: Number(val("top")) || 5,
}, },
{ dbFile }, { dbFile },
+49 -13
View File
@@ -92,7 +92,9 @@ CREATE TABLE IF NOT EXISTS records(
parent_id TEXT, topic TEXT, level TEXT, private INTEGER NOT NULL DEFAULT 0, parent_id TEXT, topic TEXT, level TEXT, private INTEGER NOT NULL DEFAULT 0,
text_hash TEXT, sources TEXT, synced_at TEXT, updated_at TEXT, text_hash TEXT, sources TEXT, synced_at TEXT, updated_at TEXT,
/** 1 = record creato offline, non ancora presente sul gateway */ /** 1 = record creato offline, non ancora presente sul gateway */
pending INTEGER NOT NULL DEFAULT 0 pending INTEGER NOT NULL DEFAULT 0,
/** Tombstone: istante di cancellazione sul gateway (soft delete) */
deleted_at TEXT
); );
CREATE INDEX IF NOT EXISTS records_project ON records(project_id); CREATE INDEX IF NOT EXISTS records_project ON records(project_id);
CREATE INDEX IF NOT EXISTS records_kind ON records(kind); CREATE INDEX IF NOT EXISTS records_kind ON records(kind);
@@ -132,6 +134,7 @@ CREATE INDEX IF NOT EXISTS pending_status ON pending(status);
.all() .all()
.map((c: any) => c.name); .map((c: any) => c.name);
if (!cols.includes("pending")) db.exec("ALTER TABLE records ADD COLUMN pending INTEGER NOT NULL DEFAULT 0"); if (!cols.includes("pending")) db.exec("ALTER TABLE records ADD COLUMN pending INTEGER NOT NULL DEFAULT 0");
if (!cols.includes("deleted_at")) db.exec("ALTER TABLE records ADD COLUMN deleted_at TEXT");
return db; return db;
} }
@@ -156,6 +159,8 @@ export interface LocalRecordInput {
private?: boolean | null; private?: boolean | null;
/** true = creato offline (in coda), false = presente sul gateway */ /** true = creato offline (in coda), false = presente sul gateway */
pending?: boolean | null; pending?: boolean | null;
/** Tombstone dal gateway (soft delete): se valorizzato il record è cancellato */
deleted_at?: string | null;
/** Fonte dell'osservazione: store | correct | get | search | export | enrich | outbox | sync */ /** Fonte dell'osservazione: store | correct | get | search | export | enrich | outbox | sync */
source: string; source: string;
} }
@@ -178,11 +183,11 @@ export function upsertRecords(db: Db, records: LocalRecordInput[]): number {
const find = db.prepare("SELECT * FROM records WHERE memory_id = ?"); const find = db.prepare("SELECT * FROM records WHERE memory_id = ?");
const insert = db.prepare(`INSERT INTO records const insert = db.prepare(`INSERT INTO records
(memory_id, text, kind, project_id, scope, agent_id, confidence, importance, created_at, (memory_id, text, kind, project_id, scope, agent_id, confidence, importance, created_at,
supersedes_id, superseded_by, parent_id, topic, level, private, text_hash, sources, synced_at, updated_at, pending) supersedes_id, superseded_by, parent_id, topic, level, private, text_hash, sources, synced_at, updated_at, pending, deleted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const update = db.prepare(`UPDATE records SET const update = db.prepare(`UPDATE records SET
text=?, kind=?, project_id=?, scope=?, agent_id=?, confidence=?, importance=?, created_at=?, text=?, kind=?, project_id=?, scope=?, agent_id=?, confidence=?, importance=?, created_at=?,
supersedes_id=?, superseded_by=?, parent_id=?, topic=?, level=?, private=?, text_hash=?, sources=?, synced_at=?, updated_at=?, pending=? supersedes_id=?, superseded_by=?, parent_id=?, topic=?, level=?, private=?, text_hash=?, sources=?, synced_at=?, updated_at=?, pending=?, deleted_at=?
WHERE rowid_=?`); WHERE rowid_=?`);
const now = new Date().toISOString(); const now = new Date().toISOString();
let written = 0; let written = 0;
@@ -217,18 +222,20 @@ export function upsertRecords(db: Db, records: LocalRecordInput[]): number {
now, now,
now, now,
r.pending === true ? 1 : r.pending === false ? 0 : (existing?.pending ?? 0), r.pending === true ? 1 : r.pending === false ? 0 : (existing?.pending ?? 0),
r.deleted_at ?? existing?.deleted_at ?? null,
]; ];
if (existing) { if (existing) {
// I campi già noti non vengono mai azzerati da osservazioni più povere // I campi già noti non vengono mai azzerati da osservazioni più povere
// (es. un risultato di ricerca che non riporta project_id). // (es. un risultato di ricerca che non riporta project_id).
const keys = ["text", "kind", "project_id", "scope", "agent_id", "confidence", "importance", "created_at", const keys = ["text", "kind", "project_id", "scope", "agent_id", "confidence", "importance", "created_at",
"supersedes_id", "superseded_by", "parent_id", "topic", "level", "private", "text_hash", "sources", "synced_at", "updated_at", "pending"]; "supersedes_id", "superseded_by", "parent_id", "topic", "level", "private", "text_hash", "sources", "synced_at", "updated_at", "pending", "deleted_at"];
const merged = vals.map((v, i) => { const merged = vals.map((v, i) => {
const key = keys[i]; const key = keys[i];
const cur = (existing as any)[key]; const cur = (existing as any)[key];
const curEmpty = cur === null || cur === undefined || cur === "" || cur === 0; const curEmpty = cur === null || cur === undefined || cur === "" || cur === 0;
if (key === "private") return cur === 1 ? 1 : v; // monotono: una volta privato resta privato if (key === "private") return cur === 1 ? 1 : v; // monotono: una volta privato resta privato
if (key === "pending") return v; // 0 = sincronizzato: prevale sullo stato locale if (key === "pending") return v; // 0 = sincronizzato: prevale sullo stato locale
if (key === "deleted_at") return cur ?? v; // tombstone monotono: una volta cancellato resta cancellato
if (key === "sources" || key === "synced_at" || key === "updated_at") return v; if (key === "sources" || key === "synced_at" || key === "updated_at") return v;
if (v === null || v === "" ) return curEmpty ? v : cur; // non azzerare if (v === null || v === "" ) return curEmpty ? v : cur; // non azzerare
return v; return v;
@@ -432,6 +439,8 @@ export interface LocalSearchParams {
topic?: string; topic?: string;
include_superseded?: boolean; include_superseded?: boolean;
include_private?: boolean; include_private?: boolean;
/** Includi i tombstone (record cancellati sul gateway) */
include_deleted?: boolean;
top_k?: number; top_k?: number;
/** true = match esatto della frase, senza espansione prefisso */ /** true = match esatto della frase, senza espansione prefisso */
exact?: boolean; exact?: boolean;
@@ -449,6 +458,7 @@ export interface LocalSearchHit {
sources: string | null; sources: string | null;
/** 1 = creato offline, non ancora sul gateway */ /** 1 = creato offline, non ancora sul gateway */
pending?: number; pending?: number;
deleted_at?: string | null;
snippet: string; snippet: string;
rank: number; rank: number;
/** "and" = tutti i termini presenti; "or" = match parziale (AND senza risultati) */ /** "and" = tutti i termini presenti; "or" = match parziale (AND senza risultati) */
@@ -490,10 +500,11 @@ async function runSearch(db: Db, params: LocalSearchParams, match: string): Prom
args.push(params.topic); args.push(params.topic);
} }
if (!params.include_superseded) where.push("r.superseded_by IS NULL"); if (!params.include_superseded) where.push("r.superseded_by IS NULL");
if (!params.include_deleted) where.push("r.deleted_at IS NULL");
if (!params.include_private) where.push("r.private = 0"); if (!params.include_private) where.push("r.private = 0");
args.push(Math.min(Math.max(params.top_k ?? 5, 1), 50)); args.push(Math.min(Math.max(params.top_k ?? 5, 1), 50));
const sql = `SELECT r.memory_id, r.text, r.kind, r.project_id, r.scope, r.agent_id, r.created_at, const sql = `SELECT r.memory_id, r.text, r.kind, r.project_id, r.scope, r.agent_id, r.created_at,
r.superseded_by, r.sources, r.pending, bm25(records_fts) AS rank, r.superseded_by, r.sources, r.pending, r.deleted_at, bm25(records_fts) AS rank,
snippet(records_fts, 0, '«', '»', '…', 14) AS snippet snippet(records_fts, 0, '«', '»', '…', 14) AS snippet
FROM records_fts JOIN records r ON r.rowid_ = records_fts.rowid FROM records_fts JOIN records r ON r.rowid_ = records_fts.rowid
WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`; WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`;
@@ -527,7 +538,7 @@ export async function localGet(memoryId: string, opts?: { dbFile?: string }): Pr
try { try {
const row = db const row = db
.prepare( .prepare(
`SELECT memory_id, text, kind, project_id, scope, agent_id, created_at, superseded_by, sources, pending, 0 AS rank, `SELECT memory_id, text, kind, project_id, scope, agent_id, created_at, superseded_by, sources, pending, deleted_at, 0 AS rank,
COALESCE(substr(text,1,200),'') AS snippet COALESCE(substr(text,1,200),'') AS snippet
FROM records WHERE memory_id = ?`, FROM records WHERE memory_id = ?`,
) )
@@ -662,7 +673,7 @@ export async function pullFromGatewayExport(
let pages = 0; let pages = 0;
let fetched = 0; let fetched = 0;
for (; pages < maxPages; pages++) { for (; pages < maxPages; pages++) {
const route = `/v1/memories:export?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; const route = `/v1/memories:export?limit=${limit}&include_deleted=true${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const { ok, status, data } = await gatewayRequest( const { ok, status, data } = await gatewayRequest(
cfg, cfg,
"GET", "GET",
@@ -676,7 +687,29 @@ export async function pullFromGatewayExport(
if (!ok) return { supported: false, pages, fetched, message: `export fallito: HTTP ${status}` }; if (!ok) return { supported: false, pages, fetched, message: `export fallito: HTTP ${status}` };
const items = data?.results ?? data?.records ?? []; const items = data?.results ?? data?.records ?? [];
if (Array.isArray(items) && items.length) { if (Array.isArray(items) && items.length) {
upsertRecords(db, items.map((r: any) => ({ ...r, source: "export" }))); upsertRecords(
db,
items.map((r: any) => ({
memory_id: r.memory_id ?? r.id,
text: r.text,
kind: r.kind,
project_id: r.project_id,
scope: r.scope,
agent_id: r.agent_id,
confidence: r.confidence,
importance: typeof r.importance === "number" ? r.importance : null,
created_at: r.created_at,
supersedes_id: r.supersedes_id,
superseded_by: r.superseded_by,
parent_id: r.parent_id,
topic: r.topic,
level: r.level,
private: r.private,
deleted_at: r.deleted_at ?? null,
pending: false,
source: "export",
})),
);
fetched += items.length; fetched += items.length;
} }
cursor = data?.next_cursor ?? ""; cursor = data?.next_cursor ?? "";
@@ -864,7 +897,7 @@ export async function flushQueue(
const items = db const items = db
.prepare("SELECT local_id, payload FROM pending WHERE status='queued' ORDER BY id LIMIT ?") .prepare("SELECT local_id, payload FROM pending WHERE status='queued' ORDER BY id LIMIT ?")
.all(opts?.limit ?? 100) as Array<{ local_id: string; payload: string }>; .all(opts?.limit ?? 100) as Array<{ local_id: string; payload: string }>;
const pace = opts?.paceMs ?? 300; // < rate limit del gateway (120/min) const pace = opts?.paceMs ?? 600; // 100 richieste/min (< rate limit 120/min)
const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 12_000); const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 12_000);
for (const item of items) { for (const item of items) {
stats.processed++; stats.processed++;
@@ -962,7 +995,7 @@ export async function flushQueue(
} }
} else { } else {
// 0/429/5xx: gateway non utilizzabile, resta in coda // 0/429/5xx: gateway non utilizzabile, resta in coda
const msg = `HTTP ${res.status}`; const msg = res.status === 429 ? "rate limit del gateway (HTTP 429): ripreso al prossimo flush" : `HTTP ${res.status}`;
db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id); db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`); stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
stats.stopped = msg; stats.stopped = msg;
@@ -1035,7 +1068,7 @@ let flushInFlight: Promise<unknown> | null = null;
/** Flush in background (single-flight): usato dopo operazioni riuscite e su session_start. */ /** Flush in background (single-flight): usato dopo operazioni riuscite e su session_start. */
export function maybeBackgroundFlush(cfg: MemoryConfig, dbFile?: string): void { export function maybeBackgroundFlush(cfg: MemoryConfig, dbFile?: string): void {
if (flushInFlight) return; if (flushInFlight) return;
flushInFlight = flushQueue(cfg, { dbFile, limit: 20, paceMs: 400 }) flushInFlight = flushQueue(cfg, { dbFile, limit: 20, paceMs: 600 })
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
flushInFlight = null; flushInFlight = null;
@@ -1045,7 +1078,7 @@ export function maybeBackgroundFlush(cfg: MemoryConfig, dbFile?: string): void {
export async function flushQueueIfPending(cfg: MemoryConfig, dbFile?: string): Promise<FlushStats | null> { export async function flushQueueIfPending(cfg: MemoryConfig, dbFile?: string): Promise<FlushStats | null> {
const stats = await queueStats({ dbFile }); const stats = await queueStats({ dbFile });
if (!stats.queued) return null; if (!stats.queued) return null;
return flushQueue(cfg, { dbFile, limit: 50, paceMs: 300 }); return flushQueue(cfg, { dbFile, limit: 50, paceMs: 600 });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1070,6 +1103,7 @@ export interface LocalDbReport {
duplicateQueue: number; duplicateQueue: number;
failedQueue: number; failedQueue: number;
pendingInIndex: number; pendingInIndex: number;
deleted: number;
oldestQueued?: string | null; oldestQueued?: string | null;
queueLastError?: string | null; queueLastError?: string | null;
topProjects: Array<{ project_id: string | null; n: number }>; topProjects: Array<{ project_id: string | null; n: number }>;
@@ -1094,6 +1128,7 @@ export async function localDbReport(opts?: { dbFile?: string }): Promise<LocalDb
duplicateQueue: 0, duplicateQueue: 0,
failedQueue: 0, failedQueue: 0,
pendingInIndex: 0, pendingInIndex: 0,
deleted: 0,
topProjects: [], topProjects: [],
duplicates: [], duplicates: [],
}; };
@@ -1123,6 +1158,7 @@ export async function localDbReport(opts?: { dbFile?: string }): Promise<LocalDb
report.duplicateQueue = one("SELECT COUNT(*) n FROM pending WHERE status='duplicate'"); report.duplicateQueue = one("SELECT COUNT(*) n FROM pending WHERE status='duplicate'");
report.failedQueue = one("SELECT COUNT(*) n FROM pending WHERE status='failed'"); report.failedQueue = one("SELECT COUNT(*) n FROM pending WHERE status='failed'");
report.pendingInIndex = one("SELECT COUNT(*) n FROM records WHERE pending = 1"); report.pendingInIndex = one("SELECT COUNT(*) n FROM records WHERE pending = 1");
report.deleted = one("SELECT COUNT(*) n FROM records WHERE deleted_at IS NOT NULL");
report.oldestQueued = db.prepare("SELECT created_at FROM pending WHERE status='queued' ORDER BY id LIMIT 1").get()?.created_at ?? null; report.oldestQueued = db.prepare("SELECT created_at FROM pending WHERE status='queued' ORDER BY id LIMIT 1").get()?.created_at ?? null;
report.queueLastError = db.prepare("SELECT last_error FROM pending WHERE last_error IS NOT NULL ORDER BY id DESC LIMIT 1").get()?.last_error ?? null; report.queueLastError = db.prepare("SELECT last_error FROM pending WHERE last_error IS NOT NULL ORDER BY id DESC LIMIT 1").get()?.last_error ?? null;
report.topProjects = db.prepare("SELECT project_id, COUNT(*) n FROM records GROUP BY project_id ORDER BY n DESC LIMIT 8").all() as any; report.topProjects = db.prepare("SELECT project_id, COUNT(*) n FROM records GROUP BY project_id ORDER BY n DESC LIMIT 8").all() as any;
+14 -5
View File
@@ -25,21 +25,30 @@ export function registerQmemGet(pi: ExtensionAPI) {
const { ok, status, data } = await gatewayRequest(cfg, "GET", `/v1/memories/${p.memory_id}`, undefined, signal); const { ok, status, data } = await gatewayRequest(cfg, "GET", `/v1/memories/${p.memory_id}`, undefined, signal);
if (!ok) { if (!ok) {
const notFound = status === 404 || data?.detail === "Memoria non trovata"; const notFound = status === 404 || data?.detail === "Memoria non trovata";
if (!notFound) { // Anche sul 404 si consulta l'indice locale: l'id può essere di un record
// Gateway non raggiungibile: tentativo sull'indice locale (SQLite/FTS5) // creato offline (in coda, non ancora sul gateway).
{
try { try {
const local = await localGet(p.memory_id, { dbFile: localDbPath(cfg) }); const local = await localGet(p.memory_id, { dbFile: localDbPath(cfg) });
if (local) { if (local) {
const origine = notFound
? "non presente sul gateway (404): record dall'INDICE LOCALE"
: `gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE`;
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: text:
`⚠️ Gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE (osservazione più vecchia del gateway, può essere incompleta).\n` + `⚠️ ${origine} (osservazione più vecchia del gateway, può essere incompleta).\n` +
`memory_id: ${local.memory_id}\n[${local.kind ?? "?"}/${local.scope ?? "?"}${local.project_id ? ` project=${local.project_id}` : ""}] agente: ${local.agent_id ?? "?"}, creato: ${local.created_at ?? "?"}${local.pending ? ", ⏳ creato offline: non ancora sul gateway" : ""}${local.superseded_by ? `, ⚠️ superseduto da ${local.superseded_by}` : ""}${local.remote_id ? `, sincronizzato come ${local.remote_id}` : ""}\n\n${local.text ?? "(nessun testo)"}`, `memory_id: ${local.memory_id}\n[${local.kind ?? "?"}/${local.scope ?? "?"}${local.project_id ? ` project=${local.project_id}` : ""}] agente: ${local.agent_id ?? "?"}, creato: ${local.created_at ?? "?"}${local.pending ? ", ⏳ creato offline: non ancora sul gateway" : ""}${local.superseded_by ? `, ⚠️ superseduto da ${local.superseded_by}` : ""}${local.deleted_at ? `, 🗑 cancellato sul gateway (${local.deleted_at})` : ""}${local.remote_id ? `, sincronizzato come ${local.remote_id}` : ""}\n\n${local.text ?? "(nessun testo)"}`,
}, },
], ],
details: { memory_id: local.memory_id, fallback: "local_sqlite", gateway_status: status }, details: {
memory_id: local.memory_id,
fallback: "local_sqlite",
gateway_status: status,
pending: local.pending === 1,
},
}; };
} }
} catch { } catch {
+3 -1
View File
@@ -64,6 +64,7 @@ if (cmd === "status") {
if (r.queueLastError) console.log(` ultimo errore : ${r.queueLastError.slice(0, 120)}`); 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.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(", ") || "-"}`); console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`);
if (r.duplicates.length) { 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(", ")}`); console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`);
@@ -86,7 +87,7 @@ if (cmd === "status") {
} else if (cmd === "find") { } else if (cmd === "find") {
const query = argv[1] && !argv[1].startsWith("--") ? argv[1] : ""; const query = argv[1] && !argv[1].startsWith("--") ? argv[1] : "";
if (!query) { if (!query) {
console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--exact] [--json]'); console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--deleted] [--exact] [--json]');
process.exit(2); process.exit(2);
} }
const hits = await localSearch( const hits = await localSearch(
@@ -99,6 +100,7 @@ if (cmd === "status") {
topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined, topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined,
include_superseded: has("all"), include_superseded: has("all"),
include_private: has("private"), include_private: has("private"),
include_deleted: has("deleted"),
exact: has("exact"), exact: has("exact"),
top_k: Number(opt("top", 5)) || 5, top_k: Number(opt("top", 5)) || 5,
}, },
+19 -2
View File
@@ -33,6 +33,8 @@ const ID_B = "22222222-2222-4222-8222-222222222222"; // store poi superseduto
const ID_C = "33333333-3333-4333-8333-333333333333"; // correzione (nuovo) 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_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 const ID_E = "55555555-5555-4555-8555-555555555555"; // visto in qmem_get
const EXPORT_ID = "66666666-6666-4666-8666-666666666666"; // dal pull export
const TOMBSTONE_ID = "77777777-7777-4777-8777-777777777777"; // tombstone dal pull export
fs.mkdirSync(SESS_DIR, { recursive: true }); fs.mkdirSync(SESS_DIR, { recursive: true });
fs.mkdirSync(path.dirname(CONFIG), { recursive: true }); fs.mkdirSync(path.dirname(CONFIG), { recursive: true });
@@ -181,7 +183,16 @@ const server = createServer((req, res) => {
return; return;
} }
if (req.url?.startsWith("/v1/memories:export")) { if (req.url?.startsWith("/v1/memories:export")) {
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "non trovato" })); res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
results: [
{ memory_id: EXPORT_ID, text: "Record arrivato dal pull export del gateway.", kind: "fact", project_id: "export-proj", scope: "agent", created_at: "2026-09-10T10:00:00Z", updated_at: "2026-09-10T10:00:00Z", updated_ts: 1 },
{ memory_id: TOMBSTONE_ID, text: "Record cancellato sul gateway con soft delete.", kind: "fact", project_id: "export-proj", created_at: "2026-09-09T10:00:00Z", deleted_at: "2026-09-11T10:00:00Z", deleted_ts: 2 },
],
count: 2,
next_cursor: null,
}),
);
return; return;
} }
if (req.url === `/v1/memories/${ID_D}`) { if (req.url === `/v1/memories/${ID_D}`) {
@@ -210,7 +221,13 @@ check("project_id/private arricchiti nel DB", afterJson.withProject > 0, `con pr
const pullOut = await runCliAsync(["pull", "--json"]); const pullOut = await runCliAsync(["pull", "--json"]);
const pull = { stdout: pullOut }; const pull = { stdout: pullOut };
const pullJson = JSON.parse(pull.stdout || "{}"); const pullJson = JSON.parse(pull.stdout || "{}");
check("pull segnala endpoint export assente", pullJson.supported === false, pullJson.message ?? ""); check("pull export: 2 record (1 attivo + 1 tombstone)", pullJson.supported === true && pullJson.fetched === 2, `pagine=${pullJson.pages} record=${pullJson.fetched}`);
const findExported = JSON.parse(runCli(["find", "pull export gateway", "--json"]).stdout || "[]");
check("record esportato ricercabile nel mirror", findExported.some((h) => h.memory_id === EXPORT_ID), `${findExported.length} hit`);
const findTombstone = JSON.parse(runCli(["find", "soft delete", "--json"]).stdout);
check("tombstone escluso dalle ricerche locali di default", !findTombstone.some((h) => h.memory_id === TOMBSTONE_ID), `${findTombstone.length} hit`);
const findTombstoneAll = JSON.parse(runCli(["find", "soft delete", "--deleted", "--json"]).stdout || "{}");
check("tombstone visibile con --deleted", findTombstoneAll.some((h) => h.memory_id === TOMBSTONE_ID && h.deleted_at), `${findTombstoneAll.length} hit`);
// ---------------------------------------------------------------- 5) outbox: flush, 409, 422, supersede // ---------------------------------------------------------------- 5) outbox: flush, 409, 422, supersede
const q1 = JSON.parse((await runCliAsync(["queue", "--json"])) || "{}"); const q1 = JSON.parse((await runCliAsync(["queue", "--json"])) || "{}");