- gateway: separate config, models, state, audit, guardrail, embeddings, store, metrics, cleanup and routes; keep main.py as FastAPI bootstrap - extension: split client/config, six tools, config command and rules; preserve jiti entrypoint and registrations - Dockerfile copies the complete gateway module set - tests: update monkeypatch boundaries for modular config/state
138 lines
6.5 KiB
TypeScript
138 lines
6.5 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:
|
|
"Salva un record di memoria nella memoria centralizzata condivisa (Qdrant + BGE-M3 su brain.vpn). " +
|
|
"Nessun LLM in scrittura: salva fatti, decisioni, preferenze o episodi deliberati e strutturati. " +
|
|
"Usa kind=decision per scelte con motivazione, kind=fact per fatti stabili, kind=preference per " +
|
|
"preferenze utente, kind=episode per esiti di azioni completate. Non salvare transcript grezzi: " +
|
|
"salva un record compatto e ad alto segnale per evento significativo. " +
|
|
"project_id è OBBLIGATORIO: consulta qmem_meta per i progetti esistenti e riusa l'id appropriato.",
|
|
promptGuidelines: [
|
|
"qmem_store: project_id è OBBLIGATORIO — consulta qmem_meta per riusare l'id esistente (fallback pi-qmem per conoscenza trasversale, mai vuoto).",
|
|
"qmem_store: salva record compatti e ad alto segnale, mai transcript grezzi.",
|
|
],
|
|
parameters: Type.Object({
|
|
text: Type.String({ description: "Il contenuto del record di memoria (compatto, ad alto segnale)." }),
|
|
kind: Type.Optional(
|
|
Type.Union(
|
|
[Type.Literal("decision"), Type.Literal("fact"), Type.Literal("episode"), Type.Literal("preference")],
|
|
{ description: "Tipo di memoria (default: fact)." },
|
|
),
|
|
),
|
|
agent_id: Type.Optional(Type.String({ description: "Nome dell'agente che scrive (solo provenienza, nessun isolamento)." })),
|
|
project_id: Type.String({
|
|
description:
|
|
"OBBLIGATORIO: progetto/dominio di appartenenza (kebab-case, es. pi-qmem, domotics, frigate-tts). " +
|
|
"Consulta qmem_meta per i progetti esistenti e riusa l'id appropriato; per domini nuovi crea un id coerente.",
|
|
}),
|
|
scope: Type.Optional(
|
|
Type.Union([Type.Literal("agent"), Type.Literal("project"), Type.Literal("org")], {
|
|
description: "Scope organizzativo (default: agent).",
|
|
}),
|
|
),
|
|
confidence: Type.Optional(
|
|
Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")], {
|
|
description: "Affidabilità del record: high = verificato (fonte autorevole/conferma), medium = probabile, low = osservazione non confermata (default: medium).",
|
|
}),
|
|
),
|
|
source: Type.Optional(Type.String({ description: "Origine del record (es. conversazione, file, ticket)." })),
|
|
expires_at: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Scadenza ISO 8601 (es. 2026-09-01T00:00:00Z) per memoria VOLATILE. Se omesso, la memoria è PERMANENTE: " +
|
|
"non verrà mai cancellata dal cleanup automatico. Usalo solo quando la memoria deve scadere.",
|
|
}),
|
|
),
|
|
supersedes_id: Type.Optional(Type.String({ description: "UUID del record da supersedere (correzione): il nuovo record diventa la versione attiva, il vecchio resta in archivio marcato superseded." })),
|
|
supersede_reason: Type.Optional(Type.String({ description: "Motivo della correzione (visibile in audit e sul vecchio record)." })),
|
|
parent_id: Type.Optional(Type.String({ description: "UUID del record genitore per organizzazione gerarchica/subtopic." })),
|
|
level: Type.Optional(
|
|
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
|
|
description: "Livello gerarchico (L1_ROOT = indice macro-topic, L2_SUBTOPIC = dettaglio specialistico, L3_DETAIL).",
|
|
}),
|
|
),
|
|
topic: Type.Optional(Type.String({ description: "Topic ID gerarchico (es. ALFA-ROMEO-GT-1300-JUNIOR/SPECS)." })),
|
|
links: Type.Optional(
|
|
Type.Array(
|
|
Type.Object({
|
|
target_id: Type.String({ description: "UUID del record target collegato." }),
|
|
predicate: Type.Optional(Type.String({ description: "Tipo di relazione (parent_of, part_of, relates_to, supersedes...)." })),
|
|
weight: Type.Optional(Type.Number({ description: "Peso della relazione (default: 1.0)." })),
|
|
}),
|
|
{ description: "Collegamenti relazionali espliciti verso altri record." },
|
|
),
|
|
),
|
|
}),
|
|
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,
|
|
level: p.level,
|
|
topic: p.topic,
|
|
links: p.links,
|
|
},
|
|
signal,
|
|
idemKey,
|
|
);
|
|
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 },
|
|
};
|
|
},
|
|
});
|
|
|
|
}
|