feat: discovery endpoint GET /v1/meta/overview + tool qmem_meta

- 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
This commit is contained in:
enne2
2026-08-13 13:06:10 +02:00
parent f6d9c5872d
commit 4b1f13c118
5 changed files with 124 additions and 2 deletions
+1
View File
@@ -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
+8
View File
@@ -340,6 +340,14 @@ pi install git:git.enne2.net/enne2/<repo>
- `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
+46
View File
@@ -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
// =========================================================================
+68 -1
View File
@@ -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)
+1 -1
View File
@@ -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",