* fix(plugins): stop dropping ovcli.conf's plugin section, and unrot the sync test
`ov config add|edit`, the config wizard and `ov config switch` all rebuild
ovcli.conf from the `Config` struct, which has no `plugin` field and no
catch-all — so every write silently deleted the whole `plugin` section the
memory plugins own. `write_config_file` now carries over the top-level keys
`Config` does not model, `save_edited_config` reads them from the old name on a
rename, and `activate_config` keeps the active file's. Modeled keys are
deliberately not carried over: one that is `None` was cleared on purpose.
`KNOWN_CONFIG_KEYS` decides what counts as modeled, guarded by a test that
fails when a struct field is added without listing it.
sync.test.mjs kept its own copies of the target lists and they had drifted —
dsh, opencode and agent-plugins were each missing modules the sync ships, so a
stale vendored file passed CI. It now imports the lists from sync.mjs (whose
`main()` moved behind an entrypoint guard) and additionally fails on a module
no target claims or a banner-carrying orphan no target lists.
Also in this hygiene pass:
- postRecall dropped `peer_scope` on any 400/422, silently widening recall from
the caller's own peer to the whole user root. It now retries only on an
unknown-field rejection, remembers the downgrade so every turn stops paying
for a rejected request, and both doctors warn while that memo is live.
- The session peer pins (Claude Code's `ws-peer-*.json`, Codex's
`workspacePeerId`) carry a version, so a pin written under one derivation
rule cannot outlive it. Derivation is unchanged, so today they re-derive to
the same value.
- recall-session-wiring.test.mjs was never registered in CI and had rotted
against a fourth RecallManager argument; assertion fixed and registered.
* feat(plugins): layered workspace configuration
A workspace can now carry `<root>/.openviking/config.json`, which a team
commits, and `config.local.json`, which stays private; a per-machine registry
under `~/.openviking/workspaces/` sits above both so the user keeps the last
word over any repository they clone. All three share one schema and one merge,
and they slot in exactly where ovcli.conf's `plugin` section already does, so
`OPENVIKING_*` still wins over everything.
Three modules, synced to all seven plugin targets:
- workspace-identity.mjs finds the workspace root and reads git's own idea of
what the repository is called, using only filesystem reads. No `git`
subprocess: hooks are fresh Node processes on prompt-level paths with budgets
as tight as Codex's 3s SessionEnd, and this keeps working where git is absent
from PATH or would refuse the repo over dubious ownership. Worktrees converge
through `commondir`; submodules stay separate; `$HOME` and `/` are never
workspace roots.
- workspace-config.mjs discovers, parses, filters and merges the layers, and
records per-key provenance — which layer won and what it covered up.
- workspace-registry.mjs keeps one file per workspace rather than one listing
them all, so concurrent hooks cannot lose each other's writes, and treats a
path whose git identity has changed as a miss rather than inheriting the
previous repository's peer.
These files are trusted without a prompt, because a hook is non-interactive and
any approval gate degrades into "run one command per workspace first". What is
refused instead is structural and costs nobody anything: connection and
credential keys are stripped loudly, `${VAR}` is never expanded, and
`cli_config_profile` — which decides which credentials reach which server — is
registry-only and name-only. What a committed file switches off is announced in
doctor rather than blocked.
An adversarial review pass over these three modules found 18 defects, all fixed
here and each now covered by a test. The one that mattered: `JSON.parse` keeps
`__proto__` as an own property, so a 128-byte committed file could write
straight into `Object.prototype`, and since `process.env` reads through the
prototype chain and the environment outranks ovcli.conf, that set
`OPENVIKING_URL` and `OPENVIKING_API_KEY` for the whole process — silently
shipping the user's real API key to an attacker's host. Prototype keys are now
dropped with a warning in both the strip and the merge. The rest: unbounded
recursion (a 4KB file could take out every sibling layer), the identity cache
storing a remote's embedded token at 0644, worktrees under a directory named
`modules` misread as submodules, the registry's negative-evidence check being
inert in its only caller, `Number(null)` pinning cost knobs to a bound, and
provenance lying when two layers disagree about a key's type.
`.gitignore` no longer ignores all of `.openviking/`, which would have stopped
a team's config.json from ever being committed; doctor warns when a workspace
still does. The schema maps `capture.commit_token_threshold`, matching the knob
the loaders actually read — the RFC's example named a turn-based one that does
not exist.
* feat(plugins): derive the workspace peer from git, configurably
The peer a workspace writes its memories under was the working directory with
every non-alphanumeric byte turned into a dash. That made the identity an
accident of where the repository happened to sit: a clone on another machine, a
rename, a worktree, or simply `cd examples/` each minted a separate, empty
namespace, and there is no server-side rename or merge to recover from it.
The default is now git's own idea of the repository. `peer.source` decides the
rule and reads from every layer — `OPENVIKING_PEER_SOURCE`, ovcli.conf's
`plugin.peerSource`, or `peer.source` in a workspace file:
- `git` (new default) ≡ `["{git_remote}", "{git_root}", "{cwd}"]` — the
normalized origin, else the repository root, else the working directory. No
preset adds a prefix; a path-derived id already starts with `-` on POSIX, so
it cannot collide with a remote-derived one.
- `cwd` — the old rule, byte for byte.
- `none` — send no peer. `OPENVIKING_WORKSPACE_PEER=0` still means this.
- Any template, or a list tried in order, over `{git_remote}` `{git_root}`
`{cwd}` `{dir}`. Substitution is all-or-nothing: an empty variable falls
through to the next template rather than leaving a half-formed shared id.
So `/Users/x/Dev/OpenViking/examples/codex-memory-plugin` with origin
`git@github.com:volcengine/OpenViking.git` is `github.com-volcengine-openviking`
from any subdirectory, worktree, machine or clone. Every clone of one repository
shares one peer; a fork has a different origin and stays separate, and
`gh pr checkout` of someone else's PR does not change origin, so reviewing does
not move a session's memory.
Nobody has to migrate. The pre-git id is always recomputable locally, so
`resolveEffectivePeerId` returns it alongside the effective one and recall still
reaches it: under the default `peer_scope: "all"` the server's cross-peer sweep
already covers it for free, and under `"actor"` — where that sweep is off by
definition — the plugin asks that peer separately, as itself, which is cheaper
and reaches more than a bare cross-peer read would. There is no deadline on
this. Wired through all five recall paths; doctor names the previous peer and
says which of the two is carrying it.
`source` keeps its three values because five call sites compare it against the
literal `"workspace"` to decide whether a session pin may be reused; the new
`origin` field names the template that actually produced the id, and doctor
prints it. Both session pins bump their version, so a pin frozen under the old
rule cannot outlive it.
* docs: the workspace peer comes from git, and a workspace can carry config
Every page that described the peer as the working directory with its
non-alphanumerics dashed now describes `peer.source` and the git default, with
the presets, the template variables, the clone-vs-fork identity semantics, and
why no migration is required. The capability reference gains the three new
shared modules; the client configuration page gains a Workspace Configuration
section covering the two workspace files, the per-machine registry, the
precedence table, the v1 schema and what a workspace file may not set; the
Claude Code and Codex integration pages, which never mentioned peer derivation
at all, each gain a short section. All zh mirrors follow.
`examples/schemas/workspace-config-v1.json` is the schema the `$schema` key in
a workspace file points at, and `examples/workspace-config.example.json` is a
file to copy. `ovcli.conf.example` shows `plugin.peerSource`.
Two facts worth stating plainly, both verified against the loaders rather than
assumed: `OPENVIKING_PEER_SOURCE` and the workspace-file layer are read only by
the Claude Code and Codex plugins today, so the other harnesses run on the
default and their pages document the config key rather than an env var that
would be inert; and pi and dsh compute their legacy id from the process cwd,
so their pages promise dual-read only under the default `peer_scope: "all"`.
* fix(mcp): stop the proxies from guessing a peer out of their launch directory
Three MCP proxies keyed the actor peer off `process.cwd()`, which for a
long-lived server started from a static MCP config is the directory the harness
happened to launch from — often the plugin's own. The Codex proxy already
refused this and had a test forbidding it; the rule now holds for all of them
through the same `resolveMcpActorPeerId`.
The plan called for the parent process to inject `OPENVIKING_PEER_ID` at launch
instead, following dsh's `mcp.mjs:27`. That only works for dsh: Claude Code and
agent-plugins are launched from a static `.mcp.json`/`mcp.json` with no
environment block, and OpenCode's `createOpenVikingMcpConfig` builds a command
and args with nowhere to put one. So the fix is to stop guessing rather than to
guess better — a proxy sends no actor peer, which is broad recall, the default.
`resolveMcpActorPeerId` now warns and widens where it used to throw. Refusing to
start took away every memory tool because a scope preference could not be
honoured, which costs the user far more than the wider search does; the warning
says which two settings would scope it.
* test(pi): follow the git-derived peer default rather than pinning the cwd id
* fix(plugins): warn on the camelCase spelling of a connection key too
The projection into harness knobs is an allowlist, so `apiKey` in a workspace
file could never take effect — but it vanished without a word, which reads as
acceptance. It is refused by name now, like its snake_case twin.
* feat(cli): ov workspace show, and ov peer link|migrate|forget-previous
`ov workspace show` answers "which layer actually set this" the way
`git config --show-origin --show-scope` does: the workspace root and how it was
found, the git remote, every template variable, the effective peer and the
template that produced it, each config layer with whether it applied, and per
key the effective value plus everything it shadowed.
That question matters here because three languages read this configuration and
each could drift. So the Rust reader is not a paraphrase of the JS one — the two
were run side by side over the identity helpers, the merge with full provenance
trees, the file-read rules and the registry's raw bytes, and made byte-identical.
That comparison paid for itself: it caught `serde_json::Map::remove` being a
swap remove under `preserve_order`, which reshuffled a registry file the JS half
reads on every rewrite.
It also caught the divergence that would have made the command a liar. ovcli.conf's
`plugin` section speaks the flat knob names a harness loader reads (`peerSource`,
`recallLimit`); a workspace file spells the same settings nested (`peer.source`,
`recall.max_items`). Both are one chain in `loadPluginSettings`, and the port had
merged the flat file into the nested tree, so `plugin.peerSource: "cwd"` in
ovcli.conf left `ov workspace show` reporting the git-derived peer while every
plugin sent the cwd-derived one.
`ov peer link <id>` pins a peer for this workspace in the registry — the way out
of a fork that should share the upstream's memory, or a legacy id worth keeping.
`ov peer migrate` moves a peer's memories and resources with the fs mv API,
reporting the plan by default and requiring `--apply`; the server has no merge
semantics, so a collision is refused with the colliding path rather than
overwritten, and a listing that fills its limit aborts rather than planning from
a truncated view that could hide one. `ov peer forget-previous` clears the
recorded ids.
`workspace show`, `peer link` and `peer forget-previous` are local and do not
require ovcli.conf; `peer migrate` talks to the server and does. Both config
gates and the hand-rendered help are registered, with a test pinning the gates
against each other.
A test now reads FORBIDDEN_KEYS, REGISTRY_ONLY_KEYS and FREE_FORM_SECTIONS out
of the JS module and compares them to the Rust constants, because those lists
are what someone fixing a bug in one language edits — and they had already
drifted once while this was being written.
* build(cli): record the sha2 dependency edge in Cargo.lock
Already vendored for other workspace members; ov_cli now uses it for the
registry slot hash.
* test(plugins): the fixtures the plan named that were still missing
A moved or renamed repository keeping its identity is the change's whole point
and had no test; a shallow clone was worth pinning because it is exactly what
the rejected root-commit scheme could not answer; and the registry's
read-modify-write window between two hooks of one session is now written down as
a test rather than only as a comment.
* fix: the defects a plan review turned up
An independent review against the plan this branch was built from found ten
real defects. Each was reproduced before being fixed and is now covered by a
test.
The four that broke a promise the feature makes:
- The workspace config layer was resolved from the hook process's own working
directory, not from the `cwd` on its stdin payload — which is the
authoritative one. A hook started in one repository while the session sits in
another applied the wrong `.openviking/config.json`: its peer, its bypass
patterns, its `capture.enabled`. `loadConfig` now takes the directory, and
every hook that receives one re-resolves with it. Late re-resolution is safe
precisely because connection and credential keys are structurally forbidden
in a workspace file, so `baseUrl` and `apiKey` cannot move under an already
built client — the gates that a workspace can switch off moved below the
parse so they are decided on the right config too.
- Codex threw away a workspace file's `peer.id`: it returned the credential
chain's peer verbatim, so `{"peer": {"id": "team-a"}}` did nothing. Claude
Code had always honoured it.
- Claude Code's session pin returned only the id and source, dropping
`legacyPeerId` — so from the second hook of a session onward, dual-read
stopped asking the peer that holds everything written before the derivation
changed. Silently, and exactly where it mattered.
- `ov peer migrate` read the source peer with the actor-peer header set, which
the server refuses for another peer's path, and treated every `stat` error as
"does not exist". The common case — an `actor_peer_id` in ovcli.conf — got a
cheerful "Nothing to migrate" instead of a 403. It now uses a client with no
actor peer and tells a real error apart from an empty source.
The rest:
- A directory outside any repository is a workspace again. It had no root at
all, so a `.openviking/config.json` there was ignored entirely. `$HOME` and
`/` are still never roots, now judged on the starting directory rather than
on where the upward walk stops, and `git_root` stays empty outside a
repository so the `git` preset still falls through to `{cwd}`.
- The registry slot is keyed on identity, not path. Two linked worktrees of one
repository are one workspace — one peer, so one set of settings and one
`ov peer link` — and keying on the checkout path split them in two. This also
makes crossing two repositories physically impossible rather than merely
detected. (Their `config.json` files still follow each checkout; those are
files on a branch.)
- git folds section and key names to lower case, so `[Remote "origin"]` with
`URL = …` is a remote `git config` reads and we did not. A quoted subsection
stays case-sensitive.
- `min_client_version` warns instead of being silently kept as data, and still
never blocks.
- `cli_config_profile` was validated and then never used. It now selects
`~/.openviking/ovcli.conf.<name>` before credentials resolve — registry-only,
name-only, and a hard error when the profile is missing, because quietly
authenticating somewhere the user did not choose is the failure the key
exists to prevent.
- `ov workspace show` is exempt from the language gate. It is a diagnostic and
has to work on a machine where no language was ever chosen; `peer link`,
`migrate` and `forget-previous` mutate state and still gate.
- `ov peer link` records the peer it replaced, so a later `migrate` with no
`--from` finds it instead of falling back to a recomputed cwd id.
Two more the plan asked for that were missing: doctor now checks the knobs
*inside* ovcli.conf's `plugin` section — it was on the allowlist, so until now
`peerSorce` sat there doing nothing with no complaint — and suggests the key
you probably meant. A test derives the known-knob set from what the two loaders
actually read, so the list cannot rot into one that rejects a real knob; it
caught a missing entry the moment it was written.
The RFC is archived at docs/design/, with the three claims implementation
disproved corrected in place: the knob is `commit_token_threshold`, `__self`
and `ext-` are not reserved server-side, and a worktree converges its identity
rather than its config file.
* revert(cli): withdraw ov workspace and ov peer from this branch
The command surface these two files added was larger than the feature they
served: 4792 lines of Rust for `ov workspace show` and
`ov peer link|migrate|forget-previous`, against a change whose whole point is
what the hooks send. None of it had reached a user-facing document — only the
RFC named it — so it goes back out whole and the branch becomes a plugin
change plus one CLI bug fix.
Restored from the branch's merge-base rather than from origin/main, since main
has moved on since the branch was cut and those commits are not this branch's
to carry. `sha2` was pulled in only by `workspace.rs`, so its dependency edge
leaves with it.
What stays is `config.rs` and `config_wizard/store.rs`: `ov config add|edit`
dropped the whole `plugin` section because the wizard round-tripped the file
through a typed struct, and that fix has nothing to do with the withdrawn
commands.
The registry under `~/.openviking/workspaces/` stays too, as a layer the
plugins read. Nothing writes it for now; `ov-memory-doctor` prints the path it
expects, and the file is small enough to create by hand. A writer can come back
on its own merits.
* fix(plugins): derive a peer only inside a git repository
Codex desktop opens a directory per task — `~/Documents/Codex/<date>/<slug>/` —
and none of them is a repository. The `git` preset ended its fallback chain at
`{cwd}`, so every one-off task minted its own empty peer, and each new one
started with no memory. Nine such directories here, nine peers.
There is nothing app-specific to read: the state file that lists those threads
is Electron-private, a megabyte wide, desktop-only, and would have to be parsed
inside SessionEnd's 3-second budget. The signal that generalizes is structural
— the directory is not a repository, and nothing in it says it is a project.
So the default chain is now `["{git_remote}", "{git_root}"]` and stops there. A
directory that is neither a repository nor marked gets no peer at all, and what
is remembered in it goes to the user-level space, which is where it went before
peers existed. Deriving an identity from a bare path is what `peer.source:
"cwd"` is for, and it is a word away.
Naming such a directory is the other half. `findWorkspaceRoot` now also stops
at a directory holding `.openviking/config.json` or `config.local.json`, so a
marker file works from any depth below it, the way a repository does — and when
that marker sits inside a repository the git variables still resolve to the
enclosing repository, so marking a subdirectory of a monorepo does not split
the default peer. `{git_root}` is the repository's root, `{dir}` the workspace
root's name whichever made it one.
Nothing moves. When no template resolves, the pre-git id is still computed and
returned as `legacyPeerId`, so `peer_scope: "actor"` keeps asking for it and
`"all"` keeps sweeping it.
Two doctor bugs fell out of the same walk: `checkWorkspace` read `git.kind`
unconditionally and threw wherever there was no repository, and the peer block
warned "set peer.source to git" at a directory where `git` is exactly what is
already set and correctly resolves to nothing. It now says why no peer is sent,
and prints the file to create.
* docs(plugins): say how to give a directory its own peer, to users and to agents
The behavior change is only useful if the reader can act on it, and two kinds
of reader have to: the person whose scratch folder stopped having a memory, and
the coding agent they ask about it.
`docs/{en,zh}/configuration/02-client.md` is the one place that spells the rule
out, and everything else links to it. It gains "Give a Directory Its Own Peer",
which opens with the file to create and then the ladder above and below it;
"By Situation", eight rows from fork to throwaway folder; and "Recall
Isolation", which separates where memories are written from what is read back,
names the server's per-category penalties, and states the cost of sending no
peer outside a repository — a user-level memory is read at full weight in every
project afterwards.
The eight integration pages, both capability references, six plugin READMEs,
the changelog and the schema stop promising a fallback to the working
directory. Checking those claims against the loaders turned up one that was
never true: opencode, dsh and pi do not read workspace files at all, so a
`peer.id` written for them does nothing. Said plainly rather than left to be
discovered.
For agents, `openviking-memory/SKILL.md` gains ten lines on where memories are
filed — it is the skill that fires when someone asks why a folder has no
project memory, and it had nothing to say — and both `ov-memory-doctor`
references gain a table from what the user says to the exact key to write.
`ov-memory-doctor` prints the same snippet, so an agent that runs it needs no
further reading.
One snippet has to be identical in twenty places for any of this to hold, so
`WORKSPACE_PEER_HINT` is a constant the report builds its line from, and
`peer-guidance.test.mjs` asserts it appears verbatim wherever it is promised,
that no page still spells the retired chain or names a command this branch
withdrew, and that every variable the canonical page documents is one the code
substitutes. It asserts no prose: rewording a page must not turn it red.
* fix(plugins): reject an unrecognized peer.source instead of using it as an id
`peer.source` accepts a preset name, a template, or a list of templates, and
anything that is not a preset was treated as a template. A template with no
`{...}` in it renders to itself, so a typo became the peer: `"Git"` wrote every
memory under a peer literally named `Git`, and `"gti"` under `gti`. Silently —
the wrong namespace is indistinguishable from an empty one until someone
notices their project has no memory.
A bare string that is neither a preset nor contains `{` now warns and falls
back to the `git` default. A list is still taken at face value: writing one is
explicit enough that a typo inside it is a different kind of mistake.
The warning travels through an optional `onWarn` callback falling back to
stderr, matching `resolveMcpActorPeerId` in `mcp-proxy-config.mjs` — there is no
warnings array in reach, because `resolveEffectivePeerId` is called from the
hook runtime and from four harness config loaders, none of which thread one.
* docs(plugins): say what each harness can actually do with a peer
The peer documentation promised the same thing everywhere, but only the Claude
Code and Codex plugins read workspace configuration files. `loadPluginSettings`
is called from exactly two loaders; the other harnesses build their config from
their own file plus the environment. So a reader following the docs under pi,
dsh, opencode or cursor would create `.openviking/config.json` and watch it do
nothing.
The skill is the sharpest case: `openviking-memory/SKILL.md` is synced to
cursor and dsh as well, and it told an agent to write that file. An agent would
have done it, reported success, and changed nothing. It now names the two
harnesses that read it and points everyone else at `OPENVIKING_PEER_ID`.
The integration pages had started teaching the recipe and then retracting it in
the same sentence, which is worse than not mentioning it; they now carry the
one instruction that works there, and link to the canonical section for the
rest. The capability reference gains the same qualification, next to the
paragraph that already says only two harnesses read those layers.
Two smaller corrections. The doctor references had the same question answered
twice, once in the peer table and once in the troubleshooting table 140 lines
below; the troubleshooting row survives, since it carries a diagnostic column.
The RFC still archived implementation notes for the CLI this branch withdrew,
which would read as a description of commands that exist.
`peer-guidance.test.mjs` guards this alignment, and had two flaws of its own: it
swept the changelogs, which are generated from GitHub Releases and would go red
on a release note nobody wrote by hand, and it sliced a page between two
headings with `indexOf` without checking either was found — renaming the closing
heading would have silently scanned to end of file.
Also here, because it is the same kind of mismatch: the zcode MCP proxy sent an
actor peer under broad recall, where the other proxies leave the header unset.
It now routes through `resolveMcpActorPeerId` like they do. The dsh proxy
deliberately does not — its parent process resolves the peer per session and
injects it into the child environment, so it is not guessing at a launch
directory, and that reason is now recorded next to the line.
* fix(plugins): ship and install only the shared modules a plugin imports
Three new modules were fanned out to all seven plugin directories in one hunk,
and only two plugins call them. That left dead weight in five directories, and
it broke three installs.
The install is the part that mattered. `install.sh` copies a hand-written list
of shared files into `~/.openviking/agent-integrations/memory-plugin-shared/lib`,
where cursor, TRAE and TRAE CLI import from. The list names `workspace-peer.mjs`
but not `workspace-identity.mjs`, which `workspace-peer.mjs` imports — nor
`workspace-config.mjs`, which identity had come to import for three filename
constants. Copying exactly that list and importing the hook runtime fails with
ERR_MODULE_NOT_FOUND, so every hook of those three harnesses would have died on
startup. Nothing tested the list.
`CONFIG_DIR_NAME`, `TEAM_FILE` and `LOCAL_FILE` now live in
`workspace-identity.mjs`, which is where the walk that recognises a marked
directory needs them; `workspace-config.mjs` imports and re-exports them, so no
call site moves. Identity has no library-internal dependency left, which is what
makes the installed set closed at fifteen files instead of pulling the whole
configuration layer along behind it. `install-lib-closure.test.mjs` derives both
sides — the list parsed out of the shell script, and the transitive imports of
the three entrypoints — and fails in either direction, so neither a new
dependency nor a stale entry can go unnoticed again.
With identity standing alone, the fan-out can follow what is actually imported.
`sync.mjs` moves from arrays chained by spread — where the harness that does not
need a file is often the one the array is named after — to explicit per-target
lists. `plugin-config.mjs`, `workspace-config.mjs` and `workspace-registry.mjs`
leave dsh, pi, opencode and zcode, none of which import them; all four workspace
modules leave agent-plugins, whose only importer was deleted a half hour after
they arrived and whose peer is environment-only by design. That is about 4500
lines of vendored code that said something the code did not do.
The registry loses its write path in the same spirit. `writeEntry`,
`rememberPreviousPeer` and `listEntries` had no caller outside their own tests:
the CLI that would have written them was withdrawn from this branch. `readEntry`
and `entryPath` stay, because a hand-created entry is still read and the doctor
still prints where to put one. `cli_config_profile` goes with them — the whole
mechanism, down to the documentation that described it, since nothing ever
resolved a profile through it.
This is not a new policy. `HARNESS_KEYS` already carried the rule in a comment,
added the day after the same speculative fan-out happened in August: add a key
as its loader starts calling `loadPluginSettings`, not before, so the section
never promises a knob that silently does nothing.
* fix(dsh): thread peerSource into the per-session peer
The integration page documents `peerSource` in dsh's Cordis patch, but `stateFor` never passed it to `resolveEffectivePeerId`, so the key resolved to nothing and every dsh session ran on the default derivation. Pass it, and keep the pre-git id alongside so dual-read reaches memories written before the default changed.
* docs(plugins): correct the shared-layer counts after the distribution changed
The capability reference still described the pre-branch distribution: 18 library modules against 23, per-target counts from before each target stopped receiving the workspace configuration layer, and `workspace-config` / `workspace-registry` listed as reaching every JS harness when only claude-code and codex load them. It also still said `cli_config_profile` was registry-only, and that the registry is written for you.
* docs(rfc): lead with a TL;DR of the workspace config and peer source proposal
* feat(plugins): let peer.source name the harness with {harness}
The peer templates could describe where a checkout sits but never which
agent was running in it, so one repository could not keep a separate
memory per agent even when its user wanted that. The harness name was
already in every config, only baked into the User-Agent string.
No preset uses the new variable: sharing one project memory across
agents is the more useful default, so splitting stays opt-in via a
template such as "{git_remote}-{harness}". It is composed at render
time rather than in the workspace identity, whose result is cached
under a cwd-only key that two harnesses in one directory would share.
* docs(rfc): record git_branch and peer.command as directions, not deliverables
* chore(plugins): resync the openclaw vendored recall-core after the rebase
* test(opencode): follow resolveEffectivePeerId's widened return shape
Also mark the openclaw shared copies generated, the way every other sync
target already is.
OpenViking Memory Plugin for Claude Code
Long-term semantic memory for Claude Code, powered by OpenViking. Recall happens automatically before every prompt, capture happens automatically after every turn — no MCP tool calls required from the model.
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.
Installable straight from the repo's marketplace catalog — no separate distribution repo. See Manual setup for the two-command remote install.
Quick Start
One-line installer (recommended)
bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/memory-plugin-shared/install.sh) --harness claude
macOS / Linux only. Claude Code and Codex share this installer (drop --harness claude to pick interactively): it asks for your language (English/中文), the download source (GitHub, or a TOS mirror for GitHub-blocked regions — pass --dist tos), and your OpenViking credentials, then installs openviking-memory via the remote marketplace. The stdio MCP proxy reads ovcli.conf at runtime, so no shell wrapper or .mcp.json rendering is needed. Re-running is safe.
If you'd rather do it by hand, follow the four steps below.
Manual setup
1. Have an OpenViking server reachable
Either run one locally or point at a remote one. The quickstart guide walks through both options, including how to issue API keys for remote use. Default port is 1933; local mode runs without authentication.
Verify it's up:
curl http://localhost:1933/health # or your remote URL
2. Tell the plugin where the server is
Easiest path — write ~/.openviking/ovcli.conf (the same file ov CLI uses):
{
"url": "https://your-openviking-server.example.com",
"api_key": "<your-api-key>",
"account": "my-team",
"user": "alice"
}
For purely local mode (http://127.0.0.1:1933 with no auth) you can skip this step entirely — the plugin will silently use the local default.
If ov.conf is what you already maintain, the plugin reads it too — see Configuration for the full priority chain and per-field overrides.
3. Install the plugin
Remote marketplace (recommended) — no clone needed. The repo root ships a .claude-plugin/marketplace.json whose entry fetches this plugin via git-subdir:
claude plugin marketplace add https://raw.githubusercontent.com/volcengine/OpenViking/main/.claude-plugin/marketplace.json
claude plugin install openviking-memory@openviking
(claude plugin marketplace add volcengine/OpenViking works too, but clones the whole repo as the marketplace.)
If you skipped step 2, configure the connection afterwards: write ~/.openviking/ovcli.conf by hand, run node <plugin-dir>/scripts/setup.mjs (an interactive wizard bundled with the plugin), or just run the one-line installer.
Local directory (development) — registers this checkout so edits to scripts/ and hooks/ take effect on the next hook invocation without reinstalling. From the OpenViking repo root:
claude plugin marketplace add "$(pwd)/examples"
claude plugin install openviking-memory@openviking
Both commands install at user scope by default — the plugin is active from any directory. We don't pass
--scope userexplicitly because older Claude Code 2.0.x builds (e.g. 2.0.76) reject the flag. On newer builds that do accept--scope, you can lift a local-scoped install to user scope withclaude plugin enable openviking-memory@openviking --scope user.Directory-mode caveat: moving / renaming / deleting the source dir, or
git checkout-ing to a branch without these files, breaks the plugin. Both modes register a marketplace namedopenviking, so the plugin id is alwaysopenviking-memory@openviking; switch modes by removing the marketplace and re-adding the other source (the installer does this automatically).
Legacy mode (Claude Code < 2.0)
claude plugin ships in Claude Code 2.0+ (Oct 2025). Older builds still have claude mcp add and the hooks system, so the same functionality can be wired up by hand:
PLUGIN_DIR="$(pwd)/examples/claude-code-memory-plugin"
# stdio MCP proxy — reads ovcli.conf / OPENVIKING_* itself, no header wiring needed.
claude mcp remove openviking -s user 2>/dev/null
claude mcp add --scope user openviking -- node "$PLUGIN_DIR/servers/mcp-proxy.mjs"
# Merge plugin hooks into ~/.claude/settings.json (with backup).
mkdir -p ~/.claude && [ -f ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json
cp -p ~/.claude/settings.json ~/.claude/settings.json.bak.$(date +%s)
sed "s|\${CLAUDE_PLUGIN_ROOT}|$PLUGIN_DIR|g" "$PLUGIN_DIR/hooks/hooks.json" > /tmp/ov-hooks.json
jq --slurpfile h /tmp/ov-hooks.json '.hooks = ((.hooks // {}) * $h[0].hooks)' \
~/.claude/settings.json > /tmp/ov-settings.json
jq -e . /tmp/ov-settings.json >/dev/null && mv /tmp/ov-settings.json ~/.claude/settings.json
rm -f /tmp/ov-hooks.json
The one-line installer automates exactly this when it detects a pre-2.0 build (it keeps a source checkout under ~/.openviking/openviking-repo for the absolute paths above).
4. Start Claude Code
claude
If it doesn't seem to fire, set OPENVIKING_DEBUG=1 and check ~/.openviking/logs/cc-hooks.log.
Configuring MCP
The plugin's hooks and MCP entry now use the same configuration chain. The checked-in .mcp.json starts servers/mcp-proxy.mjs as a local stdio MCP server; that proxy reads OPENVIKING_*, ~/.openviking/ovcli.conf, and ~/.openviking/ov.conf, then forwards JSON-RPC to the OpenViking server's native /mcp endpoint with the right auth and identity headers.
For normal plugin installs, there is nothing extra to export and no .mcp.json value to render. Update ovcli.conf or the relevant OPENVIKING_* env vars and restart Claude Code; the proxy will use the same target as the hook scripts.
The proxy requires Node.js 18+ and writes debug logs only when OPENVIKING_DEBUG=1 or claude_code.debug=true is configured. stdout is reserved for MCP protocol bytes.
Configuration
Resolution priority
Every plugin field follows this chain (highest → lowest):
- Environment variables (
OPENVIKING_*— see tables below) - Workspace registry — this machine's entry for the current repository,
~/.openviking/workspaces/<slot>.json <repo-root>/.openviking/config.local.json— private, gitignored workspace settings<repo-root>/.openviking/config.json— workspace settings the team commitsovcli.conf— CLI client config (~/.openviking/ovcli.conforOPENVIKING_CLI_CONFIG_FILE); connection fields (url,api_key,account,user) plus thepluginsection,plugin.claude_codeahead of the sharedpluginov.conf— server config (~/.openviking/ov.conforOPENVIKING_CONFIG_FILE); the plugin readsserver.url,server.root_api_key, and a legacyclaude_codeblock if present (see Legacyclaude_codeblock)- Built-in defaults (
http://127.0.0.1:1933, no auth)
The three workspace layers carry only the settings listed under Workspace configuration files; connection and credentials are never read from them.
The same connection and identity fields are also used by the stdio MCP proxy.
Environment variables
All plugin behavior can be set via env vars. Connection / identity vars affect both hooks and the MCP proxy; tuning vars only affect hooks.
Connection / identity
| Env Var | Description |
|---|---|
OPENVIKING_URL / OPENVIKING_BASE_URL |
Full server URL (e.g. https://remote.example.com) |
OPENVIKING_API_KEY / OPENVIKING_BEARER_TOKEN |
API key; sent as Authorization: Bearer <key> |
OPENVIKING_ACCOUNT |
Multi-tenant account (X-OpenViking-Account header) |
OPENVIKING_USER |
Multi-tenant user (X-OpenViking-User header) |
OPENVIKING_PEER_ID |
Optional stable peer for recall and captured session messages |
OPENVIKING_PEER_SOURCE |
How the workspace peer is derived: git (default), cwd, none, or a template |
OPENVIKING_WORKSPACE_PEER |
Derive a peer from the current workspace by default; set 0 to disable |
By default the plugin derives 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 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 — so every clone of one repository shares one project memory, while a fork, having a different origin, stays separate. Data-plane recall/profile requests send the effective peer as X-OpenViking-Actor-Peer; captured session messages store it as body peer_id. OPENVIKING_PEER_ID overrides the derived value. Subagent capture uses the parent workspace peer when available, and falls back to Claude's agent_id only when no explicit or workspace peer exists.
OPENVIKING_PEER_SOURCE (or plugin.peerSource / plugin.claude_code.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 still means 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. 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.
To give a directory that is not a repository its own peer, create .openviking/config.json there holding {"version": 1, "peer": {"id": "my-project"}}.
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 plugin asks the old peer separately. There is no deadline, and OPENVIKING_PEER_SOURCE=cwd restores the old id outright.
Recall tuning
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_AUTO_RECALL |
true |
Enable auto-recall on every user prompt |
OPENVIKING_RECALL_LIMIT |
10 |
Legacy quota-scaling input; converted to six coding quotas, not a final cap |
OPENVIKING_RECALL_TOKEN_BUDGET |
2000 |
Inline token budget for the final raw-find fallback only |
OPENVIKING_RECALL_MAX_CONTENT_CHARS |
500 |
Per-item content cap |
OPENVIKING_RECALL_PREFER_ABSTRACT |
true |
Prefer abstract over full body when available |
OPENVIKING_RECALL_PEER_SCOPE |
all |
all can recall other project memories with a score penalty; actor only sees global plus the current project |
OPENVIKING_RECALL_MAX_TOKENS |
1600 |
Token budget for the server-assembled context block (independent of local compression limits) |
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_COMPRESS |
auto |
Digest compression: off, client (host CLI), server, or auto (local first, server fallback) |
OPENVIKING_RECALL_COMPRESS_MAX_BULLETS |
6 |
Digest bullet ceiling |
OPENVIKING_SCORE_THRESHOLD |
0.35 |
Min relevance score (0–1) |
OPENVIKING_MIN_QUERY_LENGTH |
3 |
Skip recall for very short queries |
Recall defaults to the broad mode: global memory, the current workspace, and other workspace memories can all be recalled, with other workspaces penalized and rendered later. Set OPENVIKING_RECALL_PEER_SCOPE=actor for the isolation mode, which only sees global memory plus the current workspace. In deployments where one bot serves multiple real people, such as zouk, vikingbot, or AstrBot, use the isolation mode with an explicit actor peer so one person's memories are not recalled into another person's session.
| OPENVIKING_LOG_RANKING_DETAILS | false | Per-candidate scoring logs (verbose) |
Capture tuning
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_AUTO_CAPTURE |
true |
Enable auto-capture; also gates write hooks (PreCompact / SessionEnd / SubagentStop) |
OPENVIKING_CAPTURE_MODE |
semantic |
semantic (always capture) or keyword (trigger-based) |
OPENVIKING_CAPTURE_MAX_LENGTH |
24000 |
Max sanitized text length for the capture decision |
OPENVIKING_CAPTURE_ASSISTANT_TURNS |
true |
Include assistant turns (text + tool I/O). Set to 0 for user-only. |
OPENVIKING_CAPTURE_TOOL_MAX_CHARS |
1000000 |
Guard cap on one tool part's tool_output; oversized output is externalized server-side |
OPENVIKING_COMMIT_TOKEN_THRESHOLD |
20000 |
Pending-token threshold for client-driven commit |
OPENVIKING_RESUME_CONTEXT_BUDGET |
32000 |
Token budget when fetching archive overview on session resume |
Lifecycle / behavior / misc
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_TIMEOUT_MS |
15000 |
HTTP timeout for recall + general requests (ms) |
OPENVIKING_CAPTURE_TIMEOUT_MS |
30000 |
HTTP timeout for capture path (must stay under the Stop hook timeout) |
OPENVIKING_WRITE_PATH_ASYNC |
true |
Detach write hooks into a background worker so CC isn't blocked on commit RTT |
OPENVIKING_BYPASS_SESSION |
false |
One-shot: 1/true skips every hook in the current process |
OPENVIKING_BYPASS_SESSION_PATTERNS |
"" |
CSV of glob patterns matched against session_id or cwd |
OPENVIKING_MEMORY_ENABLED |
(auto) | 0/false/no=force off; 1/true/yes=force on |
OPENVIKING_DEBUG |
false |
1/true=write hook logs to ~/.openviking/logs/cc-hooks.log |
OPENVIKING_DEBUG_LOG |
~/.openviking/logs/cc-hooks.log |
Override log path |
OPENVIKING_CONFIG_FILE |
~/.openviking/ov.conf |
Override ov.conf path |
OPENVIKING_CLI_CONFIG_FILE |
~/.openviking/ovcli.conf |
Override ovcli.conf path |
Pure-env example (no config file required):
OPENVIKING_MEMORY_ENABLED=1 \
OPENVIKING_URL=https://openviking.example.com \
OPENVIKING_API_KEY=sk-xxx \
OPENVIKING_ACCOUNT=my-team \
OPENVIKING_USER=alice \
OPENVIKING_RECALL_LIMIT=8 \
claude
Enable / disable
OPENVIKING_MEMORY_ENABLEDenv var —0/false/noforces off;1/true/yesforces on (when forced on without config files, connection info must come from env vars)claude_code.enabledinov.conf—falsedisables- Config file existence — enabled if
ov.conforovcli.confexists; otherwise silently disabled (no error, hooks pass through)
Bypass a session
Use Claude Code in a /tmp PoC directory without polluting your long-term memory:
# Persistent: any session whose session_id or cwd matches a pattern
export OPENVIKING_BYPASS_SESSION_PATTERNS='/tmp/**,**/scratch/**,/Users/me/Dev/throwaway/*'
# Or one-shot:
OPENVIKING_BYPASS_SESSION=1 claude
When bypass is active, every hook approves immediately without contacting OpenViking.
Plugin settings in ovcli.conf
Client-side tuning belongs in ~/.openviking/ovcli.conf under a plugin section. Shared keys apply to every harness; a per-harness object overrides them:
{
"url": "http://127.0.0.1:1933",
"plugin": {
"recallCompress": "auto"
}
}
Resolution order: env vars → the workspace layers → plugin.claude_code → plugin → the legacy claude_code block in ov.conf → built-in defaults.
The plugin omits server-owned Context defaults such as limit=10, max_tokens=1600,
and query_expansion="auto" unless you explicitly override them.
An explicit legacy recallLimit is converted to per-category coding quotas,
not enforced as a final result cap. Values from 1 through 5 therefore produce
an effective total quota of 6, one retrieval slot for each coding domain. New
direct API integrations should configure quotas instead.
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.
{
"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.
Digest compression
recallCompress decides where the digest is produced and defaults to auto. client always compresses locally through claude -p (Sonnet with low effort by default — Haiku ignores the effort knob, so its latency is unbounded), keeping the token cost on your own subscription. server asks OpenViking for the digest. auto prefers local and falls back to the server when no healthy host CLI is found. Compressor execution or output-validation failures fall back to the uncompressed context block; an exact NO_RELEVANT_MEMORY response from either compressor is a successful empty result and injects nothing. The compressor subprocess runs with all OpenViking hooks disabled so it cannot recurse. The former OPENVIKING_RECALL_REWRITE environment variable and recallRewrite config key remain supported as lower-priority compatibility aliases.
Legacy claude_code block in ov.conf
Earlier plugin versions configured tuning fields under a claude_code block in ~/.openviking/ov.conf. That still works for backward compatibility — every env var above has a camelCase counterpart (OPENVIKING_RECALL_LIMIT → claude_code.recallLimit, OPENVIKING_BYPASS_SESSION_PATTERNS → claude_code.bypassSessionPatterns as a JSON array, etc.). Env vars take priority. New deployments should prefer env vars and shell rc — server config files shouldn't carry per-developer-machine tuning.
Hook timeouts
Defaults in hooks/hooks.json:
| Hook | Timeout | Notes |
|---|---|---|
SessionStart |
120s |
Generous because resume/compact may pull a large archive overview |
UserPromptSubmit |
60s |
Allows the default local compressor to finish; its own timeout remains shorter so the hook can degrade safely |
Stop |
45s |
Auto-capture parses transcript + pushes turns; async detach makes the user-perceived time near-zero |
PreCompact |
30s |
Synchronous commit before Claude Code mutates the transcript |
SessionEnd |
30s |
Final commit; async-detached |
SubagentStart |
10s |
Lightweight: just persists isolation state |
SubagentStop |
45s |
Reads subagent transcript and commits; async-detached |
Keep claude_code.captureTimeoutMs below the Stop timeout so the script can fail gracefully and still update its incremental state.
Statusline
The plugin renders a one-line status of OpenViking under your Claude Code input box. The installer registers it in ~/.claude/settings.json (CC's plugin manifest doesn't accept a statusLine field, so this is the only way to wire it in).
Examples:
OV ✓ │ Fable 5 · ctx 42% │ ↩ 6 mem · 50ms 6 memories injected; model + context usage
OV ⚠ slow probe missed the 1 s budget (server may be lagging)
OV ✗ offline server unreachable
OV ⚡ bypass │ Fable 5 · ctx 42% OPENVIKING_BYPASS_SESSION* matched
OV ✓ │ ✎ 573/20k · 2 arch pending capture, two archives produced this session
OV ✓ │ 🔗 resumed │ +3 today session re-hydrated; 3 archives committed today
The ctx percentage reproduces Claude Code's native context indicator (a custom statusLine replaces it), with the native color thresholds: <70% dim, 70–89% yellow, ≥90% red. Hide it with OPENVIKING_STATUSLINE_CTX=off.
For the full segment glossary and personalization recipes (hide segments, recolor, compose with another statusline, add a custom segment), see STATUSLINE.md.
Data flow:
auto-recall.mjs/auto-capture.mjs/session-start.mjswrite small snapshots to~/.openviking/state/{last-recall,last-capture,last-session-event,daily-stats}.jsonafter each turn.scripts/statusline.mjsreads those snapshots plus a 5 s shared cache ofGET /health.- Network calls have a hard 1 s timeout. Cache is shared across CC sessions to prevent stampedes.
Disable / customize:
OPENVIKING_STATUSLINE=off— silence without removing the registration.NO_COLOR=1(or non-TTY) — strip ANSI colors automatically.- Remove entirely:
jq 'del(.statusLine)' ~/.claude/settings.json > t && mv t ~/.claude/settings.json. - Already had a custom statusline? The installer prompts replace / skip / manual-compose.
Debug logging
Set claude_code.debug: true in ov.conf or OPENVIKING_DEBUG=1 to write hook logs to ~/.openviking/logs/cc-hooks.log.
auto-recalllogs key stages plus a compactranking_summaryby default.- Set
claude_code.logRankingDetails: trueonly when investigating per-candidate scoring; output is verbose. - For deep diagnosis, run the standalone scripts
scripts/debug-recall.mjsandscripts/debug-capture.mjsagainst a sample input rather than leaving the hook log on permanently.
Troubleshooting
Start with the bundled doctor — it checks the install (marketplace, enablement, hooks, MCP wiring), the resolved config (which file won, API key shown masked), the connection (reachability, auth, /mcp) and recent hook activity, and prints a fix for every finding:
node "$(jq -r '.plugins["openviking-memory@openviking"][0].installPath' ~/.claude/plugins/installed_plugins.json)/scripts/ov-memory-doctor.mjs"
Or just ask Claude to check the plugin: the ov-memory-doctor skill 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.
| Symptom | Cause | Fix |
|---|---|---|
| Plugin not activating | No ov.conf / ovcli.conf found |
Create one, or set OPENVIKING_MEMORY_ENABLED=1 plus the URL/API_KEY env vars |
| Hooks fire but recall is empty | OpenViking server not running, or wrong URL | curl http://localhost:1933/health (or your remote URL) |
| Auto-capture extracts 0 memories | Wrong embedding/extraction model in ov.conf |
Check embedding / vlm config; review server logs |
| MCP tools hit the wrong server | stale ovcli.conf / env vars, or Claude Code not restarted after config change |
See Configuring MCP, verify ~/.openviking/ovcli.conf, then restart Claude Code |
| Remote auth 401 / 403 | API key / account / user header mismatch | Verify OPENVIKING_API_KEY, OPENVIKING_ACCOUNT, OPENVIKING_USER (or their ov.conf counterparts) |
Stop hook times out |
Server slow + sync write path | Leave writePathAsync: true (default), or raise the Stop timeout in hooks/hooks.json |
| Old context keeps re-appearing in OV | Pre-fix versions captured the recall block back into OV | Update to current version — auto-capture now strips <openviking-context> before pushing |
| Logs are noisy | logRankingDetails: true left on |
Set false; use debug-recall.mjs / debug-capture.mjs for one-off inspection |
Compared to Claude Code's built-in memory
Claude Code has a built-in MEMORY.md file system. This plugin complements it:
| Feature | Built-in MEMORY.md |
OpenViking plugin |
|---|---|---|
| Storage | Flat markdown | Vector DB + structured extraction |
| Search | Loaded into context wholesale | Semantic similarity + ranking + token budget |
| Scope | Per-project | Cross-project, cross-session, peer-scoped |
| Capacity | ~200 lines (context limit) | Unlimited (server-side storage) |
| Extraction | Manual rules | LLM-powered entity / preference / event extraction |
| Subagents | Same as parent | Isolated session + peer-scoped capture |
Architecture
┌────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ SessionStart UserPromptSubmit Stop PreCompact │
│ SessionEnd SubagentStart SubagentStop │
└────┬───────────────┬───────────────┬───────────┬───────────┘
│ │ │ │
│ ┌───────────▼───────────┐ │ │
│ │ hook scripts (.mjs) │ │ │ ┌──────────────┐
│ │ read transcript + │───┼───────────┼────►│ │
│ │ call OV HTTP API │ │ │ │ OpenViking │
│ └───────────────────────┘ │ │ │ Server │
│ │ │ │ (Python) │
│ ┌────────────▼───────────▼───►│ │
│ │ MCP tools (stdio proxy → /mcp) │
│ │ find/search/recall/remember/… │ │
└─────────────────►│ │ │
OV session └─────────────────────────────► │
context inject └──────────────┘
There is no TypeScript build step and no runtime npm bootstrap. Hooks are plain .mjs files that talk to OpenViking over HTTP; MCP uses servers/mcp-proxy.mjs as a zero-dependency stdio bridge to the OpenViking server's /mcp endpoint.
A persistent OpenViking session is created on first contact and reused for the entire Claude Code session. The OV session ID is cc-<cc_session_id> (the CC session_id verbatim, no hashing), so resume / compact / multi-hook events all target the same session. Archival + memory extraction is triggered client-side: the Stop hook commits when server-reported pending tokens cross commitTokenThreshold (default 20000), and PreCompact / SessionEnd / SubagentStop commit unconditionally.
Hook responsibilities
| Hook | Trigger | Action |
|---|---|---|
UserPromptSubmit |
Each user turn | Search OV → rank → inject <openviking-context> block within a token budget |
Stop |
Claude finishes a response | Parse transcript → push new user turns to OV session → commit when pending tokens cross threshold |
SessionStart |
New / resumed / post-compact session | On resume/compact, fetch the latest archive overview and inject it as additional context |
PreCompact |
Before Claude Code rewrites the transcript | Commit pending messages so they become an archive before CC mutates the transcript |
SessionEnd |
Claude Code session closes | Final commit so the last window is archived |
SubagentStart |
Parent spawns a subagent via Task tool | Derive an isolated OV session ID for the subagent, persist start state |
SubagentStop |
Subagent finishes | Read subagent transcript → push to an isolated session with subagent peer identity → commit |
PreToolUse |
Native Read / Glob / Grep on a viking:// URI |
Deny the call and point Claude to the equivalent OpenViking MCP tool |
PostToolUse |
Read of a SKILL.md file |
Optional (default off): inject an experience block when OV has relevant skill-experience memories |
Async write path
Stop, SessionEnd, and SubagentStop use a detached-worker pattern: the parent hook drains stdin, prints {decision:"approve"} to unblock Claude Code, then spawns a detached clone to do the HTTP work. The user never waits for OV. PreCompact stays synchronous because Claude Code mutates the transcript right after.
Disable with claude_code.writePathAsync: false if you need deterministic ordering during debugging.
Memory pollution prevention
auto-capture strips <openviking-context>, <system-reminder>, <relevant-memories>, and [Subagent Context] blocks from each turn before pushing to OV. Without this, the recall context the plugin injects this turn would be captured back as part of the user's "message" next turn, creating a self-referential pollution loop.
MCP tools available from the server
The plugin's .mcp.json starts a local stdio proxy, which connects to the OpenViking server's native HTTP MCP endpoint at /mcp. Claude can call the server's retrieval, memory, resource, watch, filesystem, and code-navigation tools on demand.
See the MCP integration guide for the canonical tool list and parameters.
Plugin structure
claude-code-memory-plugin/
├── .claude-plugin/
│ └── plugin.json # plugin manifest
├── hooks/
│ └── hooks.json # 9 hook registrations
├── commands/
│ └── ov.md # /ov status command
├── skills/
│ ├── openviking-memory/ # how to use the memory tools
│ ├── ov-experience-memory/
│ └── ov-memory-doctor/ # install / config / connection / local-server troubleshooting
├── servers/
│ └── mcp-proxy.mjs # stdio -> OpenViking /mcp bridge
├── scripts/
│ ├── config.mjs # shared config loader (env > ovcli.conf > ov.conf)
│ ├── debug-log.mjs # log helper for ~/.openviking/logs/cc-hooks.log
│ ├── auto-recall.mjs # UserPromptSubmit
│ ├── auto-capture.mjs # Stop
│ ├── session-start.mjs # SessionStart
│ ├── session-end.mjs # SessionEnd
│ ├── pre-compact.mjs # PreCompact
│ ├── subagent-start.mjs # SubagentStart
│ ├── subagent-stop.mjs # SubagentStop
│ ├── debug-recall.mjs # standalone diagnostic for recall
│ ├── debug-capture.mjs # standalone diagnostic for capture
│ ├── ov-status.mjs # /ov status report
│ ├── ov-memory-doctor.mjs # diagnostics script (ov-memory-doctor skill)
│ └── lib/
│ ├── ov-session.mjs # OV HTTP client + session helpers + bypass check
│ └── async-writer.mjs # detached-worker helper for write-path hooks
├── .mcp.json # MCP server config (local stdio proxy)
├── package.json # type:module marker only — no runtime deps
└── README.md
License
Apache-2.0 — same as OpenViking.