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_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_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_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 ## 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_store` accetta anche `supersedes_id` / `supersede_reason` (supersede esplicito)
- `qmem_search` accetta `include_superseded`; i risultati mostrano `supersedes_id`/`superseded_by`/`supersede_reason` - `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 ### 11.3 Verifica rapida
```bash ```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 // 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) POST /v1/memories:search → ricerca semantica con filtri (include_superseded per la lineage)
GET /v1/memories/{id} → recupera per UUID GET /v1/memories/{id} → recupera per UUID
DELETE /v1/memories/{id} → elimina 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 GET /v1/status → health + statistiche
""" """
@@ -21,6 +22,7 @@ import logging
import os import os
import time import time
import uuid import uuid
from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal, Optional 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("memory-gateway") 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) qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
# Rate limit in-memory: {key: [timestamps]} # Rate limit in-memory: {key: [timestamps]}
_ratelimit: dict[str, list[float]] = {} _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 # Modelli
@@ -192,6 +202,7 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
collection_name=COLLECTION, collection_name=COLLECTION,
points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)], points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)],
) )
_invalidate_meta()
if superseded_id: if superseded_id:
qdrant.set_payload( qdrant.set_payload(
@@ -272,10 +283,65 @@ async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dic
if not point: if not point:
raise HTTPException(status_code=404, detail="Memoria non trovata") raise HTTPException(status_code=404, detail="Memoria non trovata")
qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id]) qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id])
_invalidate_meta()
_audit(key, "delete", memory_id=memory_id) _audit(key, "delete", memory_id=memory_id)
return {"deleted": 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") @app.get("/v1/status")
async def status() -> dict: async def status() -> dict:
info = qdrant.get_collection(COLLECTION) info = qdrant.get_collection(COLLECTION)
@@ -313,6 +379,7 @@ async def _cleanup_loop() -> None:
ids = [p.id for p in scroll[0]] ids = [p.id for p in scroll[0]]
if ids: if ids:
qdrant.delete(collection_name=COLLECTION, points_selector=ids) qdrant.delete(collection_name=COLLECTION, points_selector=ids)
_invalidate_meta()
log.info("cleanup: rimossi %d record scaduti", len(ids)) log.info("cleanup: rimossi %d record scaduti", len(ids))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.warning("cleanup error: %s", e) log.warning("cleanup error: %s", e)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pi-qmem", "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.", "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"], "keywords": ["pi-package", "memory", "agent", "qdrant", "rag"],
"license": "MIT", "license": "MIT",