Skip to content

Latest commit

 

History

History
411 lines (342 loc) · 25 KB

File metadata and controls

411 lines (342 loc) · 25 KB

Architecture

opencode-codex-memory is a TypeScript port of codex's memory system, packaged as a standalone opencode plugin (no core changes, no MCP server, no separate process). This document explains how the system is shaped and why, and how to keep it aligned with upstream codex over time.

For installation, configuration, and daily use, start with the user documentation. OpenCode 1.x/2.x below names the host API generation; Memory V1/V2 names the learning implementation selected by the plugin's version option.

For the conceptual tour — learning, remembering, forgetting, and the trade-offs behind each — see How OpenCode Codex Memory works.


Staying aligned with codex

The port tracks codex's Rust memory implementation. Alignment is maintained with two artifacts, not with prose in this file (prose rots — codex already moved memories/read/ into ext/memories/ once):

  • codex-map.yaml — the provenance map. For each source file: which codex file it came from, plus the codex commit last audited (codex_ref) and the set of upstream paths to watch.
  • scripts/check-codex-drift.sh — run it against a local codex checkout to see (1) whether any mapped codex file was moved/renamed and (2) what changed in codex memory code since codex_ref.
CODEX_REPO=/path/to/codex ./scripts/check-codex-drift.sh
# exit 0 = aligned, 1 = drift (review + port + bump codex_ref), 2 = setup error

Maintenance loop when touching memory behavior:

  1. Read codex-map.yaml to find the upstream source for the file you're editing.
  2. Run the drift script. If upstream changed, read the diff and port intentionally, or record a deliberate divergence in the mapping note:.
  3. Bump codex_ref / codex_ref_date once re-audited.

Design invariant: memory is global. Project/cwd separation exists only as a soft routing hint inside consolidation.md (and the read-path prompt), mirroring codex exactly. Do not add schema-level, read-path, or job-level project partitioning unless codex does it first.


Architecture overview

opencode-codex-memory plugin

READ PATH
  experimental.chat.system.transform hook
    → reads memories/memory_summary.md, truncates to 2500 tokens (chars/4)
    → appends a byte-identical string to system[] every turn (cache-stable)
    → stats the file every turn; re-reads only when its mtime/size/inode changes
  tools: memory_read, memory_search, memory_list, memory_add_note
    (+ control tools: memory_reset [V1 only, user-approved], memory_inspect, memory_mode)
  experimental.text.complete hook: parse <memory-citation> at text end,
    BEFORE the part is persisted → record usage_count / last_usage → strip
    the block, so neither the UI nor stored history shows citation markup
    (matches codex; messages.transform + message.part.updated remain as
    fallbacks for older hosts and pre-existing history)

WRITE PATH
  Phase 1 — per-session extraction
    pumped at first chat.message of a session and on idle
    (session.status {type:"idle"} + deprecated session.idle, deduped)
    process-local 30s anti-stampede stamps only after a claim (empty passes free)
    load transcript via session.messages API → filter instructions → redact
    → memorize-extract subagent (json_schema output; only StructuredOutput tool, transcript inline)
    → store raw_memory + rollout_summary in memory.db
  Phase 2 — global consolidation (singleton, 6h DB cooldown, lease; no process timer)
    git baseline diff of memories/ → memorize subagent updates MEMORY.md,
    memory_summary.md, skills/ → reset baseline → invalidate read-path cache
    dispose() aborts the consolidator signal so reload cannot leave two writers

STORAGE
  <home>/memory.db                         V1 outputs/jobs + shared session metadata
  <home>/memory_v2.db                      V2 outputs/jobs + consolidation progress
  <home>/memories/                         MEMORY.md, memory_summary.md, raw_memories.md,
                                           rollout_summaries/, extensions/, skills/, .git/
  <home>/memories_v2/                      V2: memory_summary.md + recaps (no MEMORY.md)
  default <home>                           OpenCode data dir (follows XDG_DATA_HOME)
  pin                                      plugin option `home`, else OPENCODE_CODEX_MEMORY_HOME
                                           (does not follow OpenCode data dir / XDG)
  test only                                OPENCODE_CODEX_MEMORY_TEST_ROOT (wins over pins)
  opencode session data                    never read from disk — transcripts and
                                           discovery go through the plugin API (D4)

