Your OpenCode agents remember everything. No more re-explaining.
Persistent cross-session memory via agentmemory — 95.2% retrieval accuracy on LongMemEval-S.
npx @agentmemory/agentmemoryThe server starts on http://localhost:3111.
Add to ~/.config/opencode/opencode.json or your project's .opencode/opencode.json:
{
"mcp": {
"agentmemory": {
"type": "local",
"command": ["npx", "-y", "@agentmemory/mcp"],
"enabled": true
}
}
}Copy the plugin file:
mkdir -p ~/.config/opencode/plugins
cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/That is the whole install. OpenCode loads every plugin file in
~/.config/opencode/plugins/ automatically, so there is nothing to register.
Do not also add it to
opencode.json. Listing the file under"plugins"on top of leaving it in the auto-loaded directory registers the same plugin twice, and it then captures every event twice. If you see doubled observations, remove the"plugins"entry and keep the file where it is.If you would rather keep plugins outside that directory, install the file somewhere else and point the config key at it:
{ "plugins": ["./my-plugins/agentmemory-capture.ts"] }Either location works. The two must not both be in effect at once.
OpenCode 1.x — the same file works, using the V1 key instead:
{
"plugin": ["./my-plugins/agentmemory-capture.ts"]
}See OpenCode 1.x and 2.x below.
Copy the commands into your project or global .opencode/commands/ directory:
mkdir -p ~/.config/opencode/commands
cp plugin/opencode/commands/recall.md ~/.config/opencode/commands/
cp plugin/opencode/commands/remember.md ~/.config/opencode/commands/Restart OpenCode or open a new session. The plugin auto-captures everything.
agentmemory-capture.ts supports both plugin APIs from a single file.
OpenCode 2 replaced the V1 Hooks-object plugin shape: the default export must
carry an id plus a setup(ctx), and hooks are registered on the domain that
owns the operation. The plugin therefore default-exports both:
export default {
id: "agentmemory-capture",
setup: v2Setup, // OpenCode 2.x
server: v1Hooks, // OpenCode 1.x (SDK 1.18.x era)
}- OpenCode 2.x reads
idandsetup(). - OpenCode 1.17 and 1.18 call
server()and use the returned hooks. They also callsetup(), with a context that has notool,sessionorevent.setup()returns immediately when those are missing, so only the V1 hooks run there. - The two implementations are separate on purpose. Sharing an export does not translate V1 hooks into V2 hooks, and the payloads differ enough that a shared core would overstate V2 coverage.
- The named
export const AgentmemoryCapturePluginexists for direct imports and for V1 releases older than 1.18.29, which expect a function as the default export. A V1 loader that resolvesdefaultand callsserver()on it sees one plugin; the named export is not consulted unless a loader iterates every export, which no observed version does.
V1 load, verified. OpenCode 1.17.10 and 1.18.34 (the opencode-ai npm
package) were run against this file in live sessions: one plugin instance,
reads and shell calls captured through the V1 hooks, and one config_loaded
per session.
Validated against OpenCode v2.0.22, and re-checked on v2.0.24, by running the plugin inside a live
session and logging the objects as they arrive, not by reading the generated
SDK types. That distinction matters: @opencode-ai/sdk 1.4.10 declares
event.properties and the V1 event names, and it is stale relative to the
runtime. Where this README says a payload was observed, it was read off a
running server.
- The payload is in
event.data, notevent.properties. The envelope iscreated, data, id, location, type. Readingpropertiesyields{}. - The V1 event names are gone. They were not renamed one-for-one; the
stream is organised differently.
locationon the envelope is process-level, so session identity comes fromdata.sessionID. list()returns{ data, location }.ctx.agent.list(),ctx.provider.list()andctx.mcp.list()all resolve to that shape.ctx.model.default()is a promise. Unawaited it is{}.session.createdexists. On v2.0.24 it carriessessionID,projectID,location,version,subpathandslug. The title arrives later insession.renamed, so a session registers on creation with its directory in hand. The fallback to "first event carrying an ID" remains for sessions that predate the plugin load, which never emit the event. An earlier revision of this plugin claimedsession.createddid not exist; that came from observing an already-open session and never creating one, which is absence in a sample rather than absence in the API.ctx.session.hook("compaction")registers, but its invocation is unverified. Callingctx.session.compact()only queues a compaction request; the hook would fire when the compaction actually runs. Forcing a compaction in a running v2.0.22 emittedsession.compaction.startedandsession.compaction.failedimmediately, withNothing to compact yet, because the session was empty. That proves the events fire and says nothing about the hook, which never got a real compaction to run. The hook is kept and the events are captured alongside it: if the hook is invoked it attaches memory to the compaction prompt, and the events record that compaction happened either way.session.execution.succeededtriggers/summarize. It fires when a run finishes and carries only{ sessionID }, so it adds no observation. V1 summarizes when a session goes idle; this is the V2 equivalent.session.instructions.updatedcarriesdelta, a map from instruction source to content hash. The plugin records the source names, not the hashes.- A standalone
opencode session deleteexits before the plugin seessession.deleted. The session then stays open in agentmemory. Summaries do not depend on it, because they come fromsession.execution.succeeded. parentIDis accepted byctx.session.createbut appears in no event payload. TheparentIDsent to/session/startis therefore always null; the field is kept in case a future version populates it.
| V1 | V2 | Status |
|---|---|---|
event |
ctx.event.subscribe() |
ported |
tool.execute.before |
ctx.tool.hook("execute.before") |
ported |
tool results (message.part.updated) |
ctx.tool.hook("execute.after") |
ported |
chat.message |
ctx.session.hook("prompt") |
ported |
experimental.chat.system.transform |
ctx.session.hook("context") |
ported |
experimental.session.compacting |
ctx.session.hook("compaction") |
ported, unverified |
config |
(no hook) | snapshot at setup, refreshed on *.updated |
chat.params |
(no equivalent) | not captured |
output.system (a string array) became event.system (SystemPart[]), so
injection pushes part objects rather than strings.
| V1 event | V2 |
|---|---|
session.created |
session.created (or first event carrying data.sessionID) |
session.deleted |
session.deleted |
session.status, session.idle |
session.step.started / session.step.ended; session.execution.succeeded triggers /summarize |
message.updated |
session.text.ended (assistant_message), session.reasoning.ended |
message.part.updated |
ctx.tool.hook("execute.after") |
command.executed |
the command arrives through execute.after; shell.exited records a non-zero exit |
session.error |
session.execution.failed |
file.edited |
file.watcher.updated |
session.compacted |
session.compaction.started / .failed |
permission.updated |
permission.asked (payload is a PermissionRequest) |
todo.updated, session.diff |
no V2 equivalent observed |
permission.asked and permission.replied were not seen firing during
development. They are handled against the documented shape, reading both the
V2 field names and the V1 fallbacks, and are not covered by the observed
columns in the table below.
ctx.session.hook("context") fires on every model call, so recalled memory is
injected every time. The previous implementation injected once per session,
which meant only the first prompt of a session carried memory.
Because the hook also fires on each tool continuation, /context is requested
at most once per prompt and then reused for the rest of the turn: the recalled
set does not change while a prompt is being answered. A new prompt clears the
cache. The compaction hook reuses the same turn cache.
Compaction is also captured from the session.compaction.started / .failed
events, which are observed.
chat.params— V2'scontexthook exposesoptionswithmaxTokensonly; temperature andtopPare absent, andmodelcarries onlyid/providerID/variant. Recordedllm_paramswould be wrong rather than partial, so they are not recorded. Themodelon the hook is captured instep_startinstead, where it is real.
The V2 setup signature is async function v2Setup(ctx: any). That is
deliberate, and worth explaining because it looks like a shortcut.
Typing it properly would mean importing types from @opencode-ai/plugin. The
generated types shipped for that package are stale for V2: SDK 1.4.10
declares EventSessionCreated = { type, properties: { sessionID, info } } and
enumerates the V1 event names, none of which the v2.0.22 runtime emits. A
type-only import would therefore make the compiler reject correct code while
accepting the shape that caused the original bug. Silence was the safer of the
two failure modes, so any is used and the correctness burden moved to runtime
verification.
The known cost, stated plainly:
- The compiler will not catch V2 payload drift. A renamed field becomes a
undefinedat runtime instead of a type error. - Nothing checks that the event names in
handleEventare real. This branch shipped a handler for fifteen V1 event names, none of which fire. - There is no compile-time guarantee that
ctx.session.hook(...)takes a name that exists. The loader accepts any string, so a typo registers nothing and fails silently.
That is why the verification suite drives the plugin with payloads captured from a live server instead of hand-built objects, and why the Verification section distinguishes observed from inferred. If a future release ships correct V2 types, this signature should be tightened and these three risks retired with it.
-
test/opencode-plugin-v2.test.ts— 38 cases driving the V2 path with payloads captured from v2.0.22. It runs under the repo's existing vitest, so:npx vitest run test/opencode-plugin-v2.test.ts
It covers session lifecycle, the tool hooks, memory injection on every model call, compaction, the event switch, and the regression where an early
returnended the event subscription. Each case added with the compaction change was checked against the previous commit: the three compaction cases fail on it and pass here. -
In a live session the plugin produced real observations for
post_tool_use,config_loaded,step_start,step_finish,reasoning,assistant_messageandnotification, and requested/summarizewhen the run finished.session.compaction.startedandsession.compaction.failedwere confirmed by forcing a compaction in a running v2.0.22.
session.text.started, session.reasoning.started, session.inbox.delivered,
shell.created and session.usage.updated are read but not recorded. The
first two carry no text yet, the inbox event carries only an ID, shell.created
duplicates the command that execute.after already records, and usage
duplicates the token and cost figures in step_finish. A config snapshot equal
to the previous one is not recorded again.
Earlier in this branch the V2 path loaded cleanly and captured almost nothing, because it was verified against a hand-built context object that agreed with its own assumptions. Verifying that the plugin loads is not verifying that it works, and the suite exists so that the next person does not have to learn that twice.
| Event | Hook | agentmemory API |
|---|---|---|
| Session start | session.created |
POST /session/start |
| Idle → summarize | session.idle + session.status (idle) |
POST /summarize |
| Status transitions | session.status (idle/busy/retry) |
POST /observe |
| Compaction | session.compacted |
POST /summarize + POST /observe |
| Metadata updates | session.updated |
POST /observe |
| Code change tracking | session.diff |
POST /observe |
| Session delete | session.deleted |
POST /session/end |
| Session error | session.error |
POST /observe |
| Event | V1 hook | V2 hook | agentmemory API |
|---|---|---|---|
| User prompt (rich) | chat.message |
ctx.session.hook("prompt") |
POST /observe |
| User prompt metadata | message.updated (user) |
message.updated (user) |
POST /observe |
| Assistant response | message.updated (assistant) |
message.updated (assistant) |
POST /observe |
| Message removed (undo) | message.removed |
message.removed |
POST /observe |
| Event | Hook | agentmemory API |
|---|---|---|
| Subagent start | message.part.updated (subtask) |
POST /observe |
| Tool completed | message.part.updated (tool completed) |
POST /observe |
| Tool error | message.part.updated (tool error) |
POST /observe |
| Step finish (cost/tokens) | message.part.updated (step-finish) |
POST /observe |
| Reasoning trace | message.part.updated (reasoning) |
POST /observe |
| Patch applied | message.part.updated (patch) |
POST /observe |
| Auto/manual compaction | message.part.updated (compaction) |
POST /observe |
| Agent selection | message.part.updated (agent) |
POST /observe |
| API retry | message.part.updated (retry) |
POST /observe |
| Event | V1 hook | V2 hook | agentmemory API |
|---|---|---|---|
| File tool params | tool.execute.before → stash paths |
ctx.tool.hook("execute.before") |
- |
| File edited | file.edited → stash paths |
file.edited |
- |
| File part attached | message.part.updated (file) → stash paths |
message.part.updated (file) |
- |
| Enrichment inject | experimental.chat.system.transform |
ctx.session.hook("context") |
POST /enrich → system prompt |
| Memory context inject | experimental.chat.system.transform |
ctx.session.hook("context") |
POST /context → system prompt |
On V1 the two injects land in output.system[]. On V2 they are pushed as
SystemPart objects onto event.system.
| Event | V1 hook | V2 hook | agentmemory API |
|---|---|---|---|
| Permission prompt | permission.updated |
permission.asked |
POST /observe |
| Permission reply | permission.replied |
permission.replied |
POST /observe |
| Event | Hook | agentmemory API |
|---|---|---|
| Task tracking (w/ priority) | todo.updated |
POST /observe |
| Command executed | command.executed |
POST /observe |
| Event | V1 hook | V2 | agentmemory API |
|---|---|---|---|
| LLM parameters | chat.params |
not captured | POST /observe (V1 only) |
| Config loaded | config |
snapshot at setup | POST /observe |
| Compaction context | experimental.session.compacting |
ctx.session.hook("compaction"), unverified |
POST /context → event.system[] |
These three are the only differences between the V1 and V2 paths. Everything else in this document is captured identically on both. See What V2 cannot do.
experimental.chat.system.transform fires before every LLM call and injects two layers of context:
-
Memory context (once per session): calls
/agentmemory/contextand injects project profile, recent session summaries, and important past observations into the system prompt. This is the OpenCode equivalent of Claude's MEMORY.md bridge — instead of syncing to a markdown file, context is injected directly into the system prompt. -
File enrichment (every turn with stashed files): calls
/agentmemory/enrichwith files stashed bytool.execute.before,file.edited, andmessage.part.updated(file parts). File-specific context (past observations, related bugs, semantic search) is injected into the system prompt.
System prompt = [OpenCode instructions] + [memory context] + [file enrichment] + [user message]
^ ^
first turn only every file-touching turn
Differences from Claude's PreToolUse:
| Dimension | Claude (PreToolUse) | OpenCode (two-hop pipeline) |
|---|---|---|
| Injection mechanism | stdout → context window | output.system[] → system prompt |
| Timing | Same turn (parallel with tool) | Next turn (before next LLM call) |
| File set | Per-tool (immediate) | Batched (all files since last enrichment) |
| Coverage | Edit/Write/Read/Glob/Grep only | Edit/Write/Read/Glob/Grep only |
| What gets injected | <agentmemory-file-context> + bug memories |
Identical /enrich response |
Claude Code and OpenCode take fundamentally different approaches to injecting memory context into the agent's system prompt.
agentmemory ──write──▶ MEMORY.md ──read──▶ Claude system prompt
- The
claude-bridge/syncendpoint serializes agentmemory observations into aMEMORY.mdfile in the project root - Claude Code reads
MEMORY.mdon session start and prepends it to the system prompt - Sync is periodic — sessions only get fresh context when the bridge last ran (session end, pre-compact)
- Coupling: memory data lives in a git-trackable file, visible to CI, team members, and other tools
agentmemory ──push──▶ OpenCode system prompt
experimental.chat.system.transformcalls/contextat runtime and pushes the response directly intooutput.system[]- Always current — context is fetched at session start (once) and before file-touching turns (per-batch)
- No file intermediary — no stale copies, no merge conflicts, no disk I/O
AGENTS.mdis a static instruction file for project conventions, coding standards, and tool guidance — agentmemory does not read or write it
| Dimension | Claude (MEMORY.md bridge) | OpenCode (direct injection) |
|---|---|---|
| Freshness | Stale between syncs | Always current (fetched at call time) |
| Visibility | Human-readable file in repo | In-memory injection only |
| Simplicity | Two moving parts (bridge + file) | One step (API → system prompt) |
| Team sharing | File is git-trackable, CI-friendly | Memory shared via agentmemory server API |
| Integration | Any tool can read MEMORY.md | Requires OpenCode plugin SDK |
agentmemory already persists everything in SQLite (data/state_store.db). Adding an intermediate MEMORY.md file would duplicate data, introduce sync lag, and require the model to re-parse structured context from markdown. Direct injection delivers the same data with lower latency and zero staleness — the agent always sees what agentmemory knows right now.
/recall <query>— Search past observations and lessons/remember <text>— Save an insight to long-term memory
Agentmemory usage instructions are injected into the system prompt on the first turn of every session via experimental.chat.system.transform (alongside memory context from /context). This is functionally equivalent to Claude Code's skills mechanism — the agent learns which agentmemory_memory_* tools to use and when, without needing separate skill invocations.
| Claude feature | Reason |
|---|---|
| SubagentStop | OpenCode's SubtaskPart type has no completion/result fields; subtask lifecycle ends are not exposed as distinct events in the OpenCode SDK |
| TaskCompleted | No team/teammate concept in OpenCode; todo.updated captures task state changes as a partial equivalent |
| Stop | session.compacted event handler exists; experimental.session.compacting injection hook defined in SDK but Go binary (v1.14.41) doesn't wire it — will auto-activate when upstream implements it |
| Skills (remember/recall/forget/session-history) | Covered by injected system instructions via experimental.chat.system.transform — agent receives usage guidance on first turn |
| Consolidation pipeline (crystals/auto + consolidate-pipeline) | Now called on session.deleted — mirrors Claude's CONSOLIDATION_ENABLED=true behavior |
| Claude MEMORY.md bridge | OpenCode-specific; OpenCode uses its own AGENTS.md mechanism, not Claude's MEMORY.md |
All other Claude Code hooks have direct or pipeline equivalents in this plugin. 12 of 12 Claude hook types covered.