Files
t0saki b7d0415c24 feat(plugins): skill catalog and openviking-skills for the memory plugins (#5161)
* 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,
5e838a0a0 and 62a456c08. The seven files they touched go back to their
state at aa77061c1, 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
2026-09-22 15:09:44 +08:00

49 KiB
Raw Permalink Blame History

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 through viking://~/memories and viking://~/skills; the uid-less viking://user/memories shorthand 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, and resume: load profile.md plus abstract-annotated indexes of preferences/ and entities/ 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 UserPromptSubmit and inject them via hookSpecificOutput.additionalContext
  • viking:// notice on PreToolUse (Bash): a shell command that carries a viking:// 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 id cx-<codex_session_id>. When pending_tokens reaches OPENVIKING_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 turns Stop never 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 past OPENVIKING_CODEX_IDLE_TTL_MS. source=resume never commits or sweeps; if the live OV session was already committed, it combines the profile block with 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 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.

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:

  1. Checks codex and Node.js 18+ (the plugin itself wants Codex's bundled Node 22+ at runtime)
  2. Sets up ~/.openviking/ovcli.conf interactively
  3. Registers the openviking 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
  4. Keeps the checked-in stdio .mcp.json intact; servers/mcp-proxy.mjs reads your active ovcli.conf at runtime
  5. 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 add therefore 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:

  1. 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).

  2. 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.

Configuration

Connection / identity source (applies to hooks, MCP, and ov commands run inside Codex):

  1. 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 active ovcli.conf is used: OPENVIKING_CLI_CONFIG_FILE or ~/.openviking/ovcli.conf. With no credential env vars set, ov config switch <name> changes the active credentials for the CLI, hooks, MCP, and child ov commands together.
  2. Forced: set OPENVIKING_CREDENTIAL_SOURCE=cli to force ovcli.conf, or OPENVIKING_CREDENTIAL_SOURCE=env to read env vars only, with neither config file.
  3. Fallback: without credential env vars or an ovcli config, ov.conf is used (server.url / server.root_api_key plus legacy codex.* tuning); then http://127.0.0.1:1933 unauthenticated.

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.md for 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:

  1. ended_retry: an .ended.<timestamp> marker is present, meaning SessionEnd fired but its commit never completed (server down, worker killed). Commit it now. A marker is swept even when the state has no live ovSessionId: PreCompact releases 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.
  2. idle_ttl: no marker, but the state has been idle for more than OPENVIKING_CODEX_IDLE_TTL_MS (default 30 min). This is the path for exits that never fire SessionEnd — signals, crashes, Codex older than 0.145, and app-server threads whose SessionEnd is deferred.
  3. 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:

  1. configured OPENVIKING_RECALL_COMPRESS_MODEL + OPENVIKING_RECALL_COMPRESS_THINKING
  2. gpt-5.3-codex-spark with thinking default
  3. gpt-5.6-luna with thinking low
  4. off (deterministic digest, no codex exec compression)

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: PreToolUse is a newly registered hook event. Run /hooks in 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:

  1. Catch-up append for any turns Stop hasn't captured yet (race-safe via capturedTurnCount)
  2. Commit the long-lived OV session so the extractor runs against the full pre-compact transcript
  3. Reset 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

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: SessionEnd is a newly registered hook event, and Codex has no trust record for it. Run /hooks in 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.