The hook names in this overview are OpenCode 1.x APIs; the OpenCode 2 adapter provides the corresponding behavior through its own host APIs.

Source layout: src/ holds the pipeline (source, citation, db, store, capture, phase1, phase2, workspace, git-baseline, redact, token, llm, rollout-input, reasoning-variant, ratelimit, paths, path-guard, host-client, lifecycle, options, diagnostics, agent-health) plus external-agent exchange (codex-interop, claude-import) and src/templates/; tools/ holds the model-facing tools (memory.ts, control.ts). The OpenCode 2 host adapter lives in src/v2/ (shim, plugin, agents, TUI) — not Codex-mapped. OpenCode 2 defaults plugin tools into Code Mode; memory tools set codemode: false so the names in the read-path prompt stay on the native tool list. User-facing panel controls are covered in the usage guide. Per-file upstream provenance lives in codex-map.yaml.

Versioned memory: version: "v2" selects memories_v2/ + memory_v2.db, summary-only extract/consolidate, and a recap-oriented read path. Default is V1. dual_write: true runs both writers through shared src/pipeline.ts, regardless of the read default. Node AsyncLocalStorage binds each asynchronous operation to its version; independent DB handles, phase-1 throttles, phase-2 guards and abort scopes prevent namespace mixing. Provider-capacity backoff remains shared when both writers use the same model.

memory_session_meta stays on memory.db (shared catalog). The same DB stores memory_session_versions: injection, tools, notes, and citations freeze their read version on first real use. Codex stores this in thread-extension state; the plugin persists it because plugin reloads do not end OpenCode conversations. Status reads peek without stamping. Reset preserves these routing stamps and session modes while clearing both workspaces, outputs, jobs, citation dedupe, and readiness progress; active DB handles remain open.

src/migration.ts reports the maximum distinct-session count from one successful V2 consolidation. Readiness also requires a currently valid V2 summary and defaults to 20 sessions. Inspect and the OpenCode-2 status RPC report it; version switching is explicit and affects new sessions. The V2 prompt remains one cache-stable string (D1); Codex's 8.9k fragment split is skipped.


Design decisions & workarounds

These explain why the code diverges from a naive port. They are the load-bearing constraints — read before changing the corresponding subsystem.

D1 — Prompt cache stability (src/source.ts)

opencode's V1 experimental.chat.system.transform has no epoch-aware injection; the system prompt is rebuilt each turn, and codex's V2 SystemContext.Source is not exposed to plugins. Workaround: append the same byte-identical string every turn.

opencode pre-joins everything of its own (agent/provider prompt, environment, AGENTS.md, MCP instructions, skills, user system) into a single system[0] before calling the hook, then keeps at most two entries: appending one string leaves [base, memory] untouched, and two or more are collapsed into [base, rest.join("\n")] (session/llm/request.ts). Its provider transform puts cache breakpoints on the first two system messages (provider/transform.ts, .slice(0, 2)). The stable memory block therefore gets its own cache segment: changing memory invalidates that segment without invalidating opencode's base prompt. The plugin caches the summary in process memory keyed by summary path, stats it each turn, and re-reads only after an external edit changes its mtime, size, or inode, or Phase 2 explicitly invalidates the cache. Sessionless invocations of the same hook (used by opencode while generating agent definitions) are ignored, as is a symlinked memory root or summary file. OpenCode's hidden title agent reuses the real conversation sessionID and the hook does not receive agent, so title-generation is skipped by sniffing the title prompt already present in system[].

Limitations, both accepted:

  • If opencode's own prompt prefix shifts (date, skills, MCP tool set), the prefix cache misses — same as any plugin hook.
  • On OpenAI-OAuth providers opencode sends no system messages at all; the whole array is joined into the request's instructions field (request.ts), so no system cache breakpoints exist and D1 buys nothing there. Injection itself is unaffected — only the caching benefit is.
  • Another plugin appending a volatile string triggers the collapse and shares one cache segment with ours, invalidating it whenever that plugin's text changes. Plugin order is deterministic, so a stable co-tenant is harmless.
  • Title-generation skip sniffs OpenCode's default title agent prompt. A rewritten title prompt (user override or upstream edit) would receive memory again until the marker is updated. The hook still has no agent field.

