import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { gatewayRequest, loadConfig } from "../shared"; import { localDbPath, submitOrQueue } from "../local-db.ts"; import { breakerInfo } from "../shared.ts"; 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: la chiave è generata da submitOrQueue (Idempotency-Key) const payload = { 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, }; // Online → gateway (con Idempotency-Key); offline → coda locale (outbox) const submitted = await submitOrQueue(cfg, payload, { dbFile: localDbPath(cfg) }); if (submitted.queued) { return { content: [ { type: "text", text: `⚠️ Gateway non raggiungibile (HTTP ${submitted.status}): record ACCODATO in locale (outbox).\n` + `id locale: ${submitted.local_id}\n` + `in coda: ${submitted.queue_size} record\n` + (() => { const br = breakerInfo(); return br.open ? `circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s ` + `(ultimo errore: ${br.lastError ?? "?"}; per forzare: /qmem:local breaker reset)\n` : ""; })() + `Il contenuto è già ricercabile offline (indice locale, marcato ⏳) e verrà caricato automaticamente al ritorno della connessione ` + `(/qmem:local flush per forzare, /qmem:local queue per lo stato).`, }, ], details: { queued: true, local_id: submitted.local_id, queue_size: submitted.queue_size, gateway_status: submitted.status, breaker: submitted.breaker ? { open: submitted.breaker.open, remaining_ms: Math.round(submitted.breaker.remainingMs), failures: submitted.breaker.failures } : undefined, }, }; } const { ok, status, data } = submitted; if (!ok) { return { content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }], 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 }, }; }, }); }