pi-qmem: memoria centralizzata condivisa per agenti AI (estensione pi + gateway)

This commit is contained in:
enne2
2026-08-11 23:14:40 +02:00
commit eadd2a756b
7 changed files with 739 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
__pycache__/
*.pyc
.env
.DS_Store
+66
View File
@@ -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 <URL> | apikey <KEY> | 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
+346
View File
@@ -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<void> {
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 <URL> | apikey <KEY> | 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;
}
},
});
}
+10
View File
@@ -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"]
+293
View File
@@ -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)
+5
View File
@@ -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
+14
View File
@@ -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"]
}
}