guest@6admin.io:~$

opencode-memory: my agent sessions share one brain now (SQLite, local)

Agent sessions have the memory of a goldfish with tenure: each one starts brilliant and ignorant, rediscovers half of your architecture decisions and dies with everything it learned. Compaction makes it worse, not better: the session’s own summary is what gets truncated. opencode-memory (v0.3.0, TypeScript, npm @log0u7/opencode-memory) is my fix: one shared SQLite store that every session on the machine reads and writes, so decisions and facts outlive the session that produced them. One package, two hosts: the same module has exposed both entry points since v0.2.0, server() for OpenCode 1.18, setup() for OpenCode 2.

Four tools, one store

Every session gets memory_save, memory_search, memory_list, memory_forget. The store lives at ~/.local/share/opencode-memory/memory.db (honors XDG_DATA_HOME), in WAL mode so concurrent sessions do not queue behind each other. Nothing to do manually: the plugin injects a system guidance pushing to save durable facts as they emerge and the first request of each session carries a deterministic inventory of the project’s recent entries (titles, kinds, dates, no bodies, ~1200 characters worst case). Reading memory no longer depends on the model remembering to call memory_search; the tool stays for deeper lookups. The store also keeps its own radius in check: memory_save rewrites an entry repeating the same scope and title instead of stacking duplicates, expired rows are purged on save, limits are clamped to 1..50 and bodies render as 500-character previews. You can also drive it by hand:

memory_save(kind="decision", title="Use WAL mode", body="...", tags=["db"])
memory_search("postgres migration strategy")
memory_forget(id="...")

The kind field is free-form, but the conventions that stuck are telling: decision, fact, preference, gotcha, compaction, lesson. “Gotcha” is the most valuable one: the thing the last session swore at for an hour, preserved so the next one skips the swearing.

Scopes: the worktree is the memory

Entries default to the session’s project worktree scope, with a global scope for machine-wide truths (“this GPU runs Ollama on port 11434”). Why the worktree and not the repo? Because the same repo checked out in two worktrees can legitimately diverge and because multi-session work (see opencode-mesh ) happens across worktrees: a worker in wt/fix-parser should not pollute the main checkout’s memory, but a machine-level fact (“the CI runner is down”) must reach everyone.

The two entry points had also misunderstood each other here: in v1 the scope derived from the session worktree, in v2 from the plugin directory and one session could write to two addresses depending on the host. Both now derive one canonical scope key and memory_forget respects the boundary: by query, the delete never leaves the caller’s scope (plus global), so a fuzzy match can never wipe another project’s entries; by id, it aims anywhere, because ids are globally unique and a closed worktree must stay cleanable from anywhere.

Search is SQLite FTS5 with BM25 ranking and a newest-first tie-break on equal scores. That choice matters more than it sounds: at equal BM25, last week’s decision must come before last year’s abandoned experiment. And a strict match returning nothing is retried as a prefix OR, so “migrat” still finds the “migration strategy” entry.

The two guards worth writing down

Secrets are refused at the door. Entries that look like credentials (sk-..., ghp_..., xoxb-...) throw a SecretDetectedError instead of being saved. A shared memory store is exactly where a careless “save the API config” prompt ends up, so the plugin treats memory as a trust boundary and validates input like one. And the scan covers title, kind, body and tags: a credential nested in a title or a kind used to bypass the guard, it no longer can (the kind also has a length cap).

Compaction becomes a save point. The plugin hooks session compaction and asks the compaction prompt to emit durable decisions as memory entries. This is the design insight of the whole project: compaction is where context is lost by construction, so it is also where saving matters most. The summary still gets truncated; the decisions now escape first. Since v0.3.0, the compaction context also lists the already-stored entries next to the save instruction: the model summarises from an inventory instead of recall and stops re-saving what is already there.

Lessons: the failure that does not repeat

The missing loop was experience feeding back. The system guidance and the compaction prompt now push the agent to save reusable lessons (kind=lesson) when a non-obvious approach fails or succeeds and to check the stored lessons before retrying an approach that already crashed. In later sessions, lessons come first in the inventory (“lessons first, then newest”), so yesterday’s failure precedes the recency noise: store.list gained a kind filter for that.

The second floor, optional and off by default: an LLM reflection at the session.idle event. Enabled, it sends the bounded tail of the session transcript to one LLM call that distills at most maxLessons durable lessons and saves them through the same store, secret guard included. Subagent sessions are skipped, failures are silent (best-effort) and a session is marked “reflected” only once a run is actually attempted: a too-short transcript stays retryable on the next turn.

{
  "plugins": {
    "@log0u7/opencode-memory": { "reflection": { "enabled": true } }
  }
}

On a v1 host, the same options travel in the tuple form under the plugin key: ["@log0u7/opencode-memory", { "reflection": { "enabled": true } }], which v2 normalizes at load. Options: enabled (false by default), model ("provider/model", default the host’s model), maxLessons (3, cap 10) and transcriptChars (8000).

One honesty line about versions: v0.3.0 carries everything before this section except the global id-based forget and the short-transcript reflection retry, merged on main, not yet tagged, like the lesson loop.

Why local, why SQLite, why not a vector DB

No cloud, no API keys, no embeddings service: the memory of an agent that reads my private repos should not leave the machine it reads them on. And no vector database, on purpose: FTS5 handles exact and fuzzy keyword recall (technical memory is full of identifiers: table names, flag names, error strings), the store is one file I can back up with cp and the plugin has no runtime dependency at all: the store is the host’s built-in SQLite (bun:sqlite under Bun, node:sqlite under Node 22.5+). Embeddings would have been the impressive choice; grep-shaped recall is the one that actually matches what sessions ask for.

One brain

In the end three convictions carry this store. Session compaction is the real memory boundary, hooking it turns losing context into saving it. Scopes are a security feature, worktree-scoped memory limits the blast radius, global is a privilege, not a default. And boring recall wins, BM25 with a newest-first tie-break covers agent memory needs, the fancy vector store can wait until recall measurably fails.