examples/codex-memory-plugin/README.md
Long-term semantic memory for Codex, powered by OpenViking.
This is the Codex counterpart to claude-code-memory-plugin. It hooks Codex's lifecycle to:
UserPromptSubmit and inject them via hookSpecificOutput.additionalContextStop (turn end): append the new user/assistant turns to a deterministic OpenViking session id cx-<codex_session_id>. When pending_tokens reaches OPENVIKING_COMMIT_TOKEN_THRESHOLD, commit while keeping a recent live tail.PreCompact: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it.SessionStart (source=startup|clear): active-window heuristic — if exactly one other state file was touched within the last 2 min, commit it (the just-ended session). On ≥2, defer to idle-TTL sweep at the tail. source=resume never commits or sweeps; if the live OV session was already committed, it may inject the latest archive summary for continuity. See DESIGN.md for the full decision tree.It also starts a local stdio MCP proxy that forwards to OpenViking's native /mcp endpoint with credentials resolved from env / ovcli.conf, so the model has direct access to the search, store, read, list, grep, glob, forget, add_resource, and health tools.
There are two install paths. Pick one — don't mix them (both surface the same openviking-memory plugin; enabling it from both would run the hooks twice). The one-line installer (A) is the recommended path for most users; the marketplace install (B) is useful when you already manage ~/.openviking/ovcli.conf yourself.
curl | bash (recommended)bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/memory-plugin-shared/install.sh) --harness codex
Claude Code and Codex share this installer (drop --harness codex to pick interactively). It asks for your language (English/中文), the download source (GitHub, or a TOS mirror for GitHub-blocked regions — pass --dist tos; Codex on TOS installs from a TOS-hosted git repo and keeps remote updates), and your OpenViking credentials. It:
codex and Node.js 18+ (the plugin itself wants Codex's bundled Node 22+ at runtime)~/.openviking/ovcli.conf interactivelyopenviking marketplace — remote git by default (codex plugin marketplace add https://github.com/volcengine/OpenViking.git), or this checkout / a TOS archive in dev/archive mode — and enables openviking-memory@openviking with features.plugin_hooks = true.mcp.json intact; servers/mcp-proxy.mjs reads your active ovcli.conf at runtimeopenviking-plugins-local marketplace when foundAfter install:
codex # first run: review /hooks once
This path uses the same checked-in stdio MCP proxy as the installer path. Authenticated and remote/cloud servers work when ~/.openviking/ovcli.conf or the relevant OPENVIKING_* env vars are present in Codex's environment.
The repo ships a Codex marketplace catalog at .agents/plugins/marketplace.json, so you can install with Codex's native commands:
# 1. add the OpenViking marketplace (use volcengine/OpenViking once merged
# upstream, or <your-fork>/OpenViking while testing a fork)
codex plugin marketplace add volcengine/OpenViking
# 2. install the plugin from that marketplace
# (older Codex builds spell this `codex plugin install`)
codex plugin add openviking-memory@openviking
Then enable plugin hooks (if your Codex build doesn't already) by adding to ~/.codex/config.toml:
[features]
plugin_hooks = true
Finally start Codex and trust the plugin hooks once:
codex # then run /hooks inside Codex to review & approve the hooks
Requirements & notes
- Codex version: this path relies on Codex injecting and inline-substituting
${PLUGIN_ROOT}in plugin hook commands (current Codex does both). On an older Codex that doesn't substitute${PLUGIN_ROOT}, the hook script paths won't resolve — use path A.- Catalog source: the catalog entry (
.agents/plugins/marketplace.json) uses a relative source (./examples/codex-memory-plugin).codex plugin addtherefore installs the plugin from the same marketplace snapshot/ref that you added. This keeps fork, branch, tag, and upstream-main installs reproducible and testable without rewriting the catalog.
This path works out of the box against an unauthenticated local OpenViking at http://127.0.0.1:1933. For remote/cloud servers, create ~/.openviking/ovcli.conf with url, api_key, and optional account / user; the proxy reads it when Codex starts.
If you don't want the installer touching your rc, do these things yourself:
Write ovcli.conf once so hooks and MCP share the same connection:
{
"url": "https://your-openviking-server.example.com",
"api_key": "<your-api-key>",
"account": "my-team",
"user": "alice"
}
Or run the bundled interactive wizard: node scripts/setup.mjs (from the plugin directory).
Add the plugin via the remote marketplace (path B above), or via a local directory marketplace: codex plugin marketplace add <checkout>/examples reads examples/.agents/plugins/marketplace.json and yields the same openviking-memory@openviking id. hooks/hooks.json needs no rendering on modern Codex: it uses the native ${PLUGIN_ROOT} token, which Codex injects into the hook env and substitutes inline.
Connection / identity source (applies to hooks, MCP, and ov commands run inside Codex):
ovcli.conf wins when present: OPENVIKING_CLI_CONFIG_FILE or ~/.openviking/ovcli.conf. Use ov config switch <name> to change the active credentials for the CLI, hooks, MCP, and child ov commands together.OPENVIKING_CREDENTIAL_SOURCE=env to force OPENVIKING_URL / OPENVIKING_BASE_URL, OPENVIKING_API_KEY / OPENVIKING_BEARER_TOKEN, OPENVIKING_ACCOUNT, OPENVIKING_USER, and OPENVIKING_PEER_ID.ov.conf (server.url / server.root_api_key plus legacy codex.* tuning); then http://127.0.0.1:1933 unauthenticated.Hooks and the MCP proxy call the same resolver directly, so the model tools and lifecycle hooks follow the same target.
Auth is sent as Authorization: Bearer <api_key> to both the REST API (used by hooks) and the /mcp endpoint (used by the model).
Set actor_peer_id in ovcli.conf (or OPENVIKING_PEER_ID with OPENVIKING_CREDENTIAL_SOURCE=env) when multiple Codex peers share the same OpenViking user and should keep separate peer memory. Hooks pass it as peer_id for captured session messages and as X-OpenViking-Actor-Peer for retrieval/filesystem calls; MCP gets the same header mapping. The legacy codex.peerId / codex.peer_id fields in ov.conf still resolve as a fallback.
The checked-in .mcp.json contains only a stdio command. It never stores server URLs, bearer-token env mappings, or identity headers, so switching ovcli.conf changes the MCP target on the next Codex launch without cache rendering.
All plugin behavior is controlled by OPENVIKING_* environment variables. Connection and identity should normally live in ovcli.conf; tuning vars can be exported in your shell rc when you want every Codex launch to pick them up.
# ~/.zshrc — examples
export OPENVIKING_RECALL_LIMIT=6
export OPENVIKING_RECALL_COMPRESS=1
export OPENVIKING_RECALL_COMPRESS_MODEL=gpt-5.3-codex-spark
export OPENVIKING_RECALL_COMPRESS_THINKING=default
export OPENVIKING_RECALL_TIMEOUT_MS=120000
export OPENVIKING_CAPTURE_ASSISTANT_TURNS=1
export OPENVIKING_AUTO_COMMIT_ON_COMPACT=1
export OPENVIKING_DEBUG=1
Full list: see the Misc env vars block in scripts/config.mjs. Tuning fields have OPENVIKING_* counterparts and env vars win for those tuning fields.
codex block in ov.confEarlier plugin versions configured tuning fields under a codex block in ~/.openviking/ov.conf. That still works for backward compat — every env var above has a camelCase counterpart (OPENVIKING_RECALL_LIMIT → codex.recallLimit, etc.) — but new deployments should prefer env vars: this is the codex CLI's per-machine plugin tuning, and the server-side ov.conf is the wrong place for it. (It's read from ov.conf, not ovcli.conf, by historical accident in scripts/config.mjs.)
┌──────────────────────────────────────────────────────────────┐
│ Codex │
└──┬─────────────────┬────────────────┬───────────────────┬────┘
│ │ │ │
SessionStart UserPromptSubmit Stop PreCompact
(startup|clear|resume) │ (per turn) │
│ │ │ │
┌────▼──────────┐ ┌────▼──────┐ ┌──────▼──────┐ ┌──────────▼──────┐
│ session-start │ │ auto- │ │ auto- │ │ pre-compact- │
│ -commit.mjs │ │ recall.mjs│ │ capture.mjs │ │ capture.mjs │
│ (active-win │ │ (search + │ │ (append + │ │ (commit + reset │
│ heuristic + │ │ compress) │ │ threshold │ │ ovSessionId) │
│ idle TTL + │ │ │ │ │ │ │
│ resume inject)│ │ │ │ │ │ │
└────┬──────────┘ └────┬──────┘ └──────┬──────┘ └──────────┬──────┘
│ │ │ │
│ ┌───▼────────────────▼───────────────────▼──┐
└────────────►│ OpenViking REST API │
│ /api/v1/search/search │
│ /api/v1/sessions [+/{id}/{messages,commit}]│
│ /api/v1/content/read │
└─────────────────┬─────────────────────────┘
│
Codex ◄── stdio MCP proxy ──► /mcp (search, store, read, list,
(env/ovcli.conf) grep, glob, forget,
add_resource, health)
The checked-in .mcp.json starts servers/mcp-proxy.mjs with node. The proxy keeps stdout protocol-clean, reads the same credential sources as the hooks, sends auth and identity headers to /mcp, caches the server mcp-session-id, and transparently reinitializes once if the server restarts.
For details on OpenViking's MCP endpoint, tools, and protocol, see the MCP Integration Guide. The tools list and per-tool semantics are documented there once, not duplicated here.
See
DESIGN.mdfor the commit decision tree — it's the source of truth for which OpenViking session is sealed by which hook event.
Codex fires SessionStart with one of three source values: startup (fresh process / /new / zouk daemon spawn-without-sessionId), resume (/resume or short reconnect), and clear (/clear — the previous transcript is orphaned and a new session_id is created). resume never commits or sweeps; on startup and clear we run the same active-window heuristic.
hooks.json registers SessionStart with matcher: "clear|startup|resume" so codex's dispatcher invokes the script on all three relevant sources. session-start-commit.mjs gates internally so only startup and clear commit/sweep.
On startup or clear, the script:
lastUpdatedAt is within OPENVIKING_CODEX_ACTIVE_WINDOW_MS (default 2 min) of "now":
OPENVIKING_CODEX_IDLE_TTL_MS (default 30 min) gets committed and cleared.On any /commit failure (OV unreachable, non-2xx, timeout) we preserve state (don't clearState) so the next sweep can retry.
On resume, the script skips commit/sweep. If local state has no live ovSessionId, it reads /api/v1/sessions/{cx-session-id}/context and injects the latest committed archive overview. The injected block includes a viking://user/sessions/{cx-session-id}/history/ URI and tells the model to use the OpenViking MCP read/search tools for exact prior commands, file paths, tool outputs, or messages. Set OPENVIKING_RESUME_ARCHIVE_INJECT=0 to disable this.
auto-recall.mjs reads prompt and session_id from stdin, derives the long-lived OpenViking session id (cx-<safe-session-id>) directly from the Codex session id (no plugin state read, so a corrupt state file can't crash recall), calls /api/v1/search/search with that session_id, ranks results, reads full content for top-ranked leaves, and emits:
{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "<openviking-context source=\"auto-recall\" format=\"digest\">\nOpenViking memory digest:\n- ...\n</openviking-context>" } }
Codex injects additionalContext into the model turn, so memories arrive without an extra tool call. By default the hook runs a Codex compression pass over recalled candidates before injection, dropping weakly-related memories and preserving only a short digest. If the compressor returns NO_RELEVANT_MEMORY, empty text, or non-digest chatter, the hook emits {} and injects nothing. The whole hook has its own OPENVIKING_RECALL_TIMEOUT_MS deadline (default 120s); the bundled hooks.json gives Codex 130s so the script can return {} before Codex kills it. Digests may keep viking:// source URIs and point the model at the OpenViking MCP read/search tools for details when the inline bullet is intentionally short. The outer <openviking-context ...> wrapper is deterministic, not compressor-generated; capture strips it to distinguish recalled context from the user's prompt. Set OPENVIKING_RECALL_COMPRESS=0 to fall back to deterministic short formatting.
The compressor profile is recreated on every SessionStart and cached under OPENVIKING_CODEX_STATE_DIR so cross-session config changes are picked up but each UserPromptSubmit does not probe models. Default fallback order:
OPENVIKING_RECALL_COMPRESS_MODEL + OPENVIKING_RECALL_COMPRESS_THINKINGgpt-5.3-codex-spark with thinking defaultgpt-5.5 with thinking lowcodex exec compression)Config knobs:
| Env var | Default | Meaning |
|---|---|---|
OPENVIKING_RECALL_COMPRESS | 1 | Set 0 / off to disable codex exec compression. |
OPENVIKING_RECALL_COMPRESS_MODEL | unset | Custom first-choice compressor model. Set off to disable compression. |
OPENVIKING_RECALL_COMPRESS_THINKING | unset | Custom model_reasoning_effort; default omits the Codex config override. Alias: OPENVIKING_RECALL_COMPRESS_REASONING_EFFORT. |
OPENVIKING_RECALL_COMPRESS_DETECT_ON_STARTUP | 1 | Recreate/cache compressor profile in SessionStart. |
OPENVIKING_RECALL_COMPRESS_DETECT_TIMEOUT_MS | 15000 | Per-candidate startup probe timeout. |
OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS | 604800000 | Cache TTL used by UserPromptSubmit when reading the latest profile. |
add_message, threshold commit)auto-capture.mjs derives one long-lived OpenViking session id per Codex session_id as cx-<safe-session-id> and incrementally appends every new user/assistant turn via /api/v1/sessions/{id}/messages. The /messages endpoint auto-creates the session on first append. Per-codex-session state lives at ~/.openviking/codex-plugin-state/<safe-session-id>.json. Capture sanitizes obvious hook noise, metadata wrappers, and plugin-injected <openviking-context ...> blocks before append; tool calls/results are retained as compact [tool-call ...] / [tool-result ...] lines capped by OPENVIKING_CAPTURE_TOOL_MAX_CHARS (default 2000).
After a successful append, Stop reads the session meta and commits when pending_tokens >= OPENVIKING_COMMIT_TOKEN_THRESHOLD (default 20000). Threshold commits pass keep_recent_count=OPENVIKING_COMMIT_KEEP_RECENT_COUNT (default 10) so the newest turns remain live for continuity while older context is archived and extracted. PreCompact still commits everything before compaction.
pre-compact-capture.mjs:
capturedTurnCount)ovSessionId to null so the next Stop re-derives the same cx-<safe-session-id> and appends the post-compact half under that deterministic OV session id/exit are silentCodex fires no hook on process exit. /compact is the only fully-deterministic "context disappearing" signal. If you /exit without /compact, the OV session for that codex session_id stays open. Two fallbacks recover the orphan:
SessionStart commits any state file older than 30 min/new or /clear shortly afterCodex's hook output schema differs from Claude Code's. Notably:
| Hook | Input field of interest | Output channel for context injection |
|---|---|---|
SessionStart | source (startup/resume/clear), session_id | hookSpecificOutput.additionalContext |
UserPromptSubmit | prompt, session_id | hookSpecificOutput.additionalContext |
Stop | last_assistant_message, transcript_path, session_id | systemMessage (only) |
PreCompact | trigger (manual/auto), transcript_path, session_id | systemMessage (only) |
Unlike Claude Code, Codex does not support decision: "approve"; only decision: "block". A no-op is {} (which is what these scripts emit when there's nothing to add).
codex-memory-plugin/
├── .codex-plugin/
│ └── plugin.json # Plugin manifest (hooks + mcp wiring)
├── hooks/
│ └── hooks.json # SessionStart + UserPromptSubmit + Stop + PreCompact
│ (uses Codex's native ${PLUGIN_ROOT} token; no
│ rendering needed on modern Codex)
├── scripts/
│ ├── config.mjs # Shared config loader (ovcli.conf + env)
│ ├── capture-utils.mjs # Transcript text extraction, filtering, tool compression
│ ├── debug-log.mjs # Structured JSONL logger
│ ├── recall-compressor-profile.mjs # Compressor profile detection/cache
│ ├── session-state.mjs # Per-codex-session OV session state
│ ├── auto-recall.mjs # UserPromptSubmit hook (REST /search/search)
│ ├── auto-capture.mjs # Stop hook (append + threshold commit)
│ ├── session-start-commit.mjs # SessionStart hook (active-window + idle TTL)
│ └── pre-compact-capture.mjs # PreCompact hook
├── servers/
│ ├── mcp-proxy.mjs # stdio -> OpenViking /mcp bridge
│ └── mcp-proxy.test.mjs # proxy contract tests
├── setup-helper/
│ └── install.sh # One-line installer
├── .mcp.json # stdio MCP wiring
├── DESIGN.md
├── VERIFICATION.md
└── README.md
No src/ or package.json: there is no build step. Hook scripts and the MCP proxy are zero-dep .mjs files running on Codex's bundled Node 22 or a compatible system Node.
The Codex marketplace catalog that exposes this plugin for codex plugin marketplace add lives at the repo root in .agents/plugins/marketplace.json (Codex resolves a marketplace manifest from the source root, not from this subdirectory). The catalog points at ./examples/codex-memory-plugin using a relative source, so the installed plugin follows the same marketplace snapshot/ref that the user added.
| Aspect | Claude Code Plugin | Codex Plugin |
|---|---|---|
| Plugin root env var | CLAUDE_PLUGIN_ROOT (expanded by CC) | ${PLUGIN_ROOT} (injected into hook env + substituted inline by modern Codex; installer also renders it to absolute paths for older Codex) |
UserPromptSubmit injection | decision: "approve" + hookSpecificOutput.additionalContext | hookSpecificOutput.additionalContext only — approve is not a Codex output |
Stop decision | decision: "approve" no-op | {} no-op — only block is a valid Codex decision |
| Compaction hook | n/a (Claude Code does not expose one) | PreCompact — full-transcript commit before context loss |
| Config section | claude_code | codex |
| Default config file | ~/.openviking/ov.conf | ~/.openviking/ovcli.conf, falls back to ov.conf |
| MCP server | Local stdio proxy to OpenViking /mcp | Local stdio proxy to OpenViking /mcp |
Apache-2.0 — same as OpenViking.