From 4b1f13c118f22c3605cce9defc2abbe8e86ae620 Mon Sep 17 00:00:00 2001 From: enne2 Date: Thu, 13 Aug 2026 13:06:10 +0200 Subject: [PATCH] feat: discovery endpoint GET /v1/meta/overview + tool qmem_meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway v2.3.0: /v1/meta/overview (auth) con scope×kind, progetti, agenti, superseduti; scroll aggregato su soli campi metadata + cache TTL 60s invalidata su POST/DELETE/cleanup; audit action 'meta' - estensione v1.3.0: tool qmem_meta (nessun parametro) che guida la ricerca settorializzata - README/playbook (sez. 11.4)/AGENTS.md aggiornati --- README.md | 1 + docs/playbook.md | 8 ++++++ extensions/index.ts | 46 ++++++++++++++++++++++++++++++ gateway/main.py | 69 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0583ca..84b29c9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Oppure copia `extensions/index.ts` in `~/.pi/agent/extensions/pi-qmem/`. | `qmem_store` | Salva un record di memoria (text, kind, agent_id, scope, project_id, source, expires_at, supersedes_id, supersede_reason) | | `qmem_search` | Ricerca semantica su tutta la conoscenza condivisa (query, kind, project_id, scope, top_k, include_superseded, min_score) | | `qmem_correct` | Corregge una memoria falsa: crea un nuovo record che **supersede** il vecchio (che resta in archivio marcato superseded) | +| `qmem_meta` | Discovery: panoramica di scope×kind, progetti, agenti e superseduti (per scegliere i filtri di ricerca) | ## Comando diff --git a/docs/playbook.md b/docs/playbook.md index 1e2c4f1..91beba5 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -340,6 +340,14 @@ pi install git:git.enne2.net/enne2/ - `qmem_store` accetta anche `supersedes_id` / `supersede_reason` (supersede esplicito) - `qmem_search` accetta `include_superseded`; i risultati mostrano `supersedes_id`/`superseded_by`/`supersede_reason` +### 11.4 Discovery (meta overview) + +- `GET /v1/meta/overview` (auth) → `{scopes:[{scope,count,kinds:[{kind,count}]}], projects:[{project_id,count}], agents:[...], superseded, total}` +- Implementazione: scroll con `with_payload` limitato ai soli campi metadata + aggregazione client (Counter) — OK fino a ~10k record, oltre passare a count-per-valore su indice keyword +- **Cache TTL 60s** nel gateway, invalidata a ogni POST/DELETE (e dal cleanup orario): costo marginale ≈ 0 per l'agente +- Tool estensione: `qmem_meta` (nessun parametro) → guida la ricerca settorializzata (`scope`/`kind`/`project_id`) +- Note: scope/kind sono enum chiusi (la discovery serve per conteggi e set aperti project_id/agent_id); espone i nomi reali di progetti/agenti (accesso condiviso già scelto) + ### 11.3 Verifica rapida ```bash diff --git a/extensions/index.ts b/extensions/index.ts index 244ed3b..bd77baf 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -393,6 +393,52 @@ export default function qmemExtension(pi: ExtensionAPI) { }, }); + // ========================================================================= + // TOOL: qmem_meta — discovery di scope, kind, progetti, agenti + // ========================================================================= + pi.registerTool({ + name: "qmem_meta", + label: "Qmem memory overview", + description: + "Restituisce la panoramica della memoria condivisa: scope con i relativi kind e conteggi, " + + "progetti, agenti e record superseduti. Usalo per decidere DOVE cercare (filtri " + + "scope/kind/project_id) prima di qmem_search su un dominio specifico, o per orientarti " + + "sui contenuti disponibili. Nessun parametro richiesto.", + parameters: Type.Object({}), + 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" }, + }; + } + onUpdate?.({ content: [{ type: "text", text: "qmem: lettura overview..." }] }); + const { ok, status, data } = await gatewayRequest(cfg, "GET", "/v1/meta/overview", undefined, signal); + if (!ok) { + return { + content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }], + details: { error: "gateway_error", status }, + }; + } + const lines: string[] = []; + lines.push(`Memoria condivisa: ${data.total} record (${data.superseded} superseduti${data.cached ? ", da cache" : ""})`); + lines.push("Scope:"); + for (const s of data.scopes ?? []) { + const kinds = (s.kinds ?? []).map((k: any) => `${k.kind}=${k.count}`).join(", "); + lines.push(` ${s.scope} (${s.count}): ${kinds}`); + } + lines.push("Progetti:"); + lines.push(` ${(data.projects ?? []).map((p: any) => `${p.project_id}(${p.count})`).join(", ") || "nessuno"}`); + lines.push("Agenti:"); + lines.push(` ${(data.agents ?? []).map((a: any) => `${a.agent_id}(${a.count})`).join(", ") || "nessuno"}`); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { total: data.total, superseded: data.superseded }, + }; + }, + }); + // ========================================================================= // COMANDO: /qmem:config — menu interattivo + modalità CLI rapida // ========================================================================= diff --git a/gateway/main.py b/gateway/main.py index 6b818eb..4f5b093 100644 --- a/gateway/main.py +++ b/gateway/main.py @@ -12,6 +12,7 @@ Endpoints: POST /v1/memories:search → ricerca semantica con filtri (include_superseded per la lineage) GET /v1/memories/{id} → recupera per UUID DELETE /v1/memories/{id} → elimina per UUID + GET /v1/meta/overview → discovery: scope×kind, progetti, agenti (cache 60s) GET /v1/status → health + statistiche """ @@ -21,6 +22,7 @@ import logging import os import time import uuid +from collections import Counter from datetime import datetime, timezone from typing import Any, Literal, Optional @@ -48,12 +50,20 @@ MAX_TEXT_LEN = int(os.environ.get("MAX_TEXT_LEN", "8000")) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("memory-gateway") -app = FastAPI(title="Memory Gateway", version="2.2.0") +app = FastAPI(title="Memory Gateway", version="2.3.0") qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY) # Rate limit in-memory: {key: [timestamps]} _ratelimit: dict[str, list[float]] = {} +# Cache overview metadati (TTL 60s, invalidata su scrittura) +_META_TTL_SECONDS = 60 +_meta_cache: dict[str, Any] = {} + + +def _invalidate_meta() -> None: + _meta_cache.clear() + # --------------------------------------------------------------------------- # Modelli @@ -192,6 +202,7 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict: collection_name=COLLECTION, points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)], ) + _invalidate_meta() if superseded_id: qdrant.set_payload( @@ -272,10 +283,65 @@ async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dic if not point: raise HTTPException(status_code=404, detail="Memoria non trovata") qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id]) + _invalidate_meta() _audit(key, "delete", memory_id=memory_id) return {"deleted": memory_id} +@app.get("/v1/meta/overview") +async def meta_overview(key: str = Depends(require_auth)) -> dict: + """Panoramica della memoria: scope×kind con conteggi, progetti, agenti, superseduti. + Usata dalla discovery per la ricerca settorializzata. Cache TTL 60s invalidata su write.""" + now = time.time() + cached = _meta_cache.get("overview") + if cached and now - cached["ts"] < _META_TTL_SECONDS: + _audit(key, "meta", cached=True) + return {**cached["data"], "cached": True} + + scope_kinds: dict[str, Counter] = {} + projects: Counter = Counter() + agents: Counter = Counter() + total = 0 + superseded = 0 + offset: Any = None + while True: + points, next_offset = qdrant.scroll( + collection_name=COLLECTION, + limit=1000, + with_payload=["scope", "kind", "project_id", "agent_id", "superseded_by"], + with_vector=False, + offset=offset, + ) + for p in points: + pl = p.payload + total += 1 + s = pl.get("scope", "agent") + k = pl.get("kind", "fact") + scope_kinds.setdefault(s, Counter())[k] += 1 + if pl.get("project_id"): + projects[pl["project_id"]] += 1 + agents[pl.get("agent_id", "shared")] += 1 + if pl.get("superseded_by"): + superseded += 1 + if not next_offset: + break + offset = next_offset + + data = { + "scopes": [ + {"scope": s, "count": sum(c.values()), "kinds": [{"kind": k, "count": v} for k, v in sorted(c.items())]} + for s, c in sorted(scope_kinds.items()) + ], + "projects": [{"project_id": pid, "count": c} for pid, c in projects.most_common()], + "agents": [{"agent_id": aid, "count": c} for aid, c in agents.most_common()], + "superseded": superseded, + "total": total, + } + _meta_cache["overview"] = {"ts": now, "data": data} + _audit(key, "meta", cached=False, total=total) + return {**data, "cached": False} + + @app.get("/v1/status") async def status() -> dict: info = qdrant.get_collection(COLLECTION) @@ -313,6 +379,7 @@ async def _cleanup_loop() -> None: ids = [p.id for p in scroll[0]] if ids: qdrant.delete(collection_name=COLLECTION, points_selector=ids) + _invalidate_meta() log.info("cleanup: rimossi %d record scaduti", len(ids)) except Exception as e: # noqa: BLE001 log.warning("cleanup error: %s", e) diff --git a/package.json b/package.json index 4169cb9..88f3420 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-qmem", - "version": "1.2.0", + "version": "1.3.0", "description": "Memoria centralizzata e condivisa per agenti AI: salva e cerca record semantici (Qdrant + BGE-M3) via Memory Gateway.", "keywords": ["pi-package", "memory", "agent", "qdrant", "rag"], "license": "MIT",