D2 — Consolidation subagent sandboxing (opencode.json)

codex uses Seatbelt to block network access; opencode has no process sandbox. Workaround: every shipped memory subagent starts with "*": "deny" and allowlists only the built-in opencode file tools it needs. The consolidator (memorize) gets read/edit/write/glob/grep; the extractor (memorize-extract) gets only StructuredOutput — opencode delivers json_schema output through a forced call to that synthetic capture tool (toolChoice: required), and it has no filesystem/shell/network capability, so the extractor stays effectively tool-less: the transcript arrives inline and a poisoned transcript still cannot induce file reads or any side effect. This also blocks IDE and MCP tools that could otherwise bypass a narrower bash deny. Tool-permission-level, not process-level — accepted trade-off.

Helper-session directory (resolveSubSessionDirectory in src/llm.ts) differs by host:

  • V1: the memory workspace, not the user's project. OpenCode treats that path as the session project boundary (containsPath / external_directory): in-bounds file tools freely touch the memory root only. Paths under the user's real project are outside that boundary and hit external_directory, which the wildcard deny blocks. Per-helper session rules restrict external access to that job's single memory root, overriding the agent's grants for both roots when dual-write is configured.
  • OpenCode2: V2 agents are location-scoped, so helpers spawn in the active plugin location (setSubSessionDirectory). Session boundary is the project; path permissions scope read/edit + external_directory to the memory workspace. glob/grep permissions match search patterns, so V2 wraps their built-in executors to validate paths against the helper's one session-granted memory root, defaulting relative searches to that root. Helper creation supplies session-scoped deny-first rules that narrow access to that job's one root before any agentic prompt.

Either way the consolidator is memory-root-scoped without Seatbelt — residual is still tool-permission-level (not process-level), not "can edit the originating repo."

injectAgentDefinitions still appends external_directory: { "<memory root>/*": "allow" } as a belt-and-suspenders grant (homedir/env-dependent path; covers edge cases if a tool path is classified external). The extractor gets no grant — inline transcript only.

D3 — LLM calls for extraction/consolidation (src/llm.ts)

The plugin SDK exposes no "make a model call" API and no provider credentials. Workaround: both phases spawn sub-agent sessions via opencode's HTTP API (session.create + session.prompt), reusing opencode's auth/provider/usage stack with zero credentials in the plugin. This is close to codex's model — codex also spawns a configured model client for extraction. Reasoning pins stay Codex low/medium when the model lists them; otherwise src/reasoning-variant.ts picks the nearest OpenCode effort (host-only — Codex does not need this).

OpenCode 2 extraction uses the tool-less generate.text API instead of a helper session. That API accepts one prompt, without a separate system role or structured-output schema. The V2 shim prepends extraction instructions, then appends a task reminder and the requested memory-version schema after the historical transcript. Shared extraction parsing and validation check the JSON reply. Consolidation still uses a sandboxed helper session.

Background model selection

Precedence is plugin option (extract_model / consolidation_model) → available host config (small_model / model) → a model on the memorize-extract / memorize helper agent → host/provider default. The first two are passed explicitly and win over an agent-level override.

OpenCode 1.x's automatic small-model pick is internal to the host; the plugin can only use an explicitly configured small_model. OpenCode 2 has no general-purpose small-model field: legacy small_model config maps to the title agent. Its extraction therefore falls back to the helper session model unless extract_model is set; consolidation uses the configured model when available.

Reasoning effort selects low for extraction and medium for consolidation when listed. Otherwise the nearest effort on none < minimal < low < medium < high < xhigh < max is used, with ties going higher. No listed effort variants means no pin.

D4 — Retroactive transcript & session access (src/capture.ts)

Phase 1 needs past transcripts and a cross-project session listing; the live message hook only sees current messages. Approach: official API only — opencode.db is never read. Everything goes through the plugin's authenticated client (input.client), which shares auth with the host:

  • Transcripts: session.messages — the same surface opencode's own UI renders history from; the session-scoped route resolves the right instance even for sessions from other projects. Errors propagate so the job fails and retries. A first-time empty transcript finalizes as no-output; an empty transcript for a session with an existing extraction retries instead of deleting that row.
  • Discovery: GET /experimental/session?roots=true (Session.listGlobal) — one call across all projects. The V1 plugin client has no experimental namespace, so the call goes through the host client's hey-api transport (client._client.get) which already carries baseUrl + auth. The SDK also auto-injects directory on GETs (project scope); we pass directory="" so the handler runs listGlobal without a directory filter — required because memory is global. Fail-safe: any error skips the pass and never finalizes a job. The pass is also rate-limited (30s min interval). Helper-session cleanup after reload uses a title-filtered, cursor-paginated host-wide list so memory-root extract/consolidate sessions are visible even beyond the first page (project-scoped session.list cannot see them).

