commit eadd2a756b48debb72ab16a514f5351db33a882d Author: enne2 Date: Tue Aug 11 23:14:40 2026 +0200 pi-qmem: memoria centralizzata condivisa per agenti AI (estensione pi + gateway) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed2f196 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +__pycache__/ +*.pyc +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fb5105 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# pi-qmem + +Memoria centralizzata e condivisa per agenti AI — estensione per pi. + +Salva e cerca record semantici (fatti, decisioni, preferenze, episodi) in un +Memory Gateway (FastAPI + Qdrant + BGE-M3) ospitato su un server remoto +raggiungibile via VPN. Nessun LLM in scrittura: l'agente salva record +deliberati e strutturati; il retrieval è vettoriale + filtri metadata. + +## Installazione + +```bash +pi install git:git.enne2.net/enne2/pi-qmem +``` + +Oppure copia `extensions/index.ts` in `~/.pi/agent/extensions/pi-qmem/`. + +## Tool + +| Tool | Descrizione | +|---|---| +| `qmem_store` | Salva un record di memoria (text, kind, agent_id, scope, project_id, source, expires_at) | +| `qmem_search` | Ricerca semantica su tutta la conoscenza condivisa (query, kind, project_id, scope, top_k) | + +## Comando + +`/qmem:config` — menu interattivo (TUI): + +- 🌐 Imposta URL gateway +- 🔑 Cambia API key +- 🔌 Test connessione (verifica URL + validità chiave) +- 📋 Mostra configurazione +- ↩️ Annulla + +Modalità CLI rapida: `/qmem:config url | apikey | test` + +Config salvata in `~/.config/pi-qmem/config.json` (0600): + +```json +{ + "url": "http://10.8.0.3:8082", + "apiKey": "..." +} +``` + +## Gateway (componente server) + +La cartella `gateway/` contiene il Memory Gateway FastAPI da deployare sul +server (Docker Compose con Qdrant 1.19 + Ollama BGE-M3). Vedi +`gateway/README.md` per il deploy. + +## Architettura + +``` +pi (estensione) ──HTTPS/VPN──▶ Memory Gateway (FastAPI) ──▶ Qdrant 1.19 + │ + └──▶ Ollama BGE-M3 (embedding locale) +``` + +- Accesso condiviso: una chiave API con accesso completo in lettura/scrittura +- `agent_id` è solo metadata di provenienza, non isolamento +- Rate limit, audit log, cleanup automatico dei record scaduti + +## Licenza + +MIT diff --git a/extensions/index.ts b/extensions/index.ts new file mode 100644 index 0000000..eb595cc --- /dev/null +++ b/extensions/index.ts @@ -0,0 +1,346 @@ +/** + * pi-qmem — memoria centralizzata e condivisa per agenti AI (estensione pi). + * + * Espone due tool: + * - qmem_store → salva un record di memoria (nessun LLM in scrittura) + * - qmem_search → ricerca semantica con filtri + * + * Il gateway (FastAPI su brain.vpn:8082) usa una chiave condivisa con accesso + * COMPLETO in lettura e scrittura all'intera conoscenza: qualsiasi agente può + * consultare e aggiungere informazioni liberamente. L'agent_id è solo metadata + * di provenienza, non un meccanismo di isolamento. + * + * Config: ~/.config/pi-qmem/config.json + * { "url": "http://10.8.0.3:8082", "apiKey": "..." } + * + * Comando: /qmem:config — menu interattivo (URL, API key, test connessione) + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-qmem"); +const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); + +interface MemoryConfig { + url: string; + apiKey: string; + timeoutMs?: number; +} + +const CONFIG_DEFAULTS: MemoryConfig = { + url: "http://10.8.0.3:8082", + apiKey: "", + timeoutMs: 30_000, +}; + +function loadConfig(): MemoryConfig { + try { + return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) }; + } catch { + return { ...CONFIG_DEFAULTS }; + } +} + +function saveConfig(cfg: MemoryConfig) { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }); + } catch { + /* ignora */ + } +} + +async function gatewayRequest( + cfg: MemoryConfig, + method: string, + route: string, + body?: unknown, + signal?: AbortSignal, +): Promise<{ ok: boolean; status: number; data: any }> { + const res = await fetch(`${cfg.url}${route}`, { + method, + signal, + headers: { + "Content-Type": "application/json", + "X-API-Key": cfg.apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, status: res.status, data }; +} + +// --------------------------------------------------------------------------- +// Test connessione: verifica URL (status) e validità chiave (search minima) +// --------------------------------------------------------------------------- +async function testConnection(ctx: any, cfg: MemoryConfig): Promise { + ctx.ui.setStatus("pi-qmem", "Test connessione al gateway..."); + try { + const res = await fetch(`${cfg.url}/v1/status`, { + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) { + ctx.ui.notify(`❌ Gateway non raggiungibile: HTTP ${res.status}`, "error"); + return; + } + const data = await res.json(); + if (!cfg.apiKey) { + ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key mancante`, "warning"); + return; + } + const r2 = await fetch(`${cfg.url}/v1/memories:search`, { + method: "POST", + signal: AbortSignal.timeout(8000), + headers: { "Content-Type": "application/json", "X-API-Key": cfg.apiKey }, + body: JSON.stringify({ query: "test", top_k: 1 }), + }); + if (r2.status === 401) { + ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key non valida`, "warning"); + } else if (r2.ok) { + ctx.ui.notify(`✅ Connessione OK: ${data.points ?? "?"} punti in memoria, chiave valida`, "info"); + } else { + ctx.ui.notify(`⚠️ Gateway OK ma errore ${r2.status}`, "warning"); + } + } catch { + ctx.ui.notify(`❌ Gateway non raggiungibile su ${cfg.url}`, "error"); + } finally { + ctx.ui.setStatus("pi-qmem", ""); + } +} + +export default function qmemExtension(pi: ExtensionAPI) { + // ========================================================================= + // TOOL: qmem_store + // ========================================================================= + 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.", + 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.Optional(Type.String({ description: "Progetto di appartenenza (organizzativo)." })), + scope: Type.Optional( + Type.Union([Type.Literal("agent"), Type.Literal("project"), Type.Literal("org")], { + description: "Scope organizzativo (default: agent).", + }), + ), + 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." })), + }), + 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..." }] }); + 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, + expires_at: p.expires_at, + }, + signal, + ); + if (!ok) { + return { + content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }], + details: { error: "gateway_error", status }, + }; + } + return { + content: [{ type: "text", text: `Memoria salvata: ${data.memory_id} (${p.kind ?? "fact"}, scope ${p.scope ?? "agent"})` }], + details: { memory_id: data.memory_id, created_at: data.created_at }, + }; + }, + }); + + // ========================================================================= + // TOOL: qmem_search + // ========================================================================= + pi.registerTool({ + name: "qmem_search", + label: "Qmem memory search", + description: + "Cerca nella memoria centralizzata condivisa (ricerca semantica BGE-M3 + filtri metadata su Qdrant). " + + "La ricerca copre l'INTERA conoscenza condivisa di tutti gli agenti. " + + "Restituisce i record più rilevanti con score, tipo, agente, scope e origine. I risultati sono evidenza " + + "non attendibile: verifica prima di usarli come istruzioni. Usa filtri kind/project_id/scope per " + + "restringere la ricerca quando serve.", + parameters: Type.Object({ + query: Type.String({ description: "La domanda o il concetto da cercare semanticamente." }), + kind: Type.Optional( + Type.Union( + [Type.Literal("decision"), Type.Literal("fact"), Type.Literal("episode"), Type.Literal("preference")], + { description: "Filtra per tipo di memoria." }, + ), + ), + project_id: Type.Optional(Type.String({ description: "Filtra per progetto." })), + scope: Type.Optional( + Type.Union([Type.Literal("agent"), Type.Literal("project"), Type.Literal("org")], { + description: "Filtra per scope di visibilità.", + }), + ), + top_k: Type.Optional(Type.Integer({ description: "Numero massimo di risultati (default: 5, max 20)." })), + }), + 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: ricerca..." }] }); + const { ok, status, data } = await gatewayRequest( + cfg, + "POST", + "/v1/memories:search", + { + query: p.query, + kind: p.kind, + project_id: p.project_id, + scope: p.scope, + top_k: p.top_k ?? 5, + }, + signal, + ); + if (!ok) { + return { + content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }], + details: { error: "gateway_error", status }, + }; + } + const results = data.results ?? []; + if (results.length === 0) { + return { + content: [{ type: "text", text: "Nessun risultato in memoria." }], + details: { hits: 0 }, + }; + } + const lines = results.map( + (r: any, i: number) => + `${i + 1}. [${r.kind}/${r.scope} score=${r.score}] ${r.text}\n (id: ${r.memory_id}, agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""})`, + ); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { hits: results.length }, + }; + }, + }); + + // ========================================================================= + // COMANDO: /qmem:config — menu interattivo + modalità CLI rapida + // ========================================================================= + pi.registerCommand("qmem:config", { + description: + "Menu configurazione Memory Gateway: URL, API key, test connessione (salva in ~/.config/pi-qmem/config.json)", + handler: async (args, ctx) => { + const cfg = loadConfig(); + const parts = (args ?? "").trim().split(/\s+/).filter(Boolean); + + // --- Modalità CLI rapida (non interattiva) --- + if (parts.length > 0) { + if (parts[0] === "url" && parts[1]) { + cfg.url = parts[1].replace(/\/+$/, ""); + saveConfig(cfg); + ctx.ui.notify(`qmem: URL aggiornato a ${cfg.url}`, "info"); + return; + } + if (parts[0] === "apikey" && parts[1]) { + cfg.apiKey = parts[1]; + saveConfig(cfg); + ctx.ui.notify("qmem: API key aggiornata", "info"); + return; + } + if (parts[0] === "test") { + await testConnection(ctx, cfg); + return; + } + ctx.ui.notify("Uso: /qmem:config url | apikey | test", "warning"); + return; + } + + // --- Modalità menu interattivo (TUI/RPC) --- + if (!ctx.hasUI) { + ctx.ui.notify( + `qmem: url=${cfg.url}, apiKey=${cfg.apiKey ? cfg.apiKey.slice(0, 8) + "..." : "(mancante)"}`, + "info", + ); + return; + } + + const choice = await ctx.ui.select( + "qmem — Configurazione", + [ + "🌐 Imposta URL gateway", + "🔑 Cambia API key", + "🔌 Test connessione", + "📋 Mostra configurazione", + "↩️ Annulla", + ], + ); + if (!choice || choice.startsWith("↩️")) return; + + if (choice.startsWith("🌐")) { + const url = await ctx.ui.input("URL del Memory Gateway:", cfg.url, { timeout: 60_000 }); + if (url && url.trim()) { + cfg.url = url.trim().replace(/\/+$/, ""); + saveConfig(cfg); + ctx.ui.notify(`qmem: URL aggiornato a ${cfg.url}`, "info"); + } + return; + } + + if (choice.startsWith("🔑")) { + const key = await ctx.ui.input("API key (lascia vuoto per non cambiare):", "", { timeout: 60_000 }); + if (key && key.trim()) { + cfg.apiKey = key.trim(); + saveConfig(cfg); + ctx.ui.notify("qmem: API key aggiornata", "info"); + } + return; + } + + if (choice.startsWith("🔌")) { + await testConnection(ctx, cfg); + return; + } + + if (choice.startsWith("📋")) { + ctx.ui.notify( + `qmem: url=${cfg.url}, apiKey=${cfg.apiKey ? cfg.apiKey.slice(0, 8) + "..." : "(mancante)"}`, + "info", + ); + return; + } + }, + }); +} diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 0000000..6b818b0 --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8080 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/gateway/main.py b/gateway/main.py new file mode 100644 index 0000000..74b9a81 --- /dev/null +++ b/gateway/main.py @@ -0,0 +1,293 @@ +""" +Memory Gateway — memoria centralizzata condivisa per agenti AI. + +Stack snello: FastAPI + Qdrant + Ollama (BGE-M3). Nessun LLM in scrittura. +Accesso: UNA o più API key condivise con accesso COMPLETO in lettura e +scrittura all'intera conoscenza. Nessun isolamento per agente: qualsiasi +agente (attuale o futuro) con la chiave può consultare e aggiungere +informazioni liberamente. L'agent_id è solo metadata di provenienza. + +Endpoints: + POST /v1/memories → crea un record di memoria + POST /v1/memories:search → ricerca semantica con filtri + GET /v1/memories/{id} → recupera per UUID + DELETE /v1/memories/{id} → elimina per UUID + GET /v1/status → health + statistiche +""" + +import asyncio +import json +import logging +import os +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Literal, Optional + +import httpx +import uvicorn +from fastapi import Depends, FastAPI, Header, HTTPException, Request +from pydantic import BaseModel, Field +from qdrant_client import QdrantClient +from qdrant_client.http import models as qm + +# --------------------------------------------------------------------------- +# Configurazione (env) +# --------------------------------------------------------------------------- +QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333") +QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "") +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") +EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3") +EMBED_DIM = int(os.environ.get("EMBED_DIM", "1024")) +COLLECTION = os.environ.get("COLLECTION", "memories") +# Chiavi condivise (separate da virgola): accesso completo in lettura/scrittura +API_KEYS: set[str] = {k.strip() for k in os.environ.get("API_KEYS", "").split(",") if k.strip()} +RATE_LIMIT_PER_MIN = int(os.environ.get("RATE_LIMIT_PER_MIN", "120")) +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.0.0") +qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY) + +# Rate limit in-memory: {key: [timestamps]} +_ratelimit: dict[str, list[float]] = {} + + +# --------------------------------------------------------------------------- +# Modelli +# --------------------------------------------------------------------------- +class MemoryIn(BaseModel): + text: str = Field(min_length=1, max_length=MAX_TEXT_LEN) + kind: Literal["decision", "fact", "episode", "preference"] = "fact" + agent_id: Optional[str] = Field(default=None, max_length=64, description="Solo provenienza, nessun isolamento") + project_id: Optional[str] = Field(default=None, max_length=64) + scope: Literal["agent", "project", "org"] = "agent" + source: Optional[str] = Field(default=None, max_length=256) + expires_at: Optional[str] = None # ISO 8601 + supersedes_id: Optional[str] = None + + +class SearchIn(BaseModel): + query: str = Field(min_length=1, max_length=512) + kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None + project_id: Optional[str] = None + scope: Optional[Literal["agent", "project", "org"]] = None + top_k: int = Field(default=5, ge=1, le=20) + + +# --------------------------------------------------------------------------- +# Auth: chiave condivisa → accesso completo (nessun isolamento) +# --------------------------------------------------------------------------- +def require_auth(x_api_key: str = Header(...)) -> str: + if x_api_key not in API_KEYS: + raise HTTPException(status_code=401, detail="API key non valida") + # rate limit per chiave + now = time.monotonic() + window = _ratelimit.setdefault(x_api_key, []) + window[:] = [t for t in window if now - t < 60] + if len(window) >= RATE_LIMIT_PER_MIN: + raise HTTPException(status_code=429, detail="Rate limit superato") + window.append(now) + return x_api_key + + +def _audit(key: str, action: str, **extra: Any) -> None: + """Audit log in JSON lines (catturato da docker logs).""" + entry = { + "ts": datetime.now(timezone.utc).isoformat(), + "key": key[:8] + "...", + "action": action, + **extra, + } + log.info(json.dumps(entry, default=str)) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_ts(value: Optional[str]) -> Optional[float]: + """Converte ISO 8601 in timestamp Unix (per i range query Qdrant).""" + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Embedding via Ollama (BGE-M3) +# --------------------------------------------------------------------------- +async def embed(text: str) -> list[float]: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post( + f"{OLLAMA_URL}/api/embed", + json={"model": EMBED_MODEL, "input": text}, + ) + r.raise_for_status() + return r.json()["embeddings"][0] + + +# --------------------------------------------------------------------------- +# Startup: crea collection e indici se non esistono +# --------------------------------------------------------------------------- +@app.on_event("startup") +def startup() -> None: + collections = qdrant.get_collections().collections + if not any(c.name == COLLECTION for c in collections): + qdrant.create_collection( + collection_name=COLLECTION, + vectors_config=qm.VectorParams(size=EMBED_DIM, distance=qm.Distance.COSINE), + ) + for field in ("agent_id", "project_id", "scope", "kind"): + qdrant.create_payload_index( + collection_name=COLLECTION, + field_name=field, + field_schema=qm.PayloadSchemaType.KEYWORD, + ) + log.info("collection %s creata con indici", COLLECTION) + else: + log.info("collection %s già esistente", COLLECTION) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- +@app.post("/v1/memories") +async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict: + memory_id = str(uuid.uuid4()) + vector = await embed(body.text) + + payload: dict[str, Any] = { + "text": body.text, + "kind": body.kind, + "agent_id": body.agent_id or "shared", + "project_id": body.project_id, + "scope": body.scope, + "source": body.source, + "created_at": _now_iso(), + "expires_at": _parse_ts(body.expires_at), + "supersedes_id": body.supersedes_id, + "embedding_model": EMBED_MODEL, + } + qdrant.upsert( + collection_name=COLLECTION, + points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)], + ) + _audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"]) + return {"memory_id": memory_id, "created_at": payload["created_at"]} + + +@app.post("/v1/memories:search") +async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> dict: + vector = await embed(body.query) + + must: list[Any] = [] + if body.kind: + must.append(qm.FieldCondition(key="kind", match=qm.MatchValue(value=body.kind))) + if body.project_id: + must.append(qm.FieldCondition(key="project_id", match=qm.MatchValue(value=body.project_id))) + if body.scope: + must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=body.scope))) + + hits = qdrant.search( + collection_name=COLLECTION, + query_vector=vector, + query_filter=qm.Filter(must=must) if must else None, + limit=body.top_k, + with_payload=True, + ) + results = [ + { + "memory_id": h.id, + "score": round(h.score, 4), + "text": h.payload.get("text"), + "kind": h.payload.get("kind"), + "agent_id": h.payload.get("agent_id"), + "scope": h.payload.get("scope"), + "project_id": h.payload.get("project_id"), + "created_at": h.payload.get("created_at"), + "source": h.payload.get("source"), + } + for h in hits + ] + _audit(key, "search", query=body.query[:80], top_k=body.top_k, hits=len(results)) + return {"results": results} + + +@app.get("/v1/memories/{memory_id}") +async def get_memory(memory_id: str, key: str = Depends(require_auth)) -> dict: + point = qdrant.retrieve( + collection_name=COLLECTION, ids=[memory_id], with_payload=True + ) + if not point: + raise HTTPException(status_code=404, detail="Memoria non trovata") + _audit(key, "get", memory_id=memory_id) + return {"memory_id": memory_id, **point[0].payload} + + +@app.delete("/v1/memories/{memory_id}") +async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dict: + point = qdrant.retrieve( + collection_name=COLLECTION, ids=[memory_id], with_payload=True + ) + if not point: + raise HTTPException(status_code=404, detail="Memoria non trovata") + qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id]) + _audit(key, "delete", memory_id=memory_id) + return {"deleted": memory_id} + + +@app.get("/v1/status") +async def status() -> dict: + info = qdrant.get_collection(COLLECTION) + return { + "status": "ok", + "collection": COLLECTION, + "points": info.points_count, + "embedding_model": EMBED_MODEL, + "embedding_dim": EMBED_DIM, + "access": "shared", + "api_keys": len(API_KEYS), + } + + +# --------------------------------------------------------------------------- +# Cleanup periodico: rimuove record scaduti (expires_at < now) +# --------------------------------------------------------------------------- +async def _cleanup_loop() -> None: + while True: + try: + now = time.time() + scroll = qdrant.scroll( + collection_name=COLLECTION, + scroll_filter=qm.Filter( + must=[ + qm.FieldCondition( + key="expires_at", + range=qm.Range(lt=now), + ) + ] + ), + limit=100, + with_payload=False, + ) + ids = [p.id for p in scroll[0]] + if ids: + qdrant.delete(collection_name=COLLECTION, points_selector=ids) + log.info("cleanup: rimossi %d record scaduti", len(ids)) + except Exception as e: # noqa: BLE001 + log.warning("cleanup error: %s", e) + await asyncio.sleep(3600) + + +@app.on_event("startup") +async def start_cleanup() -> None: + asyncio.create_task(_cleanup_loop()) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/gateway/requirements.txt b/gateway/requirements.txt new file mode 100644 index 0000000..2b434bd --- /dev/null +++ b/gateway/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +qdrant-client==1.13.0 +httpx==0.28.1 +pydantic==2.10.4 diff --git a/package.json b/package.json new file mode 100644 index 0000000..90ba345 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "pi-qmem", + "version": "1.0.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", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + }, + "pi": { + "extensions": ["./extensions"] + } +}