- gateway: supersedes_id validato (404/409), backlink superseded_by+superseded_at+supersede_reason sul vecchio record, indici keyword, search esclude i superseduti di default (include_superseded per lineage), risposta con lineage - estensione: tool qmem_correct (memory_id o query), qmem_store con supersedes_id/supersede_reason, qmem_search con include_superseded - README/playbook aggiornati, versione 1.1.0
14 KiB
14 KiB
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)
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 32in.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 suagent_id,project_id,scope,kind expires_atsalvato 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:
# 1. Backup dati (anche se sembra vuoto)
docker exec <container> redis-cli SAVE
cp -a <data-dir> /opt/backup/<nome>-$(date +%F)/
# 2. Individuare TUTTI i progetti compose (i container possono appartenere
# a progetti diversi con nomi diversi!)
docker inspect <container> --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'
cd <working_dir> && docker compose down
# 3. Rimuovere volumi e directory
docker volume ls | grep <nome>
docker volume rm <volume>
rm -rf <dir>
# 4. Verificare porte libere
ss -tlnp | grep <porta>
Lezioni apprese:
- Un container può appartenere a un progetto compose diverso da quello atteso (es.
docker-graphiti-falkordb-1era 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 <nome>)
4. Backup e disaster recovery
4.1 Backup Qdrant
# 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/<name>/snapshots?wait=true
Restore full-storage (unico metodo per snapshot full):
docker run -v <snapshot-dir>:/snapshots:ro -v <vol>:/qdrant/storage \
qdrant/qdrant:v1.19.0 ./qdrant --storage-snapshot /snapshots/<file>.snapshot
Restore collection (via API):
curl -X PUT -H "api-key: $KEY" -H 'Content-Type: application/json' \
http://127.0.0.1:6333/collections/<nuova>/snapshots/recover?wait=true \
-d '{"location": "file:///qdrant/snapshots/<collection>/<file>.snapshot", "priority": "snapshot"}'
4.2 Backup rsync incrementale (best practice)
Root cause di backup sempre falliti: virgolette letterali dentro una variabile shell:
# 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
latestper mantenere la catena incrementale (--link-dest) - Verifica incrementalità:
find <backup> -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/<nome>/ - Cron:
0 3 * * * /opt/memory/backup.sh(snapshot Qdrant + rotazione 7 giorni)
5. Reverse proxy Nginx e HTTPS
5.1 Configurazione proxy
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/<dominio>.conf(convenzione per-subdominio) - Verificare la connettività VPN prima (
curl http://10.8.0.3:8082/v1/statusdal proxy server) - Wildcard DNS
*.enne2.net→ nessun cambio DNS necessario sudo nginx -tprima di ogni reload
5.2 Certificato HTTPS
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 <dom>:443 -servername <dom> | openssl x509 -noout -subject -dates
6. Pulizia configurazioni Nginx (best practice)
Procedura per rimuovere un server block morto:
# 1. Individuare TUTTI i riferimenti al dominio
sudo grep -n '<dominio>' /etc/nginx/conf.d/*.conf
# 2. Identificare i confini del blocco (server { ... })
sudo cat /etc/nginx/conf.d/<file>.conf | sed -n '<start>,<end>p'
# 3. Rimuovere le righe del blocco
sudo sed -i '<start>,<end>d' /etc/nginx/conf.d/<file>.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://<dominio>/ # atteso: 000/444
Best practice:
- Mai rimuovere a mano i certificati: usare
certbot delete --cert-name <dominio> --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)
# 1. Elencare i certificati
sudo ls /etc/letsencrypt/live/
# 2. Identificare gli orfani (nessun server block li referenzia)
sudo grep -rn '<dominio>' /etc/nginx/conf.d/ # nessun match = orfano
# 3. Eliminare con certbot (pulisce live/, archive/, renewal/)
sudo certbot delete --cert-name <dominio> --non-interactive
# 4. Verificare
sudo ls /etc/letsencrypt/live/ | grep <dominio> # nessun output
Best practice:
- Sempre
certbot delete, mairm -rfmanuale (certbot gestisce live/, archive/, renewal/ e i log) - Un certificato è orfano se: nessun
server_namelo 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
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
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6333/collections # senza key → 401
8.3 Verifica dati e ricerca
# Conteggio punti
curl -s -H "api-key: $KEY" http://127.0.0.1:6333/collections/<name> | 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/<name>/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_counte 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 <gateway> | grep '"action"'
9. Estensione pi e pubblicazione
9.1 Struttura pi package
{
"name": "pi-qmem",
"keywords": ["pi-package"],
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
},
"pi": { "extensions": ["./extensions"] }
}
typeboxe@earendil-works/pi-coding-agentin 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
tea repo create --name <repo> --description "<desc>" [--private]
git remote add origin https://git.enne2.net/enne2/<repo>.git
git push -u origin main
pi install git:git.enne2.net/enne2/<repo>
- Verifica privacy:
curl -s -o /dev/null -w '%{http_code}' https://git.enne2.net/enne2/<repo>→ 404 = privato - Aggiornamento:
git push+pi update git:git.enne2.net/enne2/<repo>
9.3 Menu di configurazione estensione
/qmem:config→ menu TUI: URL gateway, API key, test connessione, mostra config- Modalità CLI:
/qmem:config url <URL> | apikey <KEY> | test - Config in
~/.config/pi-qmem/config.json(0600) ctx.hasUIper 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/memoriesconsupersedes_id→ crea il nuovo record e marca il vecchio consuperseded_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
IsEmptyConditionsusuperseded_by);include_superseded=trueper la lineage - Indici keyword su
supersedes_idesuperseded_bycreati 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 chiamataqmem_storeaccetta anchesupersedes_id/supersede_reason(supersede esplicito)qmem_searchaccettainclude_superseded; i risultati mostranosupersedes_id/superseded_by/supersede_reason
11.3 Verifica rapida
# 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.