Torna al blog
9 min di letturaopencode-memorypluginopencodeai-agentstypescriptmemoria

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-only per 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_useful e memory_irrelevant aggiustano 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 fatti
  • state/ — stato operativo del ciclo di vita
  • summary/ — 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. use non trova più user, test non trova pytest, red non trova redesign.
  • Gli identificatori sono cittadini di prima classe. userStore → user + store; JavaScript trova 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_read supporta query multi-termine: coffee morning trova "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:

  1. 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.
  2. I fatti local-only sono 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.
  3. Nella prima versione scrivevo che questi fatti "non lasciano mai la macchina". Non era garantibile: la chiamata memory_write stessa passa dal modello, e se il fatto era nella conversazione il provider l'ha già visto. local-only impedisce 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

  1. 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.
  2. 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.
  3. La provenienza è tutto. Sapere perché un fatto è in memoria (fonte, sessione, confidenza) trasforma un blob opaco in uno strumento fidato.
  4. 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.
  5. 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

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.