opencode-memory: Memoria Persistente Locale per OpenCode
Come ho costruito un plugin di memoria persistente per OpenCode con ciclo di vita WRITE → DREAM → SURFACE, storage locale JSON senza database esterni, supporto OpenCode V1 e V2 e un benchmark falsificabile con set held-out.
Aggiornato a ottobre 2026 — v1.7.2. Dalla prima versione di questo articolo (v1.5.0) il plugin ha ricevuto un rilascio di hardening (1.5.1), una revisione completa del retrieval (1.6.0), il supporto a OpenCode V2 (1.7.0) e opzioni di configurazione tipizzate (1.7.1–1.7.2). Alcuni numeri che avevo pubblicato erano ottimistici: più sotto spiego quali e perché.
Il problema
Gli AI agent ricominciano da zero a ogni sessione: il contesto della conversazione precedente è sparito. Le preferenze comunicate una volta vanno ripetute, le decisioni prese vanno ridiscusse, e le informazioni scoperte con fatica vengono perse.
Le soluzioni "classiche" richiedono un database vettoriale, embeddings, un servizio cloud. Volevo qualcosa di più radicale: memoria persistente locale, senza database esterni, senza embeddings obbligatori — un file JSON che sopravvive alle sessioni e un ciclo di vita chiaro che decide cosa ricordare, cosa consolidare e cosa iniettare nei prompt.
Nasce così opencode-memory, un plugin open source per OpenCode.
Il ciclo di vita: WRITE → DREAM → SURFACE
Il plugin organizza la memoria in tre fasi:
WRITE → DREAM → SURFACE
1. WRITE — memoria esplicita
L'agente (o l'utente) scrive fatti in modo esplicito tramite memory_write, con metadati che governano la durata e la visibilità:
- tier:
core(sempre iniettata),archival(su rilevanza),temporary(auto-expiry),pinned(mai soggetta a decay) - sensitivity:
local-onlyper fatti che non devono mai rientrare in un contesto visibile al modello - ttlHours: scadenza automatica
Ogni fatto esplicito ha una provenienza auditabile: so chi l'ha scritto, quando, e con quale confidenza.
Dalla 1.7.2 le scritture parallele sono sicure: due fatti distinti con formulazione simile (anche inviati insieme con Promise.all) restano entrambi, e le mutazioni dello store vengono accodate tra istanze che condividono la stessa directory. Lo stesso testo ripetuto aggiorna il fatto esistente; per correggerlo si usa memory_update.
2. DREAM — consolidamento senza tool
Il pezzo più interessante. Quando una sessione resta inattiva, il plugin rilegge la conversazione e ne estrae i fatti impliciti — ciò che l'utente ha deciso, le sue preferenze, le informazioni apprese.
Il modo in cui lo fa dipende dalla versione di OpenCode:
- OpenCode V2: consolidamento, deduplicazione semantica e rerank usano
ctx.generate.text(), una generazione isolata che non crea sessioni, non espone tool e non scrive nella cronologia. - OpenCode V1: il lavoro gira in sessioni figlie headless create con un unico divieto wildcard (
tools: {"*": false}). Nella prima versione disabilitavo solo i tool di memoria per nome: shell, filesystem, rete e MCP restavano disponibili a una sessione che elabora testo non fidato. Ora il modello non vede nessun tool.
Il consolidamento resta robusto:
- Crash recovery: un journal a due fasi (
inProgress+lastCompletedTs) garantisce semantica at-least-once senza duplicati; uno sweep periodico riprende i consolidamenti interrotti - Orphan GC (V1): sessioni figlie abbandonate vengono ripulite automaticamente
- Deduplicazione semantica: cross-language (IT/EN), evita duplicati tra fatti espliciti e sognati
- Isolamento per progetto: un consolidamento nel progetto A non può mai sopprimere, fondere, riscrivere o segnalare fatti del progetto B
Ogni fatto sognato sa da quale sessione e da quali messaggi proviene, con un punteggio di confidenza — niente fatti fantasma.
3. SURFACE — iniezione selettiva
Non tutta la memoria finisce nei prompt: quella sarebbe solo rumore. La SURFACE è relevance-gated:
- pipeline lessicale che genera candidati rispetto alla query corrente
- un relevance gate esplicito (
passesRelevanceGate): un candidato senza almeno un match "pulito" non entra mai nel blocco iniettato - slot riservati ai fatti
core(sempre iniettati, oltre i match) - feedback dall'uso reale:
memory_usefulememory_irrelevantaggiustano il ranking in base a ciò che funziona davvero
E c'è memory_why: per ogni fatto iniettato puoi chiedere perché è stato iniettato — punteggio base, match, bonus, rank finale.
Un dettaglio che ho corretto nella 1.5.1: l'esposizione automatica aggiornava la "freschezza" di un fatto. Risultato: i fatti mostrati spesso restavano freschi per sempre, in un loop auto-rinforzante. Ora l'esposizione è registrata a parte (lastSurfaced, surfacedCount) e il decay si azzera solo con una conferma esplicita: essere mostrato al modello non è una prova che un fatto sia ancora vero.
Zero database esterni
Niente ChromaDB, niente Redis, niente servizi cloud. La memoria vive in JSON locale con file locking (~/.local/share/opencode/memory), in tre parti:
store/— i fattistate/— stato operativo del ciclo di vitasummary/— statistiche e riepilogo
È una scelta architetturale voluta: la memoria deve essere leggibile, versionabile e portabile. Un file JSON lo puoi ispezionare, copiare, fare il backup con rsync. Un database no.
Retrieval: confini di token, non sottostringhe
Il retrieval di default è puramente lessicale, niente embeddings. Nella 1.6 l'ho ricostruito come una catena piccola e testabile pezzo per pezzo:
tokenizer → keyword → espansione alias → candidati → match sui confini di token
→ scoring → relevance gate → surface (+ rerank opzionale con astensione)
Le regole principali:
- I confini di token sono sacri.
usenon trova piùuser,testnon trovapytest,rednon trovaredesign. - Gli identificatori sono cittadini di prima classe.
userStore→user+store;JavaScripttrova sia "javascript" sia "java script", mai una metà da sola. - Morfologia esplicita e minima: plurale, gerundio e participio, in entrambe le direzioni. Nient'altro.
- I sinonimi contengono solo termini sostituibili (
colore↔color,db↔database). Verbi d'opinione come "prefer" o "favorite" non sono sinonimi di nulla. memory_readsupporta query multi-termine:coffee morningtrova "coffee in the morning", in qualsiasi ordine.
L'embedding semantico resta un upgrade opzionale (rerank: true): un rerank su una finestra di candidati (default 30), con timeout di 4s e fallback all'ordine lessicale, cache per query e — novità — la possibilità di astenersi quando nessun candidato risponde alla domanda.
Contraddizioni, non sovrascritture
Quando un nuovo fatto contraddice uno esistente, il plugin non sovrascrive in silenzio: marca il fatto come CONFLICTED e lo segnala nel Memory Inspector (/memory). La risoluzione è esplicita: memory_update corregge il fatto precedente con piena tracciabilità.
I dati falsi o obsoleti non spariscono di nascosto — vengono flaggati, auditati e risolti.
I numeri del benchmark (e cosa avevo sbagliato)
Nella prima versione di questo articolo riportavo 0% di false-surface su 90 casi. Era vero, ma per costruzione: la metrica contava le iniezioni non supportate dopo che il filtro di rilevanza le aveva già scartate. Non poteva fallire. Peggio: la parola "user" non era tra le stopword, quindi quasi ogni query matchava quasi ogni fatto e il gate non filtrava davvero nulla.
Nella 1.5.1 ho riscritto il benchmark perché fosse falsificabile, e nella 1.6 ho aggiunto un set held-out scritto prima dei fix e mai usato per iterare:
- DEV: 115 scenari (77 positivi, 38 negativi in sei categorie)
- HELD-OUT: 20 scenari, con trappole su identificatori, nomi di pacchetti e percorsi
DEV Recall@5 100% · MRR 95.2% · surface precision 63.2% macro / 81.8% entry-level
FPR 34.2% (13/38) · overhead 9.1 token/query
HELD-OUT Recall@5 100% · MRR 100% · FPR 26.7% (4/15) · 0 false abstentions
(algoritmo v1.5.1 sullo stesso set: FPR 46.7%)Il falso-positivo è ancora alto, e lo dico apertamente: tutti gli errori residui sono match di token esatti con un intento diverso ("test account" contro una domanda sul framework di test, "server in cantina" contro la configurazione di un server MCP). È il tetto onesto del retrieval lessicale a zero dipendenze. Gli embeddings, per ora, non sono giustificati da questi dati: il problema è di intento, non di vocabolario.
OpenCode V2 e configurazione
Dalla 1.7.0 un unico pacchetto npm espone due adapter: V2 (setup, OpenCode 2.0.18+) e V1 (server, OpenCode 1.18.29+), sullo stesso formato di store. Nella 1.7.1 sono arrivate le opzioni tipizzate in opencode.jsonc, con interruttori indipendenti per ogni fase:
{
"plugins": [{
"package": "@cioffi_ai/opencode-memory",
"options": { "dream": true, "surface": true, "summary": false }
}]
}Un'opzione sconosciuta o non valida fa fallire il setup con il suo nome, invece di ricadere silenziosamente sul default. Spegnendo DREAM o SURFACE i tool espliciti restano disponibili; memory_clear({ summaryOnly: true }) cancella il riepilogo condiviso senza toccare i fatti.
Prima di ogni pubblicazione, bun run verify:package impacchetta il tarball, lo installa fuori dal repository e verifica i nove tool memory_* su entrambe le generazioni di OpenCode, con un modello deterministico locale e senza chiamate a pagamento.
Privacy
La memoria è sempre locale: JSON + lockfile, nessun servizio cloud del plugin. Tre note:
- Durante il DREAM la conversazione passa dal provider LLM configurato in OpenCode — se è remoto, il testo va a quel provider, esattamente come qualsiasi altra richiesta OpenCode.
- I fatti
local-onlysono esclusi da ogni percorso visibile al modello: blocco di memoria, prompt di consolidamento e dedup, candidati del rerank e tutte le risposte dei tool (memory_read,memory_why,memory_inspect). Si leggono solo ispezionando i file a mano. - Nella prima versione scrivevo che questi fatti "non lasciano mai la macchina". Non era garantibile: la chiamata
memory_writestessa passa dal modello, e se il fatto era nella conversazione il provider l'ha già visto.local-onlyimpedisce la ri-esposizione nelle sessioni future, non può ritirare ciò che è già uscito. Ora il README lo dice esplicitamente.
Se vuoi zero trasmissioni: OPENCODE_MEMORY_OFF=1.
Cosa ho imparato
- La memoria senza embeddings funziona — fino a un tetto misurabile. Il match lessicale ben fatto batte l'embedding mal configurato, e ora so esattamente dove smette di bastare.
- Un benchmark che non può fallire non misura niente. Il mio 0% di false-surface era un artefatto. Negativi veri e un set held-out mi hanno mostrato il 46.7% reale, e poi quanto l'ho ridotto.
- La provenienza è tutto. Sapere perché un fatto è in memoria (fonte, sessione, confidenza) trasforma un blob opaco in uno strumento fidato.
- Le promesse di sicurezza vanno scritte al livello che puoi garantire. "Mai fuori dalla macchina" suonava meglio di "mai ri-esposto", ma solo il secondo è vero.
- Il ciclo di vita conta più dello storage. WRITE → DREAM → SURFACE è il vero design: dove salvi è un dettaglio, come decidi cosa ricordare e cosa iniettare è il prodotto.
Dove trovarlo
- GitHub: github.com/cioffiAI/opencode-memory
- npm:
@cioffi_ai/opencode-memory(v1.7.2, OpenCode V1 e V2) - Benchmark:
bun run bench(aggiungi--heldoutper il set held-out)
Il plugin è open source (MIT) e installabile con una riga di configurazione. La prossima tappa è la 2.0: memoria condivisa e scoped per agente, graph memory con relazioni tra entità, sync multi-dispositivo cifrata e import/export.