Files
pi-qmem/docs/playbook.md
T

16 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 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:

# 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-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 <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 latest per 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/status dal proxy server)
  • Wildcard DNS *.enne2.net → nessun cambio DNS necessario
  • sudo nginx -t prima 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, 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

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_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 <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"] }
}
  • 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

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.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.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_id422 (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

# 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.