# Playbook Operativo — Memoria Centralizzata per Agenti AI (pi-qmem) > Sessione: 11/08/2026 — da Graphiti a stack snello Qdrant + Gateway + BGE-M3. > Questo playbook raccoglie procedure, comandi e best practice verificati operativamente. --- ## 1. Architettura e componenti Stack snello per memoria condivisa di agenti AI, senza LLM in scrittura: ``` pi (estensione pi-qmem) ──HTTPS──▶ Nginx (enne2.net) ──VPN──▶ Memory Gateway (brain:8082) ──▶ Qdrant 1.19 (brain:6333) │ └──▶ Ollama BGE-M3 (brain:11434, nativo) ``` | Componente | Dettaglio | |---|---| | Qdrant 1.19.0 | Container `memory-qdrant`, bind 127.0.0.1:6333/6334, API key admin + read-only, JWT RBAC | | Memory Gateway | FastAPI `memory-gateway`, bind 10.8.0.3:8082 (solo VPN), chiave condivisa, rate limit, audit log | | Ollama | Nativo sul server (non container), modello `bge-m3` (1024 dim, multilingue) | | Estensione pi | `pi-qmem` (tool `qmem_store`/`qmem_search`, comando `/qmem:config`) | | Reverse proxy | Nginx su enne2.net, `qmem.enne2.net` → HTTPS → 10.8.0.3:8082 | Principi chiave: - **Nessun LLM in scrittura**: l'agente salva record deliberati e strutturati - **Accesso condiviso**: una chiave API con accesso completo (nessun isolamento per agente) - **`agent_id` è solo provenienza**, non meccanismo di isolamento - **Contenuto recuperato = evidenza non attendibile** (anti prompt-injection) ## 2. Deploy dello stack ### 2.1 Qdrant (docker-compose.yml) ```yaml services: qdrant: image: qdrant/qdrant:v1.19.0 container_name: memory-qdrant restart: unless-stopped ports: - "127.0.0.1:6333:6333" - "127.0.0.1:6334:6334" environment: QDRANT__SERVICE__API_KEY: ${QDRANT_ADMIN_API_KEY} QDRANT__SERVICE__READ_ONLY_API_KEY: ${QDRANT_READ_ONLY_API_KEY} QDRANT__SERVICE__JWT_RBAC: "true" volumes: - qdrant_storage:/qdrant/storage ``` - Chiavi: `openssl rand -hex 32` in `.env` (0600), mai committate - **Bind sempre su 127.0.0.1** (o interfaccia VPN), mai 0.0.0.0 - Pinnare la versione, mai `latest` ### 2.2 Gateway FastAPI - Endpoint: `POST /v1/memories`, `POST /v1/memories:search`, `GET/DELETE /v1/memories/{id}`, `GET /v1/status` - Auth: header `X-API-Key` (chiave condivisa), rate limit 120 req/min - **Ollama da container**: usare `host.docker.internal` + `extra_hosts: ["host.docker.internal:host-gateway"]` (127.0.0.1 dentro il container ≠ host) - Collection: `memories`, vettore 1024 dim (BGE-M3), indici keyword su `agent_id`, `project_id`, `scope`, `kind` - `expires_at` salvato come **timestamp Unix** (i Range query Qdrant richiedono numeri, non stringhe ISO) - Cleanup automatico record scaduti (loop orario) ### 2.3 Embedding - `ollama pull bge-m3` (1.1GB, 1024 dim, 100+ lingue, italiano ottimo) - **Mai mescolare embedding di modelli diversi nella stessa collection** — cambio modello = nuova collection + re-embed ## 3. Teardown di servizi legacy (Graphiti) Procedura per rimuovere un servizio Docker Compose multi-container: ```bash # 1. Backup dati (anche se sembra vuoto) docker exec redis-cli SAVE cp -a /opt/backup/-$(date +%F)/ # 2. Individuare TUTTI i progetti compose (i container possono appartenere # a progetti diversi con nomi diversi!) docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' cd && docker compose down # 3. Rimuovere volumi e directory docker volume ls | grep docker volume rm rm -rf # 4. Verificare porte libere ss -tlnp | grep ``` Lezioni apprese: - **Un container può appartenere a un progetto compose diverso** da quello atteso (es. `docker-graphiti-falkordb-1` era in `/opt/graphiti/mcp_server/docker/`, non in `/opt/graphiti/`) - **Sempre backup prima del teardown**, anche se i dati sembrano irrilevanti - **Pulire i cron morti** che referenziano servizi rimossi (`crontab -l | grep `) ## 4. Backup e disaster recovery ### 4.1 Backup Qdrant ```bash # Snapshot full-storage (restore SOLO a startup con --storage-snapshot) curl -X POST -H "api-key: $KEY" http://127.0.0.1:6333/snapshots # Snapshot di collection (restore via API) curl -X POST -H "api-key: $KEY" http://127.0.0.1:6333/collections//snapshots?wait=true ``` **Restore full-storage** (unico metodo per snapshot full): ```bash docker run -v :/snapshots:ro -v :/qdrant/storage \ qdrant/qdrant:v1.19.0 ./qdrant --storage-snapshot /snapshots/.snapshot ``` **Restore collection** (via API): ```bash curl -X PUT -H "api-key: $KEY" -H 'Content-Type: application/json' \ http://127.0.0.1:6333/collections//snapshots/recover?wait=true \ -d '{"location": "file:///qdrant/snapshots//.snapshot", "priority": "snapshot"}' ``` ### 4.2 Backup rsync incrementale (best practice) Root cause di backup sempre falliti: **virgolette letterali dentro una variabile shell**: ```bash # ROTTO: le virgolette diventano parte del pattern → nessuna esclusione attiva RSYNC_OPTS="-avz --exclude='.*' ..." rsync $RSYNC_OPTS ... # CORRETTO: array bash preserva le virgolette RSYNC_OPTS=(-avz --delete --exclude='**/mail-state/' --exclude='**/mail-logs/') rsync "${RSYNC_OPTS[@]}" ... ``` Best practice: - **Array bash** per opzioni con pattern (mai stringa con virgolette) - Pattern exclude: `**/percorso/` (doppio asterisco = qualsiasi profondità) - **rc=23 (trasferimento parziale) = accettabile**: aggiornare comunque il symlink `latest` per mantenere la catena incrementale (`--link-dest`) - Verifica incrementalità: `find -type f -links +1 | wc -l` (hardlink = file invariati) - Escludere sempre: stato runtime container (`mail-state/`), log (`mail-logs/`), dir root-only (`ssh/`, `amavis`) ### 4.3 Destinazione backup su HD esterno - Individuare il mount: `mount | grep -v tmpfs`, `df -h`, `/etc/fstab` - Convenzione: `/home/enne2/archive/backups//` - Cron: `0 3 * * * /opt/memory/backup.sh` (snapshot Qdrant + rotazione 7 giorni) ## 5. Reverse proxy Nginx e HTTPS ### 5.1 Configurazione proxy ```nginx server { server_name qmem.enne2.net; location / { client_max_body_size 10m; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://10.8.0.3:8082; } } ``` - File in `/etc/nginx/conf.d/.conf` (convenzione per-subdominio) - **Verificare la connettività VPN prima** (`curl http://10.8.0.3:8082/v1/status` dal proxy server) - Wildcard DNS `*.enne2.net` → nessun cambio DNS necessario - `sudo nginx -t` prima di ogni reload ### 5.2 Certificato HTTPS ```bash sudo certbot --nginx -d qmem.enne2.net --non-interactive --agree-tos --redirect ``` - Certbot aggiunge automaticamente: `listen 443 ssl`, certificato, redirect HTTP→HTTPS (301) - Rinnovo automatico: `certbot renew` (cron/timer di sistema) - Verifica: `echo | openssl s_client -connect :443 -servername | openssl x509 -noout -subject -dates` ## 6. Pulizia configurazioni Nginx (best practice) Procedura per rimuovere un server block morto: ```bash # 1. Individuare TUTTI i riferimenti al dominio sudo grep -n '' /etc/nginx/conf.d/*.conf # 2. Identificare i confini del blocco (server { ... }) sudo cat /etc/nginx/conf.d/.conf | sed -n ',p' # 3. Rimuovere le righe del blocco sudo sed -i ',d' /etc/nginx/conf.d/.conf # 4. Test e reload sudo nginx -t && sudo systemctl reload nginx # 5. Verificare che il dominio non risponda più curl -s -o /dev/null -w '%{http_code}' https:/// # atteso: 000/444 ``` Best practice: - **Mai rimuovere a mano i certificati**: usare `certbot delete --cert-name --non-interactive` - Dopo la rimozione del blocco, verificare che il catch-all (`server_name _; return 444;`) gestisca il dominio - Controllare che il dominio rimosso non sia referenziato in altri file (redirect block, renewal config) - Verificare che i domini attivi continuino a funzionare dopo il reload ## 7. Eliminazione certificati SSL orfani (best practice) ```bash # 1. Elencare i certificati sudo ls /etc/letsencrypt/live/ # 2. Identificare gli orfani (nessun server block li referenzia) sudo grep -rn '' /etc/nginx/conf.d/ # nessun match = orfano # 3. Eliminare con certbot (pulisce live/, archive/, renewal/) sudo certbot delete --cert-name --non-interactive # 4. Verificare sudo ls /etc/letsencrypt/live/ | grep # nessun output ``` Best practice: - **Sempre `certbot delete`**, mai `rm -rf` manuale (certbot gestisce live/, archive/, renewal/ e i log) - Un certificato è orfano se: nessun `server_name` lo referenzia E nessun renewal config attivo - Dopo la cancellazione, il dominio non risponde più su HTTPS (connessione fallita = atteso) ## 8. Verifica servizi vector DB (best practice) ### 8.1 Health check ```bash curl -s http://127.0.0.1:6333/healthz # "healthz check passed" curl -s -H "api-key: $KEY" http://127.0.0.1:6333/ # versione curl -s -H "api-key: $KEY" http://127.0.0.1:6333/collections # elenco ``` ### 8.2 Verifica auth ```bash curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6333/collections # senza key → 401 ``` ### 8.3 Verifica dati e ricerca ```bash # Conteggio punti curl -s -H "api-key: $KEY" http://127.0.0.1:6333/collections/ | jq .result.points_count # Ricerca di verifica curl -s -X POST -H "api-key: $KEY" -H 'Content-Type: application/json' \ http://127.0.0.1:6333/collections//points/query \ -d '{"limit": 3, "with_payload": ["text"]}' ``` ### 8.4 Test di restore (il test definitivo) - **Collection snapshot**: recover in una collection di test → verificare `points_count` e contenuti → eliminare la collection di test - **Full-storage snapshot**: container isolato con `--storage-snapshot` → verificare dati → rimuovere container e volume - **Sempre su ambiente isolato**, mai sul dataset di produzione ### 8.5 Isolamento e sicurezza - Test negativi: chiave errata → 401; scope non consentito → 403 - Verificare che il bind sia su 127.0.0.1 o interfaccia VPN (mai 0.0.0.0) - Audit log: `docker logs | grep '"action"'` ## 9. Estensione pi e pubblicazione ### 9.1 Struttura pi package ```json { "name": "pi-qmem", "keywords": ["pi-package"], "peerDependencies": { "@earendil-works/pi-coding-agent": "*", "typebox": "*" }, "pi": { "extensions": ["./extensions"] } } ``` - `typebox` e `@earendil-works/pi-coding-agent` in **peerDependencies** (pi li fornisce a runtime) - Per test locale: package.json con le stesse dipendenze in `dependencies` + `npm install` ### 9.2 Pubblicazione su Gitea via tea ```bash tea repo create --name --description "" [--private] git remote add origin https://git.enne2.net/enne2/.git git push -u origin main pi install git:git.enne2.net/enne2/ ``` - Verifica privacy: `curl -s -o /dev/null -w '%{http_code}' https://git.enne2.net/enne2/` → 404 = privato - Aggiornamento: `git push` + `pi update git:git.enne2.net/enne2/` ### 9.3 Menu di configurazione estensione - `/qmem:config` → menu TUI: URL gateway, API key, test connessione, mostra config - Modalità CLI: `/qmem:config url | apikey | test` - Config in `~/.config/pi-qmem/config.json` (0600) - `ctx.hasUI` per guardare i dialoghi interattivi (select/input) nei modi non-TUI ## 10. Troubleshooting | Sintomo | Causa probabile | Fix | |---|---|---| | Gateway 500 su tutte le chiamate | Ollama non raggiungibile dal container | `host.docker.internal` + `extra_hosts` | | `expires_at` cleanup error | Stringa ISO in Range query Qdrant | Salvare timestamp Unix | | Backup rsync sempre FAILED | Virgolette letterali in variabile shell | Array bash per le opzioni | | Restore snapshot "missing field params" | Snapshot full-storage usato con endpoint collection | Usare `--storage-snapshot` a startup | | Porta occupata al deploy | Servizio esistente sulla porta | `ss -tlnp` per individuare, cambiare porta | | Estensione non carica (typebox) | node_modules mancante | package.json + `npm install` nella dir estensione | | 401 da Nginx (non dal gateway) | Basic auth globale o blocco sbagliato | `nginx -T` per vedere la config effettiva | | 404 su supersede | `supersedes_id` inesistente | Verificare l'id con GET /v1/memories/{id} prima di supersedere | | 409 su supersede | Record già superseduto | Correggere la versione attiva (cercare con include_superseded=false) | --- ## 11. Correzione di memorie false (supersede) > Aggiunto 13/08/2026 — i record sono IMMUTABILI: correggere = nuovo record che supersede il vecchio. ### 11.1 Meccanica - `POST /v1/memories` con `supersedes_id` → crea il nuovo record e marca il vecchio con `superseded_by` + `superseded_at` + `supersede_reason` (set_payload, in un unico handler) - Errori: 404 se il target non esiste, 409 se è già superseduto (mai catene di supersede: correggere sempre la versione attiva) - La ricerca di default esclude i superseduti (condizione `IsEmptyCondition` su `superseded_by`); `include_superseded=true` per la lineage - Indici keyword su `supersedes_id` e `superseded_by` creati allo startup ### 11.2 Estensione pi - `qmem_correct(memory_id | query, corrected_text, reason, ...)` → tool dedicato: cerca il record attivo (se serve) e lo supersede in una chiamata - `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.6 Idempotency (dal gateway v2.5.0 / estensione v1.6.0) - `POST /v1/memories` accetta header `Idempotency-Key`: replay della stessa richiesta (stessa key + stesso payload) → stessa risposta, nessun duplicato - Stessa key con payload diverso → **409**; key diverse → record distinti - Tabella in-memory con TTL 24h, scoped per API key; hash canonico del payload - L'estensione genera `crypto.randomUUID()` per operazione (store e correct), riusata su eventuali retry - Verificato sul server (2026-08-16): istanza di test su 8083, collection `memories_test`, produzione intatta (328 punti) ### 11.7 Accesso condiviso: rischio e criteri futuri (decisione 2026-08-16) **Scelta attuale**: una chiave API con accesso COMPLETO in lettura/scrittura per l'intera conoscenza; `agent_id` è solo provenienza, non isolamento. Ambiente personale con agenti fidati (7 agenti, 2026-08-16). **Rischio documentato**: nessun namespace per tenant/agente con enforcement dell'autorizzazione (best practice AWS/OWASP: actor scoping su ogni read/write). Un agente compromesso o malevolo può leggere/scrivere/correggere qualsiasi record, incluso supersedere memorie altrui. **Criteri per passare a chiavi per-scope** (quando uno di questi si verifica): 1. Entrano agenti di terze parti o non pienamente fidati 2. Il numero di agenti supera ~10 o i progetti superano ~30 3. Si registra un incidente di sicurezza o un accesso anomalo 4. Serve audit per-agente affidabile (oggi l'agent_id è auto-dichiarato) **Implementazione futura suggerita**: mappa chiave→scope/permessi nel gateway (es. `API_KEYS=readonly:xxx,write:yyy` o chiavi con claim), filtro obbligatorio per scope/kind/project_id in base alla chiave, senza cambiare l'API pubblica. ### 11.8 Reranker: rimandato (decisione 2026-08-16) Con l'hybrid retrieval (sez. 11.9) i candidati sono fusi con RRF ma senza reranker di qualità. **Decisione**: rimandato — a ~330 record il beneficio è marginale e la latenza aggiuntiva (100-500ms/query con qwen3 locale) non vale il costo. **Criteri per implementarlo** (quando uno si verifica): 1. Volume > ~5k record o precisione insufficiente segnalata dalle metriche (dashboard Grafana: hit rate, latenza) 2. Query con molti candidati ambigui (top-8 con score simili) 3. Latenza accettabile: rerank dei top-8 con qwen3:1.7b su Ollama (brain), opt-in via parametro `rerank: true` (stesso pattern di `hybrid`) ### 11.9 Hybrid retrieval (dal gateway v2.6.0 / estensione v1.7.0) - `hybrid: true` in search → BM25 (sparso, fastembed Qdrant/bm25) + vettoriale, fusione RRF; default invariato (punteggi cosine) - Sparse vector `bm25` (modifier IDF) + indice TEXT su `text`; migrazione automatica (create_vector_name + backfill) all'avvio - `min_score` applicato al prefetch denso (anti-rumore); i punteggi hybrid sono RRF, non cosine - qdrant-client 1.19.0 (create_vector_name, query_points); fastembed 0.5.1 con pre-download del modello nel Dockerfile ### 11.10 INCIDENTE 2026-08-16: backfill che cancella payload e vettori densi **Sintomo**: dopo il deploy dell'hybrid retrieval, tutti i record hanno perso payload (testo, metadata) e vettore denso; restano solo i vettori sparsi bm25. **Causa**: `_backfill_sparse()` usava `qdrant.upsert` con `PointStruct` che contiene SOLO il vettore sparso. In Qdrant l'upsert **sostituisce l'intero punto** (payload + tutti i vettori). Il backfill ha quindi sovrascritto 330 record di produzione. **Fix**: `qdrant.update_vectors()` — aggiorna SOLO i vettori specificati, preservando payload e vettori non menzionati ("Keeps payload and unspecified vectors unchanged"). **Recovery**: 278/330 record ricostruiti dalle sessioni pi (chiamate qmem_store/qmem_correct con memory_id nei toolResult, incluse sessioni egeos-copilot). 52 record persi (agenti su altre macchine: agy, payagent, pi-local). Script: estrazione `/tmp/extract_qmem3.py` + ripristino `/tmp/recover_qmem2.py` (incrementale, salta i già ripristinati). **Lezioni**: 1. MAI usare `upsert` per aggiornamenti parziali in Qdrant — usare `update_vectors`/`set_payload` 2. Il backup giornaliero non era mai partito: il redirect del cron su `/var/log` falliva (permessi root) — log spostato in `/home/enne2/archive/backups/memory/backup.log` 3. Testare le migrazioni su una collection di test con dati REALI (non solo record creati dopo il backfill) ### 11.11 Istanza frigate (copia del Memory Gateway, embedding via llama.cpp) > Deploy 2026-08-18 — copia del server memoria su frigate.vpn (10.8.0.18), > con embedding forniti dal router llama.cpp locale (niente Ollama). **Stack** (`/home/enne2/memory-frigate/`): - Qdrant 1.19: container `memory-frigate-qdrant`, bind 127.0.0.1:6333/6334, API key + JWT RBAC - Gateway: container `memory-frigate-gateway`, bind 10.8.0.18:8082 (VPN), collection `memories` - Embedding: **llama.cpp router** (`llama-router-vulkan.service`, porta 8081) con modello `bge-m3` (Q8_0, 1024-dim, `doof-ferb/bge-m3-gguf`) **Config gateway** (env): `EMBED_API=llamacpp`, `EMBED_URL=http://host.docker.internal:8081`, `EMBED_MODEL=bge-m3`, `EMBED_API_KEY=sta.cippa` (chiave router). **Modello embedding nel router** (`models.ini`): ```ini [bge-m3] model = /home/enne2/dev/vulkan.cpp/models/bge-m3-Q8_0.gguf alias = bge-m3 embedding = true ctx-size = 8192 pooling = cls embd-normalize = 2 gpu-layers = 0 ; CPU: evita contesa GPU con i modelli di generazione ``` Dopo modifiche a models.ini: `systemctl --user restart llama-router-vulkan`. **Backend embedding nel gateway** (v2.7.0): `EMBED_API=ollama|llamacpp` (OpenAI-compatible `/v1/embeddings` con Bearer key); `EMBED_URL` sostituisce `OLLAMA_URL` (alias retrocompatibile). **Config estensione per usare frigate**: `~/.config/pi-qmem/config.json` → `{"url": "http://10.8.0.18:8082", "apiKey": ""}`. **Nota**: istanza indipendente (collection vuota). Per replicare i dati da brain: snapshot Qdrant + restore (sez. 4) o re-embedding dei record. ### 11.5 project_id obbligatorio - Dal gateway v2.4.0 / estensione v1.4.0: `project_id` è **obbligatorio** in `POST /v1/memories` (Pydantic `min_length=1`) e nello schema del tool `qmem_store` (Type.String, non più Optional) - POST senza `project_id` → **422** (validazione Pydantic); il tool rifiuta la chiamata senza project_id - `qmem_correct` mantiene `project_id` opzionale: lo **eredita** dal record superseduto (GET/search) — il gateway valida comunque il risultato - L'agente consulta `qmem_meta` per riusare gli id esistenti; domini nuovi → kebab-case - Regola nel prompt: AGENTS.md sez. "Obbligo di project_id" ### 11.3 Verifica rapida ```bash # crea record falso OID=$(curl -s -X POST $B/memories -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \ -d '{"text":"...falso...","kind":"fact"}' | jq -r .memory_id) # supersede curl -s -X POST $B/memories -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \ -d "{\"text\":\"...corretto...\",\"supersedes_id\":\"$OID\",\"supersede_reason\":\"motivo\"}" # verifiche: GET vecchio → superseded_by; search default esclude; include_superseded=true mostra ``` *Fine playbook — aggiornato 13/08/2026.*