opencode-episodic-memory
v0.3.2
Published
Semantic search over past OpenCode conversations — local embeddings (Transformers.js), SQLite index, native plugin tools. Inspired by obra/episodic-memory, rebuilt natively for OpenCode.
Maintainers
Readme
opencode-episodic-memory
Semantic search over your past OpenCode conversations. Remember past discussions, decisions, and patterns across sessions.
Inspired by obra/episodic-memory, rebuilt for OpenCode primitives — native plugin tools instead of an MCP server, plugin events instead of hooks, and OpenCode's own session database as the source.
Wondering how this compares to opencode-mem, codemem, memsearch, and the rest of the OpenCode memory-plugin landscape? See docs/alternatives.md.
How it works
- Read — sessions/messages/parts from OpenCode's
~/.local/share/opencode/opencode.db(read-only) - Parse — condensed exchanges (user text, assistant text, tool names; no reasoning blobs or tool output)
- Embed — local, offline embeddings via Transformers.js in a lazily started, idle-evictable system-Node sidecar (
Snowflake/snowflake-arctic-embed-m-v1.5q8, 768 dims; retrieval prefix on search queries). Chosen by empirical eval on a real corpus — see docs/embedding-model-eval.md - Index — plain SQLite at
~/.local/share/opencode-episodic-memory/index.dbby default; optional libSQL/Turso remote storage can combine source-scoped indexes across devices - Recall — native plugin tools
episodic_search/episodic_read_window/episodic_read_session, plus aremembering-conversationsskill that teaches the agent when to search - Stay fresh — the plugin re-indexes each session on the
session.idleevent
Design note: bun:sqlite cannot load dynamic extensions, so sqlite-vec is not
usable inside OpenCode plugins. Brute-force cosine is single-digit milliseconds
at this scale (thousands of chunks) and has zero native-dependency risk. The
store layer is the single swap point if a real ANN index is ever needed. FTS5 is
compiled into bun:sqlite (not a loadable extension), so lexical BM25 ranking
is available; search is vector-only by default, with lexical and hybrid
(reciprocal-rank-fusion) modes opt-in — hybrid is off by default because BM25
tends to match injected boilerplate on this corpus.
Install
opencode plugin [email protected] -gThis adds the plugin to your OpenCode config (-g = global config; omit it
to install for the current project only). Pin the version — OpenCode
caches npm plugins and never re-resolves a bare name / @latest
(anomalyco/opencode#25293).
To update later, re-run with the new version and --force.
Or edit ~/.config/opencode/opencode.json manually:
{
"plugin": ["[email protected]"]
}Default sidecar-mode semantic indexing and vector/hybrid search require a system
Node 20+ binary (node by default). The first embedding run downloads the
model (~100 MB, cached afterward). The model and its native runtime live in
that Node sidecar, not inside OpenCode's Bun/TUI process. The warm sidecar is
stopped after its configured idle timeout to release model memory: the host
sends SIGTERM, then SIGKILL if the child remains alive one second later. Output
from retired children is ignored. The next embedding request waits for the old
child to exit before starting a fresh one and reloading the cached model, so the
first request after idle eviction has model-loading latency. Explicit
EPISODIC_EMBED_MODE=inline works without Node but is unsafe in affected
OpenCode/Bun versions. episodic_read_window, episodic_read_session, and lexical text search also remain
available without Node.
Install the skill so the agent knows when to search, via the
skills CLI:
npx skills add robertn702/opencode-episodic-memory -g(-g installs to ~/.config/opencode/skills/; omit it to install into the
current project. npx skills update picks up future skill changes.)
Alternatively, copy it manually — it's included in the npm package; once OpenCode has downloaded the plugin (i.e. after first launch), copy it out of the package cache (the path contains your pinned version):
cp -r ~/.cache/opencode/packages/[email protected]/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/Then backfill existing history and restart OpenCode:
bunx [email protected] syncCLI
The package ships an opencode-episodic binary (requires bun on PATH).
Invoke it through the package spec — pin it to match your plugin version:
bunx [email protected] sync [--force] # index new/changed sessions
bunx [email protected] search "query" # semantic (vector) search
bunx [email protected] search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
bunx [email protected] search q --hybrid # fuse vector + BM25 (RRF; opt-in)
bunx [email protected] search q --after 2026-07-01 --limit 5
bunx [email protected] read <session-id> # full transcript (live store)
bunx [email protected] read <id> --indexed # local indexed excerpts
bun run src/cli.ts read <id> --indexed --source laptop # remote indexed excerpts (development/source checkout)
bunx [email protected] stats # index statistics
bunx [email protected] doctor # diagnose setup--after/--before take YYYY-MM-DD (midnight UTC). --after D is inclusive
of day D; --before D is exclusive of day D (i.e. up to the start of that day).
Agent tools
episodic_search—query(+ optionaltext,mode: vector|text|hybrid,after,before,limit).vector(default) is semantic;textis lexical BM25;hybridfuses both via RRF (opt-in — can surface lexical noise). Returns dated excerpts with session IDs, scores, and message anchors. Remote indexes are vector-only: the plugin exposes onlyvectorthere, and explicit remotetext/hybridrequests fail before embedding with actionable guidance rather than silently falling back. Local text/hybrid behavior is unchanged.episodic_read_window—session_id,anchor_message_id(+ optionalsource_id, required for remote indexes;before,after, each 0-20, default 3). Current-source hits use privacy-gated live messages. Foreign-source hits use labeled indexed exchanges around the anchor, with before/after counting chunks instead of messages and each chunk rendered at up to 600 UTF-8 bytes. Missing or stale anchors cannot expand; useepisodic_read_sessionwith the same session/source andindexed: truefor available indexed excerpts.episodic_read_session—session_id(+ optionalsource_id, required for remote indexes;indexed). Reads the full live transcript, or indexed excerpts when requested, deleted, or on another source. Current-source indexed reads still check the live privacy marker and withhold cached content if validation fails. Preferepisodic_search->episodic_read_window->episodic_read_session, stopping once enough context has been recovered.
Excluding conversations
Any conversation containing this marker is archived nowhere and indexed nowhere:
DO NOT INDEX THIS CHATNote: the marker is matched as a bare substring anywhere in any message part, so this also excludes conversations that merely quote the phrase (such as discussions about this tool itself). This is broader than upstream's full instruction-tag match — the intent is the same, but our matching is literal.
Configuration (env vars)
| Variable | Default | Purpose |
|---|---|---|
| EPISODIC_SOURCE_DB | ~/.local/share/opencode/opencode.db | OpenCode session store |
| EPISODIC_INDEX_DB | ~/.local/share/opencode-episodic-memory/index.db | Index location |
| EPISODIC_INDEX_URL | unset | Opt-in libSQL/Turso index URL; activates remote vector-only mode |
| EPISODIC_INDEX_AUTH_TOKEN | unset | Required for remote network URLs; passed to @libsql/client |
| EPISODIC_SOURCE_ID | unset | Required in remote mode; stable device/source identity |
| EPISODIC_EMBED_MODEL | Snowflake/snowflake-arctic-embed-m-v1.5 | Transformers.js embedding model |
| EPISODIC_EMBED_MODE | sidecar | sidecar runs embeddings in Node; inline is an explicit escape hatch |
| EPISODIC_NODE_BINARY | node | Node 20+ executable used by sidecar mode |
| EPISODIC_EMBED_BATCH_SIZE | 32 | Texts per sidecar request (1-64) |
| EPISODIC_EMBED_READY_TIMEOUT_MS | 600000 | Maximum wait for sidecar/model startup |
| EPISODIC_EMBED_REQUEST_TIMEOUT_MS | 120000 | Maximum wait for a post-startup embedding request |
| EPISODIC_EMBED_IDLE_TIMEOUT_MS | 300000 | Stop an idle sidecar after this many milliseconds; 0 disables idle eviction |
EPISODIC_EMBED_MODE=inline loads Transformers.js native addons directly in
OpenCode's embedded Bun process. It exists only as an explicit compatibility
escape hatch and is unsafe with affected OpenCode/Bun releases that can crash
during native-addon teardown. It is never selected automatically if sidecar
startup fails. Run bun run src/cli.ts doctor to diagnose the selected mode,
Node version, and a real embedding.
The sidecar idle timer starts only after initialization and all embedding work has completed. Startup, queued requests, in-flight work, and sequential batches keep it alive. Background indexing counts as embedding activity. Invalid timeout settings fail before a sidecar is spawned; the timer is unref'd so it does not keep the CLI running. Idle eviction reduces retained idle memory, but does not cap memory across simultaneously active runtimes.
Optional shared remote index
Set all three variables on each device (use a distinct, stable source ID per device):
export EPISODIC_INDEX_URL='libsql://your-index.turso.io'
export EPISODIC_INDEX_AUTH_TOKEN='...'
export EPISODIC_SOURCE_ID='laptop'This is an explicit privacy boundary: sync still reads OpenCode locally and
generates embeddings locally, but it uploads condensed chunk text, session
metadata, anchors, and embedding vectors to the configured database. Do not
configure it unless that database is appropriate for this conversation data.
Sessions containing DO NOT INDEX THIS CHAT upload neither content nor a
metadata tombstone; an existing row for that source/session is removed.
Absolute local file: libSQL URLs are supported without a token for hermetic
testing; network
URLs must use https:, wss:, or TLS-enabled libsql: and require a token
supplied only through EPISODIC_INDEX_AUTH_TOKEN (not embedded in the URL).
For libsql:, omit tls or use exactly one lowercase tls=1 parameter.
The remote schema is freshly created or validates/adopts a compatible existing
schema and does not use FTS, so remote mode supports vector search only. --text,
--hybrid, and plugin text/hybrid modes fail clearly in remote mode.
Remote cosine search reads embedding candidates in bounded pages and hydrates
only the final result set.
Remote search includes the source ID in each hit. source_id is required for
remote indexed reads. Another device's hit can be read through the bounded
indexed foreign-source path in episodic_read_window when its anchor is
present and current; otherwise use episodic_read_session with
indexed: true. These are condensed indexed excerpts, not a live transcript;
indexes remain stale until the source syncs and carry no live privacy or
freshness guarantees across devices.
Network failures are surfaced by the CLI and doctor; plugin background reindex
logs failures and never silently falls back to a local index (which would split
history). To return to local-only mode, unset EPISODIC_INDEX_URL,
EPISODIC_INDEX_AUTH_TOKEN, and EPISODIC_SOURCE_ID; the existing local index
is selected unchanged. Re-run sync if the local index needs rebuilding.
Not yet implemented (deliberate)
- LLM-generated per-session summaries embedded instead of raw exchange text
(upstream does this; deferred until search quality data says it's needed —
would use OpenCode provider auth via
client.session.prompt) - Multi-concept AND search, MCP server wrapper for non-OpenCode clients
- ANN index (see design note above)
Development
To hack on the plugin itself, clone the repo and point OpenCode at the local entrypoint instead of the npm package:
git clone https://github.com/robertn702/opencode-episodic-memory.git
cd opencode-episodic-memory
bun install// ~/.config/opencode/opencode.json
{
"plugin": ["/path/to/opencode-episodic-memory/plugin/episodic-memory.ts"]
}Inside the repo, run the CLI as bun run src/cli.ts <command> (same
subcommands as above), tests with bun test, and typechecking with
bun run typecheck.
License
MIT