Trade-off accepted: discovery rides an experimental route (stable since 1.17.x) rather than reading opencode.db. The only SQLite the plugin touches is its own memory.db (D5) — plugin-owned state with no API equivalent.

OpenCode 2 discovery and citation handling

The OpenCode 2 adapter lists sessions through a registered local service. Readiness uses /api/info, falling back to legacy /api/status and /api/health only when a route returns 404. A separate IDE serve --port 0 process can use a healthy loopback service even when its PID differs from the service registration. A non-loopback PID mismatch is refused. Without a registered service, extraction is limited to conversations observed by this process; existing summary injection still works.

OpenCode 2 can retain citation markup in persisted replies for native UI rendering. The adapter strips it before subsequent model calls, rather than relying on OpenCode 1.x's pre-persistence text-completion hook. Status reports service unavailability instead of presenting a stale idle state; successful consolidation timestamps are kept separate from failed attempts.

D5 — Separate plugin DB (src/db.ts)

The plugin owns memory.db (its own schema + migrations); opencode's own database is never opened — not even read-only (see D4). No migration conflicts, no risk to opencode's data. Same isolation codex uses with its dedicated memories SQLite.

D6 — External-agent import via extensions

Default-off exchange with foreign agent memory stores, built on the generic extensions contract instead of a second memory root. Both this plugin and Codex already render extension prompt blocks into their consolidation prompt whenever extensions/ exists and instruct the consolidator to read every extension's instructions.md — so sharing is pure content, no read-path or schema change.

Codex CLI (src/codex-interop.ts)

Two-way exchange of consolidated global memory:

The handbook exchange applies only to the Memory V1 writer. With dual-write enabled it runs through that writer even if new sessions read Memory V2; with only the Memory V2 writer enabled it is disabled. Claude import below works with either memory writer.

  • Import (codex_interop.import): inside each claimed phase-2 job — after the baseline, before the diff capture — Codex's consolidated MEMORY.md/memory_summary.md are byte-compared and copied into extensions/codex_import/resources/codex/, so imported changes are consolidated in the same run (codex memory_import.rs orders prepare-workspace-then-copy the same way). Source-gone deletes the copies so the workspace diff carries the forgetting signal.
  • Export (codex_interop.export): after a successful phase 2, our validated artifacts are copied into $CODEX_HOME/memories/extensions/opencode_import/resources/opencode/ with an instructions.md written for Codex's consolidator. Strictly additive: never bootstraps Codex's workspace, never touches Codex's state DB — Codex discovers the files through its own workspace diff.
  • Echo guard: both instructions files require a provenance tag ([from codex] / [from opencode]) and forbid re-importing content carrying the other side's tag; foreign metadata (thread UUIDs vs ses_* ids, citation formats) must never be reinterpreted. This is deliberately instruction-level, like every content rule in both memory systems. A code-level line filter at the sync boundary was tried and rejected: line granularity strips the very line that carries the provenance marker while leaving the rest of a multi-line entry behind as unattributable fragments — worst for entries whose origin memory has since been deleted, which become uninterpretable remnants. Only the consolidator can delimit semantic units, so it must see the tags, not their absence. Untagged leakage degrades to duplication, never to unsafe behavior (the consolidator's tool allowlist is the safety boundary, D2).
  • Overlapping Codex/plugin memory roots fail closed.

Claude Code (src/claude-import.ts)

