* refactor(skills): install skills through one shared helper POST /api/v1/skills kept its whole install loop (source resolution, per-skill install, source metadata, list_only) inline in the route. Move it into openviking/server/skill_ingest.py:install_skills so the MCP add_skill tool and signed skill uploads can reuse the exact same code path. The REST route's behavior is unchanged. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(mcp): add an add_skill tool MCP clients had no way to create a skill: write refuses the skills/ subtree (_USER_MANAGED_SUBTREES) and add_resource validates its target as a resource. Agents that should keep skills in OpenViking could read them but never add one. add_skill takes either the full SKILL.md text (data) or a path. A Git or GitHub tree URL installs through the same source resolution as REST, with skills=[...] to pick from a multi-skill repository and list_only to preview it. A local SKILL.md, directory, or zip gets the add_resource treatment: the tool mints a one-time upload token, now tagged kind="skill" with the target root, selection and list_only, and the signed temp_upload installs the file as skills instead of ingesting it as a resource. target_uri="viking://agent/skills" shares the skill with the account. All three paths (REST, MCP inline/Git, signed upload) go through skill_ingest.install_skills. The tool count in the server log, app comment, docs, and the Codex plugin's REAL_MCP_TOOLS moves to 16. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): search shared skills in find(context_type="skill") Without a target_uri, find resolved the generic default targets, which stop at the caller's user root, so a skill search never reached the account-shared viking://agent/skills. REST /skills/find and the context search already cover both roots. When context_type resolves to skill only and no target_uri is given, the MCP tool now targets default_target_directories(ctx, context_type=SKILL): the user's own skills plus viking://agent/skills. REST find semantics are unchanged. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): print directory abstracts in tree(include_abstract=true) The tree tool skipped to the next entry right after printing a directory, and only printed abstracts for files, but the storage layer only fills abstracts for directories (files always come back empty). The flag therefore never printed anything. Print the abstract after either kind of entry and ask for up to 1024 characters, enough for a full skill description, so tree(uri="viking://~/skills", level_limit=1, include_abstract=true) lists every skill with its description. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(skills): honor node_limit in GET /api/v1/skills list_skills declared node_limit but always listed each skill root with a hardcoded 1000. Pass it through per root; 0 keeps the default so the CLI's accepted range (-n 0) still lists everything. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): point skill hits in find/search at their SKILL.md A skill is indexed through its directory's .abstract.md, so find and list-mode search printed hits like viking://agent/skills/x/.abstract.md. Following the "use the read tool to expand a URI" advice returned only the frontmatter, and read_content inlined the same stub. Skill hits now show <dir>/SKILL.md, and read_content reads that file. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): validate add_skill targets and sources before minting an upload Review findings on the add_skill tool: - target_uri passed the content-kind check for any path under a skills root (viking://~/skills/pdf) and, for ROOT, for another user's root, but the installer only accepts the caller's own skills root or viking://agent/skills. On the local-path branch the tool minted a one-time upload token anyway, and the upload failed with 400 after the token was spent. The target is now resolved with the installer's own rule first; shared subpaths map to viking://agent/skills, the rest fail at once, and the error names both allowed roots. - Non-Git remote sources such as tos:// were treated as remote, then refused as "direct host filesystem paths". add_skill now decides Git with the same prefixes resolve_skill_source uses (shared as GIT_SKILL_SOURCE_PREFIXES) and reports other schemes as unsupported. - With list_only, the upload instructions still said the skill would be installed and that no further call was needed; they now say the upload only lists the source's skills. - The zip example packaged hidden files, so .git and .env files went into the stored skill. It now excludes VCS data, .env files, node_modules and .DS_Store, starting from a fresh archive. - tree(include_abstract=true) printed the "abstract is not ready" placeholder for directories that never get an abstract, such as a skill's scripts/. Those placeholders are skipped. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(mcp): say that write only refuses the user's own skills subtree The capability reference claimed MCP write refuses every skill URI. It refuses the user's own skills/ subtree, but under viking://agent/skills it writes a plain file that skips skill installation. State that, and point shared skills at add_skill as well. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * style(skills): format skill_processor.py Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): inject an <available-skills> catalog at session start Agents on every harness learned about memories at session start but had no idea which skills OpenViking held, so a stored skill was only found if a later recall happened to surface it. buildProfileBlock() now takes the caller's resolved config as a fourth argument and, when skillCatalog is on (default), adds <available-skills> after <available-memories>: one GET /api/v1/skills lists the user's own skills first, then account-shared ones, dropping a shared skill the user shadows by name. Descriptions are cut to about 40 tokens and envelope tags in them are escaped, since the shared root is written by anyone on the account. The block has its own budget (skillCatalogTokenBudget, default 1200) and degrades from descriptions to names to a one-line count; with no skills, or a server without the endpoint, it is omitted. The shared formatListing now gives entries back so its "+N more" tail fits: a greedily filled listing never left room for it, so a cut listing ended silently. This also applies to <available-memories>. All six callers (claude-code, codex, opencode, dsh, pi, and the thin hook runtime for cursor/trae/zcode) pass their config through. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): recall account-shared skills and flag skill entries The server context face already mixes both skill roots into per-prompt recall, but the plugins' last-resort ranked find only searched viking://~/memories and viking://~/skills, so on servers without the context face a shared skill in viking://agent/skills could never be recalled. Add it as a third source, and name each skill hit by its directory rather than the .abstract.md it was indexed through, matching the context face and the session-start catalog. When an injected recall block carries a skill (a type="skills" entry, or a [skill] line from the fallback), its header gains one line telling the agent to read the skill's SKILL.md before following it. Turns without a skill are unchanged. The openclaw plugin's vendored recall-core copy is regenerated with it. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): point skill writes at add_skill in the URI guard A local Write or Edit aimed at viking://.../skills/... was denied with a hint to use MCP write or edit instead, but the server refuses both under the skills subtree, so the hint led straight into a second error. Hints may now depend on the URI: for a skill URI (viking://~/skills, viking://user/<id>/skills, viking://agent/skills) the default table and dsh's bridged table name add_skill with an add_skill(data="<the full SKILL.md text>") example. Other URIs keep their hints. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): bundle an openviking-skills skill Nothing told an agent how to work with skills stored in OpenViking: how to load one from the catalog, run its helper files, create one through add_skill, install from Git or a local folder, share it with the account, or move the user's existing local skills over. examples/skills/openviking-skills covers all of that, including a user-triggered, one-time migration of ~/.claude/skills, ~/.agents/skills and ~/.cursor/skills that keeps environment-bound skills local (shipped by a plugin or marketplace, symlinked in by a CLI installer such as lark-cli, or needing a local binary) and uploads only what the user approves skill by skill. It passes strict server validation. sync.mjs ships it wherever add_skill is a real tool and a bundled skill loads: the codex, claude-code, cursor and dsh plugins. openviking-memory now points to it for skill work. A new sync test keeps synced skills flat, since copySkill copies a flat file list and a subdirectory would crash it; the marketplace tests pin the packaged copies, and dsh's provider test expects both bundled skills. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * chore(plugins): bump versions for the skill integration claude-code 0.6.0, codex 0.10.0, agent-hook (cursor/trae/zcode) 0.4.0 and dsh 0.5.0 gain the skill catalog and, except trae/zcode, the bundled openviking-skills skill; opencode 0.3.3 and pi 0.3.3 gain the catalog. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(codex): recall shared skills and flag skill entries in Codex too Codex builds its recall block itself instead of through recall-core's wrappers, so the previous commit's changes never reached it: its raw search still skipped viking://agent/skills, its digest carried no skill hint, and its post-processing kept only level-2 leaves, which silently dropped every skill hit (skills are found through their directory's level-0 abstract), including the viking://~/skills search it already ran. recall-core now exports skillEntryHint() and skillHitUri(). The hint is added when a block carries a type="skills" entry or cites any skill URI, which also covers digests that only keep URIs. Codex searches the shared skill root as a third bucket, labels skill hits "skills" under their directory URI, lets them through post-processing, and adds the same hint line to its envelope. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(cursor): install openviking-skills next to openviking-memory sync.mjs puts openviking-skills into hosts/cursor/skills, but the installer copied only openviking-memory into ~/.cursor/skills, so Cursor never saw the new skill. Install, uninstall, the post-install check and the doctor's file list now cover both skills. The install test also moves to the agent-hook plugin's new 0.4.0 version string. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(dsh): keep PLUGIN_VERSION in step with package.json The version bump moved package.json to 0.5.0 but left the PLUGIN_VERSION constant at 0.4.3, which bundle.test.mjs and npm run check:version compare against the manifest. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(skills): match ov-skills commands to the real ov CLI flags The ov-skills skill documented flags the CLI never had (--json, and a --limit that is only a hidden alias), a raw-content "ov skills add -" form that sends a literal "-", and ov resources subcommands that do not exist. Every command line now follows the clap definitions: -o json for JSON, -n/--node-limit, -p/--uri on read commands and -p/--parent-auto-create on add, -s/--skill as a comma list, show --format, and validate's --strict-only body-length warning. ov add-skill is documented as the same command as ov skills add. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(plugins): document the skill catalog, openviking-skills, and skill-aware recall The integration pages (Claude Code, Codex, Cursor, TRAE, opencode, pi, dsh), the capability reference, the plugin development guide, and the plugin READMEs now describe the <available-skills> session-start block, its skillCatalog / skillCatalogTokenBudget knobs, the bundled openviking-skills skill where it ships, recall reaching viking://agent/skills with the skill-entry hint, and the URI guard sending skill writes to add_skill. en and zh pages carry the same facts. Stale statements fixed on the lines touched: thin hook hosts use the same 10000-token profile budget as the rest, the claude-code and codex plugins ship four skills, dsh mounts the server MCP surface, and the opencode install guide lists openviking_add_skill once. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): let the user's own skills use the catalog budget the shared group leaves Review findings on <available-skills>: - Each group got at most its even share of the listing budget, with unused tokens passing only forward, so the user's own skills (always first) never got more than half. Twenty own skills and one shared skill fell back to names only while most of the 1200 tokens went unused. A group now takes its even share or everything the later groups leave when listed in full, whichever is larger. - When a group's share could not hold its header plus the "+N more" tail, formatListing gave back every entry and printed a bare header, which reads as an empty directory. It now prints the one-line "N entries, budget too tight" stub instead (memory listings too). - A budget too small for even the one-line count now injects nothing rather than overrunning it. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): rank skill hits like memory leaves in the recall fallbacks Naming a skill hit by its directory instead of its .abstract.md cost it the 0.12 leaf boost, since the boost keyed on a ".md" URI, so a skill that main would recall lost to ten slightly weaker memory leaves. In Codex, skills were also never picked while enough memory leaves passed the threshold (leaves are picked first), and a hit the server labeled with another category lost its "skills" label. Skill hits now count as leaves in ranking and picking in both recall-core and Codex, and Codex always labels them "skills". Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): send shared-skill edits back to the shared root in the URI guard The guard's add_skill example carried no target_uri, and add_skill without one installs into the caller's own root. Fixing a shared skill that way left the team copy unchanged and created a private copy that the catalog then shows instead. For viking://agent/skills URIs the example now passes target_uri="viking://agent/skills", and a helper file (anything below a skill's SKILL.md) points to a folder upload through add_skill(path=...) rather than SKILL.md text. addSkillExample() builds the example for both the default table and dsh's. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(skills): tighten openviking-skills where review found unsafe steps - The catalog is a snapshot that drops descriptions or entries with many skills, so a name missing from it does not prove the name is free. Check <root>/<name>/SKILL.md, and confirm with the user before replacing an existing skill, since add_skill replaces silently. - Updating a shared skill must pass target_uri="viking://agent/skills" after the user confirms; otherwise add_skill creates a private copy that shadows it. - Every file in an uploaded folder is stored with the skill: zip without .git, .env files, node_modules and .DS_Store, and delete the archive afterwards. The migration now inspects the whole folder, hidden files included, for secrets. - Migration flags frontmatter keys OpenViking drops (for example disable-model-invocation or context), and fixes a missing name or description in a temporary copy, never in the user's file. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): keep the session-start block under the host's inline limit Claude Code saves hook context over 10,000 characters to a file and shows the model a 2 KB preview; Codex and trae-cli spill past about 10,000 bytes; ZCode drops stdout over 32 KB. The session-start block (profile at a 10,000-token budget, memory index, skill catalog, and the archive on resume) routinely ran 25-40 KB, so on these hosts the model saw only the start of the profile, and the catalog appended at the end never reached it. sessionStartMaxBytes caps the whole block in UTF-8 bytes: 9500 for claude-code and codex, 20000 for zcode, no cap elsewhere. Under the cap buildProfileBlock shrinks its token budgets (about 4 bytes per estimated token); if the block still does not fit it drops the memory index, then the catalog. On resume/compact the archive takes up to half, truncated on a line with a pointer to viking://~/sessions/<id>/history/. On resume, claude-code and codex skip the profile block when it matches the one this session already received, since the restored history holds it; a changed block is injected again. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): return one hit per skill package in find #5045 made a skill index as a whole package, so an item-level find now returns one hit per file inside it. Route skill-only find through SearchService.find_skills, which keeps the best hit per package, and resolve every skill hit to its package's SKILL.md instead of only rewriting the two index sidecars. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): point skill changes at add_skill in the tool descriptions The server keeps accepting write/edit under viking://agent/skills, and forget still removes a skill directory, so the constraint lives in the tool descriptions: add_skill is the one entry point for creating and updating a skill, and removal goes through ov skills remove or Studio. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(mcp): describe a skill hit by its own abstract A package hit can be any file inside the skill, whose abstract describes that file and not the skill, so find would list a skill under a helper script's summary. Read the package's abstract for those hits, the way GET /skills/find already does. Keep a filter-only skill query on the generic find, which find_skills does not serve. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): document package-level skill retrieval in find Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * feat(agent-plugins): ship the openviking-skills skill Agent Plugins has no hooks, so no session-start catalog: without this skill the model never learns that the account's skills exist. The skill's own text now reaches for find(context_type="skill") first and treats <available-skills> as something only some harnesses inject, so one copy reads correctly in both kinds of harness. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(plugins): name the skill package a recall hit came from Since a skill is indexed as a whole package, a hit can be any file inside it, not just the two index sidecars the old rewrite stripped. Derive the package root the way the server's skill_root_uri does, drop the internal update backups, and keep one entry per package at its best score. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(plugins): let the skill catalog use both roots' full listings The server already caps each skill root at node_limit, so a second cap over the merged list only bites once the private root alone fills it — and then it drops the shared root whole while reporting nothing dropped. The token budget is what should decide, and it already does. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(plugins): say node_limit caps each skill root, not the merged list Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(mcp): do not paste an unready abstract over a skill hit's own summary fs.abstract returns a placeholder string rather than raising when a package has no usable .abstract.md, so the substitution replaced a useful file summary with a diagnostic line. Reject the same placeholders tree already rejects, and bound the per-package reads the way read_content is bounded. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): correct how search reports skill hits, and refresh the tool table find and search both render one line per skill package, so the earlier wording — that search returns several hits per package — contradicted the code. Say what actually differs: search still spends a limit slot per matching file and keeps that file's summary. Also point forget and add_resource at add_skill where an agent would look for them, name the REST delete alongside the CLI, and bring the capability table's line citations back in step with mcp_endpoint.py. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(agent-plugins): list add_skill among the tools the package exposes Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * refactor(mcp): drop guards and prose no caller can reach install_skills only ever returns a dict, add_skill always fills root_uri, and a source with no SKILL.md raises before it gets here, so the isinstance, empty-list and missing-uri branches were unreachable. fs.abstract only returns the directory placeholder. One skill package resolves to one rendered item, so the pending map holds one each. In the docstrings, drop what Args already says and the one removal path an MCP caller cannot take. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(plugins): drop a comment about a branch formatListing cannot take A listing left with only its header returns the stub above, so it never reaches the silent close the comment described. Also name sessionStartMaxBytes in buildProfileBlock's options type, where the .d.mts already has it. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * revert(plugins): drop the skill changes in the recall fallback Reverts the recall half of the skill integration:992215bfb,553416200,5e838a0a0and62a456c08. The seven files they touched go back to their state ataa77061c1, the merge base with main. Those four commits taught the local recall assembly about skills: a third source for viking://agent/skills, skillHitUri naming a hit's package, dedupeSkillHits keeping one entry per package, rankItem scoring a skill like a memory leaf, and a header line telling the model to read SKILL.md. Four of the five only ever ran in the raw-find fallback. recallForPeer calls buildServerAssembledBlock first and returns as soon as it answers, so searchAllSources, rankItem and the fallback block builder are reached only on a deployment whose server has no context face. The fifth, the header line in wrapContext, did run on the main path, but the server already reports each entry's type and the URI it wants read, so the line restates what the block carries. Skills still reach the model on the path that runs: the context face searches both skill roots, returns them as entries with type="skills", and the session-start <available-skills> catalog lists every skill with its description. Both stay. The documentation that described the fallback behaviour goes with it. The statements that survive are the ones the context face makes true on its own, such as recall covering the skills shared with the account. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * chore(plugins): bump opencode and pi past main's releases Both were 0.3.3 on this branch and main has since shipped 0.3.3 of its own, so the version a host installs by no longer moves for the skill catalog they now carry through the shared library. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): say what list-mode search actually reports for a skill hit The search row claimed a skill package's summary is the matching file's. It is not: _format_search_result rewrites every skill hit onto the package's SKILL.md, keeps the best-scored one per package, and _describe_skills_by_package replaces the summary with the package's own abstract. What is true is that limit applies during retrieval, before that merge, so a package matching several files still spends several slots and fewer than limit results come back. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW
49 KiB
OpenViking Memory Plugin for Codex and TraeCode CLI 2.0
Long-term semantic memory for Codex, powered by OpenViking.
TraeCode CLI 2.0 supports the same plugin format; use the shared installer's dedicated --harness trae-cli entry.
Requires an OpenViking server with
viking://~home-alias support. Recall targets the caller's own context space throughviking://~/memoriesandviking://~/skills; the uid-lessviking://user/memoriesshorthand is rejected by newer servers.
This is the Codex counterpart to claude-code-memory-plugin. It hooks Codex's lifecycle to:
- Session-start profile injection on
startup,clear, andresume: loadprofile.mdplus abstract-annotated indexes ofpreferences/andentities/through the shared CJK-aware profile builder, followed by an<available-skills>catalog of your own and account-shared OpenViking skills. - Auto-recall relevant memories on every
UserPromptSubmitand inject them viahookSpecificOutput.additionalContext viking://notice onPreToolUse(Bash): a shell command that carries aviking://URI still runs, and the model is told that the URI is an OpenViking virtual path and which MCP tool reads it.- Incremental capture on
Stop(turn end): append the new user/assistant turns to a deterministic OpenViking session idcx-<codex_session_id>. Whenpending_tokensreachesOPENVIKING_COMMIT_TOKEN_THRESHOLD, commit while keeping a recent live tail. - Commit on
PreCompact: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it. - Commit on
SessionEnd(Codex ≥ 0.145): when a thread shuts down gracefully, catch up any turnsStopnever sent and commit the OV session, so the extractor runs on the whole conversation the moment you leave. - Fallback sweep on
SessionStart(source=startup|clear): commit state files that carry an end marker whose commit did not go through, or that have been idle pastOPENVIKING_CODEX_IDLE_TTL_MS.source=resumenever commits or sweeps; if the live OV session was already committed, it combines the profile block with the latest archive summary for continuity. SeeDESIGN.mdfor 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 server's retrieval, memory, resource, skill (add_skill), watch, filesystem, and code-navigation tools.
Quick Start
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.
A. One-line installer — curl | bash (recommended)
bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/memory-plugin-shared/install.sh) --harness codex
For TraeCode CLI 2.0:
bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/memory-plugin-shared/install.sh) --harness trae-cli
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:
- Checks
codexand Node.js 18+ (the plugin itself wants Codex's bundled Node 22+ at runtime) - Sets up
~/.openviking/ovcli.confinteractively - Registers the
openvikingmarketplace — 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 enablesopenviking-memory@openvikingwithfeatures.plugin_hooks = true - Keeps the checked-in stdio
.mcp.jsonintact;servers/mcp-proxy.mjsreads your activeovcli.confat runtime - Runs plugin-list and stdio MCP validation
After install:
codex # first run: pick "Trust all and continue" at the hook review prompt
Startup stops on 6 hooks need review — pick Trust all and continue. Every later update that touches a hook asks again, for however many changed. Choosing Continue without trusting, or skipping the prompt, leaves the hooks off: MCP tools still work, but recall and capture never fire. Two independent switches have to be on to get them back: /hooks (hook trust and on/off) and /plugins (the plugin's own enabled state). The same applies to TraeCode CLI 2.0, which runs this plugin under trae-cli.
B. Codex marketplace install
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]
hooks = true
# plugin_hooks = true # for older Codex releases
Finally start Codex and trust the plugin hooks once:
codex # then trust the hooks at the startup prompt, or via /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.
Manual setup
If you don't want the installer touching your rc, do these things yourself:
-
Write
ovcli.confonce 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>/examplesreadsexamples/.agents/plugins/marketplace.jsonand yields the sameopenviking-memory@openvikingid.hooks/hooks.jsonneeds no rendering on modern Codex: it uses the native${PLUGIN_ROOT}token, which Codex injects into the hook env and substitutes inline.
Configuration
Connection / identity source (applies to hooks, MCP, and ov commands run inside Codex):
- Default (auto): env-var credentials (
OPENVIKING_URL/OPENVIKING_BASE_URL,OPENVIKING_API_KEY/OPENVIKING_BEARER_TOKEN,OPENVIKING_ACCOUNT,OPENVIKING_USER,OPENVIKING_PEER_ID) win when any is set; otherwise the activeovcli.confis used:OPENVIKING_CLI_CONFIG_FILEor~/.openviking/ovcli.conf. With no credential env vars set,ov config switch <name>changes the active credentials for the CLI, hooks, MCP, and childovcommands together. - Forced: set
OPENVIKING_CREDENTIAL_SOURCE=clito forceovcli.conf, orOPENVIKING_CREDENTIAL_SOURCE=envto read env vars only, with neither config file. - Fallback: without credential env vars or an ovcli config,
ov.confis used (server.url/server.root_api_keyplus legacycodex.*tuning); thenhttp://127.0.0.1:1933unauthenticated.
The MCP proxy loads its connection through the same loadConfig() as the hooks, so the model tools and lifecycle hooks use the same target and key, including one set only in ovcli.conf's plugin.codex section. Codex passes the proxy only the variables .mcp.json lists, and that list covers every variable the connection reads.
Auth is sent as Authorization: Bearer <api_key> to both the REST API (used by hooks) and the /mcp endpoint (used by the model), and as nothing else — the hooks used to repeat the key as X-API-Key, which a gateway of your own can still add if it needs one. account and user go out as X-OpenViking-Account / X-OpenViking-User only in trusted mode; an api_key server reads both out of the key and ignores the headers.
By default the hooks derive the peer from git rather than from where the repository happens to sit: the normalized origin URL, else the repository root path. Outside a repository nothing is sent, and what is remembered there goes to your user-level space at viking://user/<you>/memories. In /Users/x/Dev/OpenViking/examples/codex-memory-plugin with origin git@github.com:volcengine/OpenViking.git the peer is github.com-volcengine-openviking, and it stays that from any subdirectory, worktree, machine or clone. Every clone of one repository therefore shares one project memory; a fork has a different origin and stays separate, and gh pr checkout of an external PR leaves origin alone, so reviewing one does not move the identity. Derivation is pure filesystem work — no git subprocess — so it also holds where git is missing from PATH or would refuse the repository over dubious ownership. Hooks pass the effective peer as peer_id for captured session messages and as X-OpenViking-Actor-Peer for retrieval and filesystem calls.
OPENVIKING_PEER_SOURCE (or plugin.peerSource / plugin.codex.peerSource in ovcli.conf, or peer.source in a workspace config file) picks the rule:
| Value | Meaning |
|---|---|
git |
Default. Same as ["{git_remote}", "{git_root}"]: normalized origin, else repository root. Outside a repository nothing is sent. No prefix is added. |
cwd |
The previous behaviour, byte for byte — every non-letter-or-digit character becomes -, so /Users/x/Dev/OpenViking becomes -Users-x-Dev-OpenViking. |
none |
Send no peer at all; OPENVIKING_WORKSPACE_PEER=0 and codex.workspacePeer=false still mean this. |
| a template | "git-{git_remote}", "team-{dir}", or a list tried in order; a template with an empty variable falls through to the next. |
The variables are {git_remote}, {git_root}, {cwd} and {dir} — see Workspace Peers for what each resolves to. {git_root} is empty outside a repository; {cwd} is never empty but sits in no default chain, so a bare path becomes a peer only when you ask for one; {dir} is the workspace root's directory name — the repository root, or the directory holding .openviking/config.json — and is empty when the directory is not a workspace.
To give a directory that is not a repository its own peer, create .openviking/config.json there holding {"version": 1, "peer": {"id": "my-project"}}.
Set actor_peer_id in ovcli.conf (or OPENVIKING_PEER_ID with OPENVIKING_CREDENTIAL_SOURCE=env) to pin an explicit peer instead of deriving one. The legacy codex.peerId / codex.peer_id fields in ov.conf still resolve as a fallback.
Upgrading from the path-derived peer needs no action: memories written under the old id stay reachable. With the default peer_scope: "all" the server's cross-peer sweep already covers them at no cost; with actor scope the hooks ask the old peer separately. There is no deadline, and OPENVIKING_PEER_SOURCE=cwd restores the old id outright.
Recall defaults to broad mode: global memory, the current workspace, and other workspace memories can all be recalled, with other workspaces ranked lower and rendered later. In this mode, the MCP proxy omits X-OpenViking-Actor-Peer so it can read any URI returned by broad recall for the authenticated user.
Set OPENVIKING_RECALL_PEER_SCOPE=actor or codex.recallPeerScope="actor" for isolation mode, which only sees global memory plus the configured peer. The MCP proxy requires actor_peer_id or OPENVIKING_PEER_ID in this mode and exits with a configuration error if neither is set. In deployments where one bot serves multiple people, such as zouk, vikingbot, or AstrBot, use isolation mode with an explicit actor peer so sessions cannot read another person's memories.
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.
Tuning the plugin
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=10
export OPENVIKING_RECALL_COMPRESS=1
export OPENVIKING_RECALL_COMPRESS_MODEL=gpt-5.3-codex-spark
export OPENVIKING_RECALL_COMPRESS_THINKING=default
export OPENVIKING_RECALL_COMPRESS_BASE_URL=https://api.example.com/v1
export OPENVIKING_RECALL_TIMEOUT_MS=120000
export OPENVIKING_CAPTURE_ASSISTANT_TURNS=1
export OPENVIKING_AUTO_COMMIT_ON_COMPACT=1
export OPENVIKING_PROFILE_TOKEN_BUDGET=10000
export OPENVIKING_SKILL_CATALOG=1
export OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET=1200
export OPENVIKING_SESSION_START_MAX_BYTES=9500
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.
Private-gateway extra headers
Some private OpenViking deployments sit behind a gateway that requires custom headers on every request. The stdio MCP proxy reads those from OPENVIKING_EXTRA_HEADERS, a JSON object of scalar values (header name → header value) — see your deployment's gateway docs for the exact header names it expects.
export OPENVIKING_EXTRA_HEADERS='{"<header-name>":"<header-value>"}'
Reserved headers (Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, Authorization) are dropped with a stderr warning: the proxy owns those and letting an env var override them would break session negotiation or expose the wrong credential. Bad JSON is ignored with a warning rather than crashing the proxy. This env var is scoped to the stdio MCP proxy; hooks read credentials through the ovcli chain and do not consult it.
Input filters
Two knobs put an ordered list of regex rules in front of the text the plugin sends: recallQueryFilters / OPENVIKING_RECALL_QUERY_FILTERS shapes the prompt before it becomes a search query, and captureFilters / OPENVIKING_CAPTURE_FILTERS shapes every turn on the write path before it is stored.
Rules are sed-style strings applied in order to one piece of text: s<d>pattern<d>replacement<d>[flags] substitutes, d<d>pattern<d>[flags] drops the text on a match, and k<d>pattern<d>[flags] keeps it only on a match (chain them for AND). <d> is any punctuation delimiter — /, |, #, : — escaped with \ inside the pattern; flags are i, m, s, u, g. A user: or assistant: prefix limits a rule to that role.
# strip a thinking-keyword prefix, and don't recall on slash / bash-mode prompts
export OPENVIKING_RECALL_QUERY_FILTERS='s/^\s*(ultrathink|think harder?)\s+//i,d|^\s*[/!]|'
# redact tokens before they are stored, and never store /clear or /compact turns
export OPENVIKING_CAPTURE_FILTERS='s/\b(sk|ghp|xoxb)_[A-Za-z0-9_-]+/[redacted]/g,user:d/^\s*\/(clear|compact)\b/'
The env vars are comma-separated lists, split before parsing, so a rule needing a literal comma — a bounded {10,} quantifier, say — belongs in the ovcli.conf array instead, where only trimming happens:
{
"plugin": {
"codex": {
"captureFilters": ["s/\\b(sk|ghp)_[A-Za-z0-9_-]{10,}/[redacted]/g"]
}
}
}
Rules run top to bottom and the first d that matches (or k that does not) ends the decision; text a substitution empties is not a drop, just too short to recall on. Filters run before OPENVIKING_MIN_QUERY_LENGTH and before the built-in ack / slash-command heuristics. A capture rule shapes what is sent, not what is already stored, and anything already in the pending queue carries the rules that were in effect when it was enqueued; adding a d/k rule mid-session also shortens the turn list the cursor counts, which reads as a transcript rewrite and replays from the last user turn. A rule that fails to compile is skipped, never fatal — ov-memory-doctor lists the active rules and reports the exact error for the ones it could not parse.
Workspace configuration files
A repository can carry its own plugin settings in <repo-root>/.openviking/config.json, which the team commits, and <repo-root>/.openviking/config.local.json, which stays private and gitignored. A third layer, this machine's entry under ~/.openviking/workspaces/, outranks both, and all three outrank ovcli.conf.
{
"version": 1,
"peer": { "source": "git" },
"recall": { "peer_scope": "actor" },
"bypass": { "session_patterns": ["**/fixtures/**"] }
}
version: 1 is required; a file declaring another version is skipped with a warning. Schema v1 is peer.source, peer.id, recall.enabled, recall.peer_scope, recall.dedup_turns, recall.max_items, recall.score_threshold, capture.enabled, capture.commit_token_threshold, bypass.session_patterns, and labels. Lists union across layers, and a leading "!reset" drops what was inherited. Unknown keys are kept and ignored.
These files are trusted without a prompt, because a hook is non-interactive and an approval gate would mean one command per workspace. What is refused is structural: connection and credential keys (url, api_key, account, user, extra_headers, …) are stripped with a warning and ${VAR} is never expanded in them. What a committed file switches off is announced by $ov-memory-doctor rather than blocked.
Keep .gitignore from ignoring all of .openviking/, or config.json can never be committed — narrow the rule to .openviking/media/ and .openviking/downloads/. The doctor warns while the blanket rule is in place.
Legacy codex block in ov.conf
Earlier 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.)
Architecture
┌────────────────────────────────────────────────────────────────────────┐
│ Codex │
└──┬─────────────────┬────────────────┬──────────────────┬───────────┬───┘
│ │ │ │ │
SessionStart UserPromptSubmit Stop PreCompact SessionEnd
(startup|clear|resume) │ (per turn) │ (graceful exit)
│ │ │ │ │
┌────▼──────────┐ ┌────▼──────┐ ┌──────▼──────┐ ┌─────────▼──────┐ ┌───▼─────────┐
│ session-start │ │ auto- │ │ auto- │ │ pre-compact- │ │ session- │
│ -commit.mjs │ │ recall.mjs│ │ capture.mjs │ │ capture.mjs │ │ end.mjs │
│ (profile + │ │ (search + │ │ (append + │ │ (commit + reset│ │ (mark + │
│ fallback sweep│ │ compress) │ │ threshold) │ │ ovSessionId) │ │ catch-up + │
│ + resume │ │ │ │ │ │ │ │ commit) │
│ archive) │ │ │ │ │ │ │ │ │
└────┬──────────┘ └────┬──────┘ └──────┬──────┘ └─────────┬──────┘ └───┬─────────┘
│ │ │ │ │
│ ┌───▼────────────────▼──────────────────▼───────────▼──┐
└────────────►│ OpenViking REST API │
│ /api/v1/search/{recall,search} │
│ /api/v1/sessions [+/{id}/{messages,commit}] │
│ /api/v1/content/read │
└─────────────────┬───────────────────────────────────┘
│
Codex ◄── stdio MCP proxy ──► /mcp (find, search, read,
(env/ovcli.conf) remember, resources, add_skill,
watches, filesystem)
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.
How It Works
See
DESIGN.mdfor the commit decision tree — it's the source of truth for which OpenViking session is sealed by which hook event.
SessionStart profile injection and fallback sweep
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 the hook runs the fallback sweep.
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 sweep.
On all three sources, the hook uses the same shared buildProfileBlock() implementation as the Claude Code, OpenCode, and pi integrations. It reads the user's profile.md and adds URI plus abstract indexes for preferences/ and entities/, with a CJK-aware token budget. The default budget is 10000; set OPENVIKING_PROFILE_TOKEN_BUDGET or plugin.codex.profileTokenBudget to change it. Set OPENVIKING_NO_AUTO_INJECT=1 or plugin.codex.noAutoInject=true to disable only this fixed profile/background injection, skill catalog included; per-prompt semantic recall remains controlled separately by OPENVIKING_AUTO_RECALL.
The same builder appends an <available-skills> block after <user-profile> and <available-memories>, inside the same <openviking-context source="session-start"> envelope. One GET /api/v1/skills?node_limit=200 call returns your own skills and the ones shared with the account under viking://agent/skills. Your own skills are listed first, and a shared skill with the same name as one of yours is left out. Each description is cut to about 40 tokens (CJK-aware), and envelope tags inside a description are escaped.
<openviking-context source="session-start">
<user-profile uri="viking://user/default/memories/profile.md">...</user-profile>
<available-memories>...</available-memories>
<available-skills>
OpenViking skills (stored in OpenViking, not local files). Before following one, read <dir>/<name>/SKILL.md with the OpenViking read tool.
viking://user/default/skills/
- pr-review — Review a pull request against the team checklist.
viking://agent/skills/
- deploy-runbook — Shared deployment runbook for the payments service.
</available-skills>
</openviking-context>
The catalog has its own budget, OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET or plugin.codex.skillCatalogTokenBudget (default 1200, range 0–20000), and never draws on the profile budget. Every entry keeps its description when that fits; otherwise the catalog lists names only, ending with ... +N more, search OpenViking skills to find the rest if even the names do not all fit; when not even one name fits, the block shrinks to the single line <available-skills>N OpenViking skills; search OpenViking skills to find them.</available-skills>. Set OPENVIKING_SKILL_CATALOG=0, plugin.codex.skillCatalog=false, or the budget to 0 to leave the catalog out. With no skills, or against a server without GET /api/v1/skills, the block is omitted. The bundled $openviking-skills skill tells the model how to find a skill, create or replace one with MCP add_skill, install one from Git or a local folder, share one to viking://agent/skills, and run a one-time migration of local skills that the user asks for and approves skill by skill.
On startup or clear, the script walks every state file except the new session_id and, for each one that still holds a live ovSessionId or carries an end marker:
ended_retry: an.ended.<timestamp>marker is present, meaningSessionEndfired but its commit never completed (server down, worker killed). Commit it now. A marker is swept even when the state has no liveovSessionId:PreCompactreleases the id but leaves the cursor behind, so the catch-up under the lock is the only way the tail turns are ever sent, and it derives a live id by itself as soon as it has something to send.idle_ttl: no marker, but the state has been idle for more thanOPENVIKING_CODEX_IDLE_TTL_MS(default 30 min). This is the path for exits that never fireSessionEnd— signals, crashes, Codex older than 0.145, and app-server threads whoseSessionEndis deferred.- Cursor retention in the same pass: a state file with no live OV session is kept as a resume cursor until
OPENVIKING_CODEX_COMMITTED_TTL_MS(default 30 days), or dropped after the idle TTL if it never captured a turn.
Each candidate is committed under its per-session lock with no waiting; a lock the sweep cannot take means a SessionEnd or Stop worker already owns that session, and the sweep logs the skip and moves on. Under the lock it first appends whatever the state's recorded transcriptPath still holds past the cursor, so a session whose own workers never ran is not archived without its tail turns; if part of that append fails it keeps the live session and the marker and leaves the commit to the next sweep. It also re-reads the .ended marker there: an ended_retry candidate whose marker is now gone (the thread was resumed) or newer than the snapshot (a later exit will commit it) falls back to the idle rule. A recorded transcriptPath that cannot be read is never mistaken for an empty transcript: the sweep logs transcript_unreadable, keeps the live session and the marker, and skips the commit. Commits preserve the transcript cursor for resume.
On any /commit failure (OV unreachable, non-2xx, timeout) we preserve state (keep ovSessionId set, and keep the .ended marker) so the next sweep can retry. SessionEnd and PreCompact apply the same unreadable-transcript guard as the sweep, so neither commits a session whose transcript it could not read.
On resume, the script skips commit/sweep. It still injects the profile block. If local state has no live ovSessionId, it also reads /api/v1/sessions/{cx-session-id}/context and combines the latest committed archive overview into the same SessionStart output. The archive block includes a viking://~/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 the archive half without disabling profile injection.
Auto-recall (every UserPromptSubmit)
auto-recall.mjs adapts the Codex prompt/session payload and calls the shared
buildRecallBlockDetailed() pipeline. The shared core owns context search,
legacy /recall, raw-search fallback, ranking, injection budgets, digest selection,
compression caching and URI repair. Codex owns the cx-<safe-session-id> mapping,
model/profile selection, CLI execution and the hook deadline.
{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "<openviking-context>\n...\n</openviking-context>" } }
The shared core prefers a server digest and suppresses injection for
no_relevant / NO_RELEVANT_MEMORY. Local compressor failures retain bounded
retrieved context. Raw fallback uses session-aware search when a session exists,
then retries without the session if all targets are empty; unavailable search
can fall back to find. Explicit user targets retain the home-alias fallback.
Local compression receives bounded full leaf content before injection truncation.
Without local compression, recallPreferAbstract and the shared token budget
control the fallback (including URI and wrapper overhead).
The hook has an OPENVIKING_RECALL_TIMEOUT_MS deadline (default 120s); the bundled
hook allows 130s. Nested compressor calls disable automatic memory hooks and are
killed on timeout. A failed compressor is not restarted for a legacy-peer pass
within the same turn. Capture recognizes the shared <openviking-context> wrapper
and removes injected context from newly captured messages.
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:
- configured
OPENVIKING_RECALL_COMPRESS_MODEL+OPENVIKING_RECALL_COMPRESS_THINKING gpt-5.3-codex-sparkwith thinkingdefaultgpt-5.6-lunawith thinkinglow- off (deterministic digest, no
codex execcompression)
Config knobs:
| Env var | Default | Meaning |
|---|---|---|
OPENVIKING_RECALL_LIMIT |
10 |
Legacy quota-scaling input; explicit values are converted to six coding quotas, not enforced as a final result cap. |
OPENVIKING_RECALL_COMPRESS |
auto |
server: cloud rewrite, never launches codex exec; client: local only; auto: local when available, otherwise cloud; off / 0: uncompressed. 1 aliases auto. |
OPENVIKING_RECALL_COMPRESS_MODEL |
unset | Custom first-choice compressor model. Set off to disable the local compressor (auto then uses cloud 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_BASE_URL |
unset | Base URL for the nested compressor's provider. Use this when --ignore-user-config prevents the compressor from reading the main Codex provider configuration. |
OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS |
1500 |
Skip the nested compressor below this recalled-context size. Set 0 to compress every non-empty result. |
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. |
OPENVIKING_RECALL_MAX_TOKENS |
1600 |
Token budget the server assembles the context block within, independent of the local compressor input limit. |
OPENVIKING_RECALL_DEDUP_TURNS |
5 |
Cross-turn cooldown: URIs served in the last N turns are skipped. |
OPENVIKING_RECALL_QUERY_EXPANSION |
auto |
auto lets the server widen short prompts using session context; off disables it. |
OPENVIKING_RECALL_QUERY_FILTERS |
"" |
Comma-separated regex rules applied to the prompt before it becomes a query — see Input filters. |
Recall now asks the server to assemble the context block in one request
(POST /api/v1/search/search with mode="context"), so budgeting, detail tiers
and cross-turn dedup are shared with every other harness. Deployments without
that endpoint fall back to /api/v1/search/recall, and that outcome is cached so
only the first turn pays for the probe. Server-owned Context defaults are omitted
unless explicitly configured, so the plugin follows the server instead of copying
values such as limit=10 or max_tokens=1600. An explicit legacy recallLimit
is converted to per-category coding quotas, not a final result cap. Values
from 1 through 5 therefore produce an effective total quota of 6, one retrieval
slot for each coding domain. Eligible cache misses still use local codex exec
compression on top of whichever path answered.
The mode="context" request covers skills as well as memories, from both your own skills/ and the account-shared viking://agent/skills, so a skill that fits the prompt can show up in the digest with its viking:// URI.
Client-side knobs can also live in ~/.openviking/ovcli.conf under
plugin (shared) or plugin.codex (this harness only), or in the workspace
layers; resolution order is env vars → the workspace layers → plugin.codex →
plugin → the legacy codex block in ov.conf → defaults.
viking:// URI notice (PreToolUse on Bash)
viking:// URIs are OpenViking virtual paths, so cat viking://… or ls viking://… cannot open them. uri-guard.mjs runs before every Bash call. When the command contains a viking:// URI, it returns hookSpecificOutput.additionalContext without a permissionDecision: the command runs unchanged, and the model is told which OpenViking MCP tool reads the URI and to ignore the notice when the URI is intentional data (an ov CLI argument, an HTTP payload, a search pattern). A command without a viking:// URI gets no output.
Nothing is denied: Codex edits files through apply_patch, whose input is a patch body rather than a path (Codex's Edit / Write matchers are aliases for it), so there is no path argument to guard.
Upgrading to 0.9.1:
PreToolUseis a newly registered hook event. Run/hooksin Codex after updating and approve it; until then shell commands run without the notice.
Stop (turn end → 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 and results become dedicated tool parts and tool_output is reported verbatim — the server externalizes anything larger than tool_output_externalization.threshold_chars (default 20000) and leaves a synopsis stub plus tool_output_ref, so the original stays readable via /api/v1/sessions/{id}/tool-results. OPENVIKING_CAPTURE_TOOL_MAX_CHARS (default 1000000) is only a guard against pathological payloads. Configured captureFilters rules run last, just before the payload is sent — see Input filters.
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.
PreCompact (deterministic commit)
pre-compact-capture.mjs:
- Catch-up append for any turns Stop hasn't captured yet (race-safe via
capturedTurnCount) - Commit the long-lived OV session so the extractor runs against the full pre-compact transcript
- Reset
ovSessionIdtonullso the nextStopre-derives the samecx-<safe-session-id>and appends the post-compact half under that deterministic OV session id
Session end
SessionEnd exists since Codex rust-v0.145.0. It fires when a thread shuts down gracefully — /quit, /exit, double Ctrl-C, EOF, and the end of a codex exec run — and at TUI exit every thread the process touched gets one, as a burst. /new on its own does not end the previous thread; its SessionEnd arrives when the process exits.
session-end.mjs catches up whatever turns the last Stop never sent, then commits the OV session so the extractor runs on the whole conversation. If any of those turns fail to land it keeps the live session and the marker instead of committing, so the sweep retries rather than archiving a conversation without its tail. Codex budgets the hook at 1s by default and clamps timeout in hooks.json to 3s, forces async: true hooks to run synchronously, and ignores their stdout — far too little for a commit. So the parent hook only writes an .ended marker next to the session state (lock-free, a millisecond) and detaches a worker that does the catch-up and the commit; Codex deliberately leaves cleanly detached helpers running after a hook exits.
SessionEnd does not fire on SIGTERM, SIGHUP, a closed terminal, kill -9, or a crash. When the TUI is attached to a codex app-server daemon, it is deferred to thread unload (30 min) or daemon shutdown. Those cases, and Codex older than 0.145 (and any TraeCode CLI build without it), are covered by the fallback sweep at the next SessionStart.
The .ended.<timestamp> marker and the per-session .lock directory live beside the state file. The timestamp it was written at is the marker's identity, and it is part of the filename: the SessionEnd parent hands it to its worker, which verifies the marker still matches before committing and returns untouched if it does not, and Stop / PreCompact / resume only clear markers older than their own start time. Because each removal unlinks the exact marker paths below its cutoff, a marker written while a removal is in flight is a different file and survives, so a late worker cannot erase a fresh exit's marker. Date.now() is only the starting point for that name: the marker is created exclusively and its timestamp bumped until that succeeds, so two exits within one millisecond cannot share a path. A bare <id>.ended written by an older build is still read back.
The lock serializes the four writers that persist the whole state object — the Stop worker, PreCompact, the SessionEnd worker, and the sweep — so none of them can clobber another's cursor or ovSessionId. The holder stamps an owner file inside the lock directory and releases only while it still owns it; a stale lock is taken over in place by claiming that owner file — an atomic rename aside followed by an exclusive create, so exactly one taker wins and the lock path is never momentarily absent. Its wait budget is OPENVIKING_CODEX_LOCK_WAIT_MS (default 120s for SessionEnd, 40s for PreCompact, which must answer inside a 60s hook budget); the sweep never waits.
Upgrading from 0.7.x:
SessionEndis a newly registered hook event, and Codex has no trust record for it. Run/hooksin Codex after updating and approve it, otherwise it silently never runs and every session falls back to the sweep.
Codex hook output schema
Codex'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, cwd |
hookSpecificOutput.additionalContext; may also include systemMessage when an orphaned session was committed |
UserPromptSubmit |
prompt, session_id |
hookSpecificOutput.additionalContext |
PreToolUse (Bash) |
tool_name, tool_input.command |
hookSpecificOutput.additionalContext with no permissionDecision, so the command still runs; no output when the command has no viking:// URI |
Stop |
last_assistant_message, transcript_path, session_id |
systemMessage (only) |
PreCompact |
trigger (manual/auto), transcript_path, session_id |
systemMessage (only) |
SessionEnd |
session_id, transcript_path, cwd, reason (constant other) |
none — Codex ignores the output; the script prints {} for symmetry |
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).
Troubleshooting
Start with the bundled doctor — it checks the install (marketplace, config.toml enablement, hook trust records, MCP wiring), the resolved config (which file won, API key shown masked), the connection (reachability, auth, /mcp) and the session state left by the hooks, and prints a fix for every finding:
node "$(ls -d ~/.codex/plugins/cache/openviking/openviking-memory/*/ | sort -V | tail -1)scripts/ov-memory-doctor.mjs"
Or invoke the $ov-memory-doctor skill in Codex, which runs the same script and walks the report. When the server runs on the same machine (loopback url) the report adds a Server health section — whether anything listens on the port, plugin-only keys in ov.conf that stop the server from starting, and GET /ready; everything else server-side (config validation, live embedding probe, native engine, disk) stays with openviking-server doctor.
Testing
There is no package.json and no build step, so the suite runs straight through Node's own test runner:
cd examples/codex-memory-plugin
node --test scripts/*.test.mjs
CI runs the same files (.github/workflows/pr.yml), so a green local run is the same signal. They cover every hook end to end against a stubbed server — the deterministic cx-<codex_session_id> derivation, incremental append and idempotent re-runs, the PreCompact and SessionEnd commit paths with their .ended.<ts> markers and locks, the SessionStart sweep (idle TTL, cursor retention, source=resume), and recall assembly. The MCP proxy is shared code and its contract is tested once, in examples/memory-plugin-shared/mcp-proxy-core.test.mjs.
Live checks
Two legs need a real server and real Codex auth, so they stay manual. Prerequisites: the ov CLI installed and reachable, Node.js 22+, and ~/.openviking/ovcli.conf (or a per-tenant variant like ovcli.conf.bob) pointing at the OpenViking server you want to write to. The plugin sends Authorization: Bearer <api_key> from this file, and X-OpenViking-Account / X-OpenViking-User only in trusted mode.
Memory extraction landed in the user namespace. After a session commits, wait ~60 s for OV's extractor, then:
export OV_CONF=$HOME/.openviking/ovcli.conf.bob # or whichever tenant
OPENVIKING_CONFIG_FILE=$OV_CONF ov ls viking://user/<your-user>/memories/
OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://user/<your-user>/memories/profile.md
Expect new entries describing the preferences the conversation stated, with timestamps from this run.
Codex CLI smoke test (requires codex auth):
codex plugin marketplace add /path/to/OpenViking-codex-marketplace # if not already
codex # interactive
# Have a brief conversation that mentions a clear preference,
# then /compact (manual PreCompact) to force a commit, then exit.
Then re-run the extraction check above.
Plugin Structure
codex-memory-plugin/
├── .codex-plugin/
│ └── plugin.json # Plugin manifest (hooks + mcp wiring)
├── hooks/
│ └── hooks.json # SessionStart + UserPromptSubmit + PreToolUse + Stop
│ + SessionEnd + PreCompact (uses Codex's native
│ ${PLUGIN_ROOT} token; no rendering needed on modern Codex)
├── skills/
│ ├── openviking-memory/ # How to use the memory tools
│ ├── openviking-skills/ # Find, use, create (add_skill), share, and migrate OpenViking skills
│ ├── ov-experience-memory/
│ └── ov-memory-doctor/ # Install / config / connection / local-server troubleshooting
├── scripts/
│ ├── config.mjs # Shared config loader (ovcli.conf + env)
│ ├── ov-memory-doctor.mjs # Diagnostics script ($ov-memory-doctor skill)
│ ├── 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 (+ .ended.<ts> / .lock sidecars)
│ ├── ov-session.mjs # Shared OV HTTP + transcript catch-up helpers
│ ├── auto-recall.mjs # UserPromptSubmit hook (REST /search/search)
│ ├── auto-capture.mjs # Stop hook (append + threshold commit)
│ ├── session-start-commit.mjs # SessionStart hook (profile + fallback sweep + resume archive)
│ ├── session-end.mjs # SessionEnd hook (mark + detached catch-up + commit)
│ ├── pre-compact-capture.mjs # PreCompact hook
│ ├── uri-guard.mjs # PreToolUse hook (viking:// notice on Bash)
│ └── *.test.mjs # node --test suites (session-end, pre-compact, ...)
├── servers/
│ └── mcp-proxy.mjs # stdio -> OpenViking /mcp bridge
├── setup-helper/
│ └── install.sh # One-line installer
├── .mcp.json # stdio MCP wiring
├── DESIGN.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.
Differences from the Claude Code Plugin
| 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 |
License
Apache-2.0 — same as OpenViking.
Cloud recall compression
Set OPENVIKING_RECALL_COMPRESS=server to request POST /api/v1/search/search
with mode: "context", rewrite: true. This also disables local startup compressor
probes. A returned server digest is injected without a second local compression
pass; a server no_relevant result injects nothing. If rewrite is unavailable,
the hook preserves the existing raw-context / legacy retrieval fallback.
auto uses rewrite: "auto" when the Codex executable or its compressor profile
is unavailable (including a cached runtime failure). A first local failure still
uses the deterministic fallback for that turn; later turns use the server.