One-way port of codex's Claude memory importer (external-agent-migration memory.rs + memory_import.rs), continuous on phase 2 instead of a migration UI:

  • Reads ~/.claude/projects/<key>/memory/**/*.md (optional claude_home / projects allowlist).
  • Resolves cwd from newest project *.jsonl with an absolute existing cwd (codex project_cwd_from_sessions); no-cwd projects are skipped.
  • Whole-project replace into extensions/external_agent_import/resources/<key>/ plus scope.json ({ "cwd": "..." }) and codex-aligned instructions.md.
  • Source gone / dropped from allowlist → remove resources (forgetting signal).
  • Unreachable Claude home is a no-op, never a mass-delete.
  • Same extension name and layout as Codex, so a consolidator trained on either system can merge the files. No write-back to Claude; no second memory root.

Resource files for both importers are nested and untimestamped, so extension-resource pruning never touches them.

The alternative — mounting a foreign workspace as a second, read-only memory root — was rejected: it would need source-aware tools, a split summary budget, and read-path changes, and it would strain the "memory is global, one root" invariant. The extension approach is pure content.


Known gaps vs codex (accepted trade-offs)

Gap Codex This plugin Mitigation
Network sandbox Seatbelt Tool-permission deny on subagents memorize* deny bash/webfetch/websearch/task
Consolidator FS scope WorkspaceWrite limited to memory root Sub-session directory = memory root → project-bound tools only see that tree; user projects need external_directory (denied) No shell/network; tool-permission sandbox, not process Seatbelt
Token counting tiktoken chars/4 estimate Sufficient for the 2500-token cap
Cache-stable injection V2 SystemContext.Source V1 hook + byte-identical append Content-addressed provider caches (D1)
LLM call API Internal model client HTTP API → subagent sessions Reuses opencode auth/usage (D3)
Transcript access Direct rollout files (own format) session.messages + experimental.session.list; opencode.db never read Official surfaces only (D4)
Git baseline gix / libgit2 isomorphic-git (pure JS) No external binary; git bundled
Hook stability N/A (core code) experimental.* V1 hooks may deprecate Migrate to V2 SDK if/when it exposes the seam
Rate-limit awareness Provider rate-limit info (min_rate_limit_remaining_percent), fail open Phase-1 30s anti-stampede plus an observed-quota circuit breaker (1h, scoped by configured model or phase default); quota failures do not burn stage-1 retries See src/ratelimit.ts; wire live provider quota when opencode exposes it
Plugin dispose / reload N/A (in-process core) dispose aborts pluginShutdownSignal (extract) + phase-2 scope (consolidator), releases jobs without 1h backoff; best-effort session.abort on sub-sessions; startup reseeds helpers via paginated host-wide experimental.session Signal-driven cancel unblocks both phases; host session.abort still best-effort for server-side cleanup. Cross-process lease may still run until expiry
Per-instance state Single process per home Module-global options/client/caches; when one opencode process hosts several instances (directories), the last-booted instance's plugin options and client win. OpenCode2 ref-counts live location instances: panel toggles survive later setups, a disposed location re-points the shim to a survivor, and only the last dispose shuts the pipeline down Memory itself is global, so shared state is mostly correct; revisit if per-project plugin options ever matter
Memory version lifetime Thread-extension runtime state Persistent per-session read-version stamp New sessions follow config; resumed sessions retain their namespace
External-context pollution via codemode fetch() Every external-context channel marks the thread (tool output flag, MCP calls, MCP hooks) OpenCode2 codemode fetch() is a sandbox extension, not a tool, so tool.execute.before never fires and disable_on_external_context cannot see it. Inner codemode tool calls (websearch, webfetch, MCP) are marked normally Accepted gap: no code-text heuristics (fetch( is too common a name). Revisit when OpenCode reports codemode web requests to plugins
Extraction model config (OpenCode2) Model client of the running thread Extraction prefers the registered service's generate.text, which resolves models against the server's global config (no location parameter); a provider defined only in a project's config is unavailable there Deliberate: memory is global, and the service call is cancellable. The plugin-location ctx.generate.text fallback applies only when no registered service offers generate.text

Codex stability assessment

Codex's memory system is young and still refactoring structurally (as of the pinned codex_ref, memories/read/ and ext/memories/ coexist mid-migration). The architecture (two-phase pipeline, citation loop, git baseline, on-disk artifacts) is stable; storage layout and module boundaries still move. The port copies the architecture, not codex's storage schema, so codex schema changes don't touch memory.db. Prompt/extraction improvements land in template files that can be updated independently — which is exactly what the drift script surfaces.