64 Commits
Author SHA1 Message Date
t0saki 3e02c6ac29 fix(dsh-plugin): drop the plugin group from the bundle patch (#5390)
DSH's plugin manager lists every bundle row, group rows included, but
resolves row state and toggles through the running-plugin inventory,
which skips group entries. The outer `@deepseek-ai/cordis-plugin-group`
row therefore always showed as disabled, and enabling it failed with
`unknown-plugin`.

The group only isolated the `openvikingMemory` service, which nothing
consumes, so the bundle now inserts the runtime row directly under the
same row id. Desktop toggles and id-targeted overrides keep applying,
and the docs show the id-targeted override form, which also applies to
the old nested layout.
2026-09-25 16:13:46 +08:00
z1gon 4edc30b068 fix(plugins): filter hook capture before truncation (#5375)
* fix(capture): filter hook-host turns before truncation

(cherry picked from commit 6b05eb47a529eb49b53f4a2bdf9ee574453ad2cf)

* chore(plugins): bump versions for shared capture change
2026-09-24 23:39:57 +08:00
z1gonandhemingzhe 92dcf00f83 fix(plugins): share capture filtering across adapters (#5359)
* fix(pi): honor conversation capture filters in faithful mode

Apply the shared turn filter before capture decisions so configured rules cannot be bypassed by takeover or attached tool parts, while preserving tool values and behavior without applicable rules.

(cherry picked from commit e8fb6c9476)
Signed-off-by: Hao Zhe <haozhe4547@gmail.com>

* fix(opencode): wire captureFilters through config and the capture path

captureFilters (sed-style redaction rules, e.g. s/sk-.../[REDACTED-SK]/g)
was defined in the shared capture-utils library but never reached the
OpenCode capture path: lib/config.mjs had no mapping for the key and
memory-session's buildCapturePayload applied no filtering, so secrets
typed into OpenCode sessions were captured verbatim. The Codex plugin
applies the identical library correctly (issue #4984).

- map captureFilters in config (file key, OPENVIKING_CAPTURE_FILTERS env
  override as JSON, non-string entries dropped)
- apply filterCaptureParts() to extracted parts in buildCapturePayload,
  mirroring the Codex reference flow (role-level drops skip the turn;
  an empty filtered result gates capture)
- cover config mapping, env override and validation in tests

(cherry picked from commit b5bd21930d)
Signed-off-by: Hao Zhe <haozhe4547@gmail.com>

* refactor(capture): share sanitized filter path across adapters

* test(capture): preserve mixed tool messages and cover OpenCode v2

* fix(capture): cap mixed text and preserve dated logs

* fix(capture): use shared shaping in Claude Code hooks

* fix(capture): sanitize fallback text once

---------

Signed-off-by: Hao Zhe <haozhe4547@gmail.com>
Co-authored-by: hemingzhe <hehesmilett@163.com>
2026-09-24 22:46:20 +08:00
t0sakiandTrent Telfer 12076389da feat(opencode-plugin): support OpenCode v2 (#5341)
* feat(opencode-plugin): support the OpenCode v2 plugin API

OpenCode 2 does not run v1 hook plugins. Keep the existing server()
entrypoint and add setup() so one package serves both, adapting MCP,
recall, capture, and lifecycle events to the v2 shapes.

Refs #5226

* fix(opencode-plugin): align v2 lifecycle handling

* docs(opencode-plugin): document v2 support

* fix(opencode-plugin): preserve capture across v2 compaction

* fix(opencode-plugin): drop reasoning and keep state on failed v2 executions

v2 capture turned reasoning blocks into assistant text, so chain-of-thought
reached memory extraction and inflated pending tokens. v1 parts and the shared
capture filter drop reasoning; v2 now does the same.

A failed v2 execution ends one turn, not the session. Mapping it to v1's
session.error committed and deleted the session state, so a later capture
without a cursor resent every earlier turn. Failed executions now take the
same idle path as succeeded and interrupted ones.

* fix(opencode-plugin): scope v2 capture to the plugin location and persist its cursor

One OpenCode v2 service runs a plugin instance per location, and the plugin
event stream carries every location's events. Each instance therefore
captured and committed every session, storing each message once per open
project. Handle only lifecycle events whose session belongs to this
location, resolved from session.created or ctx.session.get because execution
events carry no envelope location.

The capture cursor lived only in memory, and the shared session state file
is rewritten by every instance, so a reloaded or evicted instance could
resend the transcript since the last compaction. Keep the cursor in plugin
storage and remove it when the session is deleted.

* docs(opencode-plugin): describe v2 commit points and location scoping

---------

Co-authored-by: Trent Telfer <4094016+ttelfer@users.noreply.github.com>
2026-09-24 15:35:07 +08:00
Zayn Jarvisandsomewhere1994 20a97022d5 fix(agent-hook): stamp the effective peer into captured messages (#5343)
Cursor, TRAE, ZCode and Kimi Code capture through addAgentMessages and sent
their peer only as the X-OpenViking-Actor-Peer header. Session routes never
read that header (they use get_session_request_context), so messages landed
without a peer and their memories went to the user-level layer instead of
peers/<peer>. Claude Code and Codex already put peer_id in the body.

addAgentMessages now takes the peer and stamps it on payloads that do not
name one; the hook passes the same effective peer the header carries. When
peer mode is off the peer is empty and nothing is stamped.

Co-authored-by: somewhere1994 <108641179+somewhere1994@users.noreply.github.com>
2026-09-24 11:42:23 +08:00
ydflowandydflow de1c5c4954 fix(dsh-plugin): pass recallExcludeUris so subtrees can be excluded from recall (#5312)
`recall-core.mjs` reads `options.excludeUris` and forwards it as the search
request's `exclude_uris`, but the DSH runtime built its recall options without
that key, so no configuration could stop a subtree from being recalled. The
generated per-directory context files (`viking://user/<space>/skills`,
`viking://user/<space>/resources`, `viking://agent/skills`) came back as ordinary
hits and carry only boilerplate text — on a vague prompt, 3 of 7 returned entries
were these files. The only remedy was deleting the data.

Add a `recallExcludeUris` list knob to the shared config schema and pass
`cfg.recallExcludeUris` through as `excludeUris` from the DSH recall call, which
is the single place that builds those options. The schema entry lands in
`memory-plugin-shared/lib` and is propagated to the claude-code and codex copies
by `sync.mjs`; those two plugins are marked `committed: true` there because a
host installs them from a directory in this repository, so their vendored copies
belong in git. `recall-core.mjs` already caps the forwarded list at 200 entries
and omits the field entirely when the list is empty, so the default behaviour and
the request body are unchanged.

Validation, from `examples/dsh-memory-plugin` after
`node ../memory-plugin-shared/sync.mjs` and `npm install`:
`node --test *.test.mjs` 77 tests, 76 passed, 0 failed, 1 skipped. The new
`recallExcludeUris reaches the search request` failed before the change with
`exclude_uris` undefined in the request body and passes after it; the companion
case asserts no `exclude_uris` field is sent when the knob is unset.
`node --check` passes on all three changed source files.

`examples/claude-code-memory-plugin` fails 6 tests in
`scripts/auto-capture.test.mjs` on this Windows machine. Those tests spawn a real
subprocess that talks to a mock server on 127.0.0.1; they fail identically with
this change stashed and with a pristine checkout, so they are pre-existing and
environmental rather than caused by this change.

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-09-23 22:56:07 +08:00
wangyuandwangyu134 14a7b8126e fix(plugins): find the installer runtime in the fetched checkout (#5280)
ensure_checkout's SRC_ROOT assignment dies with the command substitution that
calls it, so install_lib_dir never sees the checkout under REPO_DIR. OpenCode
is the only host that reads the installer's JavaScript from there, so it alone
failed with "Installer runtime not found"; REPO_DIR is now a candidate.

Co-authored-by: wangyu134 <wangyu134@58.com>
2026-09-23 22:47:57 +08:00
梧桐雨andzhengxiao.wu 03391bae43 feat(plugins): add Kimi Code CLI memory plugin (#4787)
* feat(plugins): add Kimi Code memory integration

* fix(plugins): read Kimi UserPromptSubmit prompt field

Kimi Code passes the submitted prompt as `prompt` (camelToSnake of
inputData { prompt, isSteer }), not `input`, so recall and first-prompt
profile injection never ran. Align tests and DESIGN.md, and point the
host references at the MoonshotAI/kimi-code sources.

---------

Co-authored-by: zhengxiao.wu <zhengxiao.wu@bytedance.com>
2026-09-23 14:37:39 +08:00
t0sakiandTRAE CLI bbf2e37f88 feat(pi): mirror the server's MCP tool surface (#5272)
* feat(pi): mirror the server's MCP tool surface

The pi extension defined seven viking_* tools over its REST client and had
drifted from the MCP harnesses: no grep, glob, tree, write, edit, no watch
tools, no health, and no context mode on search. pi has no MCP client, so
the extension now drives the shared stdio->HTTP proxy core in process --
never calling its start(), just handleMessage over an injected sink -- and
republishes every descriptor from the server's tools/list as a native pi
tool named openviking_<tool>. Nothing in the extension enumerates tools, so
a server that gains or drops one changes pi's surface at the next session
with no plugin release. No new dependency and no subprocess.

viking_* is removed outright with no alias period. The server's usage
attribution already recognises openviking_* but never recognised viking_*,
so pi's retrieval and reads now count toward Experience usage and lineage.

A failed handshake does not fail startup: recall, session capture and
takeover keep working, the status line and /viking report the failure, and
a later turn retries, so a server started after pi is picked up without a
restart. The shared mcpEnabled key now applies to pi as well.

pi validates tool arguments locally, which the other harnesses do not, so a
small schema-driven repair pass runs before validation. It only erases that
extra local strictness -- dropping explicit nulls on optional fields,
wrapping a scalar where the schema wants an array, and parsing a JSON
string the way FastMCP's pre_parse_json does -- and deliberately leaves
everything the server itself would reject.

Recall, session sync, profile injection and takeover stay on REST. The
experimental context-management fork keeps its own tools; only its
coexistence probe learns the new name, plus a globalThis marker for the
case where a 403 leaves the stable extension with no tools but a live
session.

* refactor(pi): replace custom MCP bridge with official client

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* test(pi): cover SDK calls, reload cleanup and dependency installs

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(pi): align MCP architecture descriptions

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-22 18:20:02 +08:00
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
Zayn Jarvis e44ea6e11a fix(plugins): honor cloud recall compression across harnesses (#5240)
* fix(plugins): honor cloud recall compression across harnesses

* chore(plugins): bump versions for cloud recall support

* fix(dsh): align runtime and package versions

* test(plugins): derive installed version from the manifest

* refactor(plugins): route Codex recall through the shared pipeline
2026-09-21 15:43:04 +08:00
t0saki 9b0ce3dea5 fix(doctor): read /health with credentials where the gateway gates it (#5088)
The doctor probes /health once without credentials on purpose: it reports
version and auth_mode before judging the key. OpenViking Cloud authenticates
/health at the gateway, so that probe answers 401 and the report stopped
right there with "the server answered but not like OpenViking" — discarding
the system/status, fs/ls and /mcp probes it had already taken, and skipping
/ready — while the configured key was valid all along.

When the credential-less /health answers 401/403, fall back to the
authenticated probe for version, auth_mode and identity and keep the ladder
going; send credentials on /ready as well. A key the gateway really rejects
is now reported as a rejected key instead of a wrong url.
2026-09-21 15:19:50 +08:00
t0saki 3c6e3d456b feat(agent-plugins): ship the ov-experience-memory skill (#5172)
Sync examples/skills/ov-experience-memory into agent-plugins/skills so
Agent Plugins clients can retrieve and apply Experience through the find,
search, and read MCP tools. The package has no hooks and no session
capture, so the copy is retrieval-only; the README and the Agent Plugins
integration docs say so.
2026-09-18 19:14:08 +08:00
t0saki 0ec8d25996 fix(plugins): resolve one connection for every plugin's hooks and MCP proxy (#5132)
* fix(codex): read the MCP proxy's connection from the hooks' loadConfig

The proxy resolved url, api_key, account and user through the bare
credential chain while taking everything else from loadConfig(). Since
the loader became buildPluginConfig() it adds layers that chain never
sees: ovcli.conf's plugin.codex apiKey/accountId/userId, and ov.conf's
codex.apiKey when ovcli.conf names only the server. With either, hooks
authenticated and every MCP tool call went out without a key.

Codex also hands a stdio MCP server only the env vars .mcp.json lists,
and OPENVIKING_AUTH_MODE was not one of them, so an env-set auth mode
decided the identity headers for hooks but not for MCP calls.

* fix(dsh): forward the resolved auth mode and timeout to the MCP proxy

The proxy runs as a child whose env DSH scrubs, so the parent forwards
what it resolved. It forwarded the endpoint, key, account, user and
peer but not the auth mode or request timeout, so a Cordis patch that
set either configured the in-process runtime and not the MCP calls.

The proxy now also takes its credential source and watched paths from
the resolved config instead of a second credential-chain call, and a
shared test keeps every proxy that ships beside hooks off that chain.

* refactor(shared): one connection resolver for hooks and the MCP proxy

The credential chain lived in two layers. `resolveOpenVikingCredentials()`
could not read ovcli.conf's `plugin.<harness>` keys, the ov.conf harness
fallback or the root-key tail; `buildPluginConfig()` patched those in, and
any caller that used the lower layer alone resolved a different key and
identity than the hooks did.

`resolveConnection(harness, { env, files, hostInput, rootKeyFallback })`
now answers server, key, identity and auth mode in one place, reading only
host input, the environment and the two ~/.openviking files. The hook
loader and `buildProxyConnection()` both consume it, and the old
two-layer entry points (`resolveOpenVikingCredentials`, `resolveAuthMode`,
the credentials.mjs CLI) are gone so a half-resolved chain cannot be
written again.

Behaviour is unchanged for every hook harness (checked field by field
against the previous implementation over thousands of generated file/env
combinations). Two deliberate additions: the portable agent-plugins proxy
now honours ovcli.conf's `plugin` connection keys and ov.conf's harness
section like every other harness, and dsh hands the host's `authMode`
(or `auth_mode`) over as host input, ranking it with the host's endpoint,
key and identity.

* fix(shared): a forced env credential source reads only the environment

`OPENVIKING_CREDENTIAL_SOURCE=env` is documented as "env vars only", but
only the url honoured it: the key, account, user, ovcli.conf's actor peer
and the auth mode still fell through to ovcli.conf, its plugin keys,
ov.conf and the root key when the variable was unset. A process that
exported an empty key to mean "no key" was silently handed whatever the
files held.

Forced to `env`, the connection now reads no file and an unset variable
stays empty; the url defaults to http://127.0.0.1:1933. The `peerId`
setting keeps its own layers. The doctor labels that mode instead of
pointing at files the chain skipped.

* refactor(shared): one proxy-config mapper and one forwarded-env list

Each proxy entrypoint copied a dozen fields out of its loader by hand,
under two sets of names, and the copies had drifted. What the proxy
process must be handed was a second hand-kept list, in Codex's
`.mcp.json` and in its test.

`toMcpProxyConfig(cfg, options)` maps a resolved loader or proxy
connection to the proxy config once. `MCP_PROXY_ENV_VARS` names every
variable that changes what a proxy sends; Codex's `env_vars` is now
checked against it, which adds the missing `OPENVIKING_STATE_DIR`.

* refactor(plugins): every loader takes an env, every proxy exports readProxyConfig(env)

The six MCP proxy entrypoints now reduce to one line: resolve through the
harness's own loader (or `buildProxyConnection` for the hook-less package)
and hand the result to `toMcpProxyConfig`. Every one exports
`readProxyConfig(env)`, and the codex, claude-code, opencode and agent-hook
loaders accept an injected env, so a test can drive a hook and its proxy
from the same inputs without touching process.env.

Mapping through one function fixes what the hand copies had lost: the
Claude Code and DSH proxies never passed `mcpUrl`, so
`OPENVIKING_MCP_URL` moved the hooks and left the tools behind.

The source guard now requires the shared mapper and the exported reader
in every proxy, and `buildProxyConnection` reports its two config paths
instead of a watch list of its own.

* fix(dsh): forward the resolved connection to the MCP proxy

DSH starts its MCP subprocess with the parent's environment minus
credential-shaped names (`/KEY|PASSWORD|SECRET|TOKEN/i`), so the bundle
forwards what it resolved. It forwarded the values but not the mode, and
only the non-empty ones:

- A child that receives `OPENVIKING_URL` runs the chain unpinned. Where
  the parent's chain was pinned to an ovcli.conf that names only a url,
  the parent sent no key while the proxy fell through to ov.conf's
  `server.root_api_key`, so the tools reached the server as root while
  the hooks were anonymous.
- `OPENVIKING_ACCOUNT`, `OPENVIKING_USER` and `OPENVIKING_PEER_ID` survive
  DSH's scrub, so a value the parent's chain ignored filled the gap in
  the child and went out as an identity header.
- With no peer to forward, the proxy derived one from its own launch
  directory and sent an actor peer the runtime did not.

`forwardConnectionEnv(connection)` now writes every credential variable,
the empty ones too, with the forced `env` source, so the child reads no
file and resolves exactly the parent's url, MCP url, key, identity, auth
mode and peer. The proxy takes its peer from that environment only.
`buildMcpConfig` moves to `mcp-env.mjs`, which carries no host
dependency, so shared tests can build the child environment without the
DSH bridge.

* test(shared): prove the proxy and the hooks resolve one connection

The existing guards checked shape — that a proxy called the shared
builder — never that it reached the server as the same caller its hooks
did, which is how two harnesses shipped proxies that disagreed with them.

`mcp-hook-parity.test.mjs` runs every harness that ships a proxy beside
hooks through a dozen configurations: ovcli.conf's own fields, its
`plugin.<harness>` and shared plugin keys, ov.conf-only installs, the
pinned fallbacks to a harness key and to the root key, credential and
auth-mode variables, a forced source over stale variables, an explicit
MCP URL, a host's own input, and a workspace file that tries to move the
connection. The hook loader sees the full environment; the proxy sees
only what its host lets through — Codex's `env_vars`, DSH's scrubbed
inheritance plus the forwarded connection, everyone else's full
environment — and the url, key, identity and identity-header switch they
put on the wire must match. Scenarios with a known answer pin it too, and
a coverage check fails when a new proxy or hook client has no row.

The two codex-only proxy tests the matrix now covers are removed.

* docs(plugins): one connection for hooks and MCP, and version bumps

The capability reference, plugin development guide, Agent Plugins and
Codex pages (en/zh), both doctor references and the plugin READMEs now
describe the chain `resolveConnection()` runs: host input first, the
pinned ovcli.conf branch and what still falls through it, the auth mode
reading `OPENVIKING_AUTH_MODE` and the `plugin` keys in every mode, a
forced `env` source reading no file, and the two ways a connection crosses
into an MCP process (Codex's forwarded-variable list, dsh's forwarded
connection). The parity test is registered with the credential tests.

Versions move past both this branch's base and main: claude-code 0.5.2,
codex 0.9.2, agent-hook 0.3.2, opencode 0.3.2, dsh 0.4.3, pi 0.3.2,
agent-plugins 0.1.2.
2026-09-17 19:38:18 +08:00
t0saki 6fb370cfba fix(memory-plugin): run shell commands that carry viking:// and attach a notice (#5131)
* fix(memory-plugin): split the viking:// URI guard into deny and notice

A shell command that carries a viking:// URI is not necessarily trying to
open it: ov CLI arguments, HTTP payloads and grep patterns all mention one.
The guard used to deny every such command, and models learned to split the
URI to get past it.

evaluateUriGuard now denies only file tools whose path is a viking:// URI.
evaluateUriNotice returns a notice for shell tools instead, naming the
plugin, the replacement tool and telling the model to ignore it when the URI
is intentional. preToolUseOutput wraps both for the PreToolUse hosts.

* fix(memory-plugin): stop treating grep's pattern as a path

pattern was one of the path keys, so Grep(pattern="viking://", path="/repo")
was denied on every host that guards grep, although it only searches local
files for the text. It stays a location for glob, which the generic sweep
still reaches.

* fix(dsh): run shell commands that carry viking:// and attach a notice

bash is no longer denied by tools/pre-execute. A tools/post-execute listener
delegates to the rest of the chain first, then appends a plugin context with
form "notice" when the command carried a viking:// URI, so a later listener's
block or content replacement survives. pluginMessage moves to capture.mjs and
takes the whole source, since a notice needs a summary as well as a form.

* fix(pi): notice on tool_result instead of blocking bash

tool_call now denies only read/grep/find/ls on a viking:// path. A bash
command that carries a URI runs, and tool_result appends the notice after the
result's own content blocks. The shared plugin-config test reads pi's version
from its package.json, like the other harnesses, instead of a literal.

* fix(agent-hook-plugin): trae notices shell commands, cursor stops guarding the shell

TRAE and ZCode use the shared preToolUseOutput, so Bash and RunCommand on
TRAE get additionalContext instead of a deny. ZCode's matcher still names no
shell tool, because its strict output schema is not verified to accept that
envelope.

Cursor has no channel that shows the model a note after a shell command, so
beforeShellExecution is dropped. The installer prunes an entry an older
install left behind, and the guard ignores a shell event that still arrives.

* feat(opencode): notice on tool.execute.after

bash had no guard on opencode. A command that carries a viking:// URI now
gets the notice appended to its output; read/glob/grep keep their deny in
tool.execute.before.

* feat(claude-code): guard Edit/Write and notice on Bash

The PreToolUse matcher grows from Read|Glob|Grep to
Read|Glob|Grep|Edit|Write|Bash. Edit and Write on a viking:// path are denied
like the read tools, and a Bash command that carries one gets
additionalContext. The script is now just preToolUseOutput, so the shared
library drops the guarded option that only this script used.

* feat(codex): add the PreToolUse URI guard

Codex gets the same uri-guard script as claude-code on a Bash matcher. Its
Edit and Write matchers are aliases for apply_patch, whose input is a patch
body with no path to deny, so the hook only ever adds a notice. The doctor
expects the sixth hook trust record, and users approve it once in /hooks.

* docs: capability reference rows for the deny/notice guard
2026-09-17 17:25:20 +08:00
Evoandr266-tech c809fec2be fix(dsh): avoid watching bundled skills during plugin upgrades (#5074)
* fix(dsh): avoid watching bundled skills during plugin upgrades

* test(dsh): read current manifest version in shared config checks

---------

Co-authored-by: r266-tech <r266-tech@users.noreply.github.com>
2026-09-16 14:40:48 +08:00
yorickandTRAE CLI 87ae94031b feat(memory-plugin): support OPENVIKING_EXTRA_HEADERS for private gateways (#5065)
* feat(memory-plugin): support OPENVIKING_EXTRA_HEADERS for private gateways

Some private OpenViking deployments sit behind gateways that require
custom tenant/vault headers on every request (e.g. `openviking_name`).
The stdio MCP proxy hard-coded its header set, so those deployments
returned HTTP 400 before the initialize handshake could run.

- Parse OPENVIKING_EXTRA_HEADERS (JSON object of scalars) into
  proxyConfig.extraHeaders via buildMcpProxyConfig.
- Merge extras first in headersForRequest so proxy-owned headers
  (Authorization, Mcp-Session-Id, MCP-Protocol-Version, identity)
  always win on the wire.
- Drop reserved header names at parse time with a stderr warning.
- Whitelist the env var in codex-memory-plugin/.mcp.json and document
  the escape hatch in its README.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(codex): remove tenant-shaped example values from OPENVIKING_EXTRA_HEADERS

Reviewer feedback: the `openviking_name`, vault id and region examples
read like internal-deployment specifics. Replace them with generic
`<header-name>/<header-value>` placeholders and refer operators to their
gateway docs for the actual names.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-15 22:07:51 +08:00
t0saki 31f017bc16 fix(openclaw): commit the generated shared runtime copies again (#5062)
#4773 moved examples/openclaw-plugin/shared/ out of git on the grounds that
the plugin ships as a package and builds its copies at pack time. That holds
for ClawHub and npm, but not for every way the plugin is installed or built:

- ov-install's GitHub source downloads the plugin file by file from a git
  ref. install-manifest.json lists shared/ as required, the GitHub contents
  API answers 404 for it on main, and the helper exits after three retries.
  It is reached with --plugin-version=main/master/v*, --github-repo,
  PLUGIN_SOURCE=github, and whenever npm resolution fails and the helper
  falls back to GitHub. The helper cannot run sync.mjs on that path, and a
  helper-side fix would not reach the copies already installed globally.
- build.sh, bot/scripts/install_local_openclaw_plugin.sh and the guide's
  `npm run typecheck` / `npm run build` steps never run sync.mjs, so on a
  fresh clone tsc cannot resolve ./shared/recall-core.mjs.

The closure is two modules (three files with the declaration), so mark the
target committed, drop its .gitignore entry, commit the copies, and let the
shared-sync workflow regenerate them on main alongside the other committed
lanes. The capability reference and plugin-development guide now list
OpenClaw with the committed targets.
2026-09-15 20:54:51 +08:00
t0sakiandTRAE CLI 65b009c578 fix(plugins): stop the pi installer loading the extension twice (#5059)
The installer copied the extension into ~/.pi/agent/extensions/openviking
and then ran `pi install` on the same path. That directory is already one
of pi's auto-discovery roots, so pi found the extension twice: auto-discovery
registered its index.ts while the `pi install` packages entry pointed at the
directory. pi dedupes on the canonical path, and a directory does not match
its own index.ts, so both survived and the extension loaded twice — a
duplicate `/viking` command and a doubled Extensions listing.

Drop the `pi install` call and instead `pi remove` the path to purge any
stale packages entry left by older installer versions (pi remove on a local
path only edits settings, it never deletes the copied files). Flip the
validation check so a lingering packages entry is reported as a warning
rather than success, and update the READMEs and pi docs (zh/en) accordingly.

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-15 19:06:49 +08:00
t0sakiandTRAE CLI aa3ee2f431 refactor(plugins): resolve every harness's knobs from one declaration, and generate the packaged copies at pack time (#4773)
* fix(codex): stop dropping turns on an outage, and honour bypass patterns

Two capabilities every other JS harness has were wired in the shared library
but never reached codex.

Offline queue. `catchUpTurns` called `sendSessionMessages` without
`enqueueOnRetryable`, so a 5xx or a connection failure returned a count and the
turns were gone: codex hooks are short-lived subprocesses with no retry of
their own, and the `capturedTurnCount` cursor only compensates while the
process survives long enough for another Stop. The queue module was vendored
into `scripts/shared/` all along with no caller, and nothing ever replayed it.
The send now enqueues, and SessionStart drains the queue after its health check
— the one codex hook that runs against a known-healthy server.

Bypass. `isBypassed` had no codex caller, so `bypassSessionPatterns` did
nothing there: a directory a user had excluded still had its turns captured and
still got memories injected, and the `bypass.session_patterns` key a repository
commits in `.openviking/config.json` was silently inert for every codex user in
that repository. All five hooks now consult the shared matcher, and the loader
reads the same keys and the same `OPENVIKING_BYPASS_SESSION[_PATTERNS]` env
vars as claude-code.

SessionStart is deliberately only half-suppressed under bypass: it skips peer
registration and injection, but still replays the queue and still sweeps. Both
finish work recorded by sessions that were not bypassed, and this hook is the
only place codex runs either, so bailing out would strand that data for as long
as the user kept working in a bypassed repository.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(pi): use the shared bypass matcher, and keep the pre-git peer reachable

`config.ts` kept only `.peerId` out of `resolveEffectivePeerId()` and dropped
`legacyPeerId`, so under `recallPeerScope: "actor"` — where the effective peer
is the only one asked — every memory written before the git-derived peer
replaced the path-derived one became unreachable. The shared recall builder has
read `options.legacyPeerId` for the dual read all along; pi just never passed
it.

Bypass was a hand-written `matchBypass()` under this extension's own
`bypassPatterns` key: it understood a leading or trailing `*` and nothing else,
so `**/scratch/**` did not work here while it did everywhere else. It now calls
the shared `isBypassed`, reads `bypassSessionPatterns` like every other
harness, and honours `OPENVIKING_BYPASS_SESSION[_PATTERNS]`. `bypassPatterns`
still reads.

That does change one behaviour for existing setups: a bare path used to match
its subdirectories as a prefix, and a glob does not. The README says to write
`"/tmp/scratch**"` where `"/tmp/scratch"` used to be enough.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* chore(plugins): remove the deprecated standalone TRAE CLI integration

`examples/trae-cli-memory-hooks` was added on 2026-08-17 (#4026) for TraeCode
CLI 1.0 and deprecated the next day by #4079, which routed `--harness trae-cli`
to the codex plugin alias instead. Since then `install_trae_cli` has not been in
the installer's dispatch block — `normalize_trae_cli_harness` rewrites the
harness to `codex` before it could run — so the directory, its 588 lines and its
CI test have been dead weight that the marketplace archive still shipped to
every user.

The uninstall path stays: `remove_legacy_trae_cli_integration` and
`agent_remove_trae_cli_configs` delete what an old install left on disk and
never read this directory, which is what the retention test in
`release-marketplace.test.mjs` claimed to protect. `install-agent-hooks.test.mjs`
covers that path and still passes.

Also drops the write half that only `install_trae_cli` called.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): make a changed plugin prove it shipped

Claude Code resolves an installed plugin by the `version` string in its
manifest, not by the commit the marketplace ref points at. That string has read
0.4.5 since #4389 on 2026-08-27, with seven commits to the plugin and the shared
library behind it — so nobody running the marketplace build has received any of
them. Codex's manifest has been frozen at 0.8.1 for ten. Every vendored-copy
diff those commits paid for went to a payload no user received.

Both versions move, and a new PR check fails when files under a
marketplace-distributed plugin change without its version string moving with
them. The generated `shared/` copies live inside each plugin directory, so a
shared-library change reaches the check through them.

Also removes `claude-code-memory-plugin/package-lock.json`: 1174 lines locking
95 packages for a private manifest that declares no dependencies, still pinned
at 0.4.4.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* docs(agent-integrations): record what codex, pi and trae-cli now do

The capability matrices said codex has no on-disk queue and counted four hooks
where hooks.json declares five; both are now wrong. pi's bypass is no longer a
prefix match under its own key. And the removed TRAE CLI 1.0 integration is
described by what remains of it: the installer's uninstall path.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* refactor(plugins): derive each plugin's vendored set from what it imports

sync.mjs kept hand-written capability groups, and the groups got spread into
each other: the file's own header states the rule it could not enforce ("what a
plugin ships must equal what it imports"). Both directions had failed in
practice — a module a plugin imported but no list named became
ERR_MODULE_NOT_FOUND on the first hook of a fresh install, and modules nobody
imported sat vendored in plugin trees, landing in every review diff forever.

The lists are gone. Each target now says only where its code lives and where
its copies go; the file set is the transitive closure of the imports that code
actually resolves into the vendored directory — including re-export shims like
claude-code's scripts/lib/, which are the sole importer of several modules. The
sync reports a copy nothing imports and a lib module no plugin reaches, and
sync.test.mjs asserts what is on disk equals that closure in both directions.
The run is byte-identical for everything still shipped, and it named the two
copies nobody had noticed: claude-code's capture-utils (515 lines, its
auto-capture hand-rolls the same filter) and codex's uri-guard (78 lines, codex
has no PreToolUse hook).

ZCode stops vendoring altogether. Its only install path is
`assemble_agent_integration`, the same one cursor and trae use, which already
placed the canonical runtime beside it — it just imported its own committed
copy instead. Pointing its nine imports at `../../memory-plugin-shared/lib/`,
the path that resolves both in the repository and under
`$OV_HOME/agent-integrations/`, drops 4294 generated lines and leaves cursor,
trae and zcode reading one runtime. The installer's assembly list grows by the
three modules zcode needs, derived by the same closure code so it cannot drift
either, and the end-to-end archive install test covers it.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): run cursor and trae capture through the shared filter

zcode got this in #4594; cursor and trae never did. Both sent whatever their
transcript parser produced straight to the extractor: `/compact`, a bare "ok",
a punctuation-only turn and a `[openviking-memory]` status line all became
memories, and a turn past `captureMaxLength` went out uncapped. Every other
harness has run `shouldCaptureText` on the way in for months.

The decision now lives beside the runtime the thin harnesses compose, as
`filterCaptureTurns`, so a harness gets it by calling rather than by carrying
its own copy — and the installer already assembles `capture-utils.mjs` for
these three since they share one runtime.

Both hooks keep hashing the raw turn rather than the filtered text, so raising
captureMaxLength never resends a turn the server already holds truncated, and a
dropped turn is recorded as handled so a re-read of the same transcript does not
re-evaluate it every run.

The TRAE hook test used `last_assistant_message: "done"` — an acknowledgement
the filter drops, and correctly so. Its fixture now carries a turn worth
remembering.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): bump every plugin the shared change reached, and pair the manifests

The version check added earlier in this branch caught what a human review would
not have: `filterCaptureTurns` landed in the shared library, its generated copy
changed inside opencode and dsh, and neither manifest moved — so both hosts
would have kept installing the build without it. cursor, trae and pi move for
the same reason.

It also caught a mismatch this branch introduced: cursor carries the version in
both `.cursor-plugin/plugin.json` and `openviking.integration.json`, the host
installs by the first and the installer decides "nothing changed" by the second,
and bumping only one makes a plugin report upgraded while behaving as it did.
zcode had been sitting at 0.1.1 against 0.1.2 on main for the same reason. The
check now compares the pair, so neither can drift again.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* refactor(plugins): resolve every harness's knob from one declaration

A setting only stayed uniform across the harnesses when a shared module read
it on the hot path. `recallLimit`, `scoreThreshold` and `captureMaxLength`
agree everywhere because `recall-core` and `capture-utils` read them; the
switches, which nothing shared read, drifted into four spellings — a boolean
`autoRecall`, an `autoRecall` object in opencode, `syncTurns` in dsh and pi,
and no recall switch at all in the thin hook runtime. Three more inventories
had to be kept in step by hand: the doctor's known-knob set, the workspace
file's dotted-key map, and the implicit list inside `loadAgentHookConfig`.

`lib/config-schema.mjs` is now the one declaration, and the rest are
projections of it. `resolveSettings()` resolves any harness through the layers
the shared README has documented since the `plugin` section landed — env, the
workspace file and registry, `ovcli.conf` `plugin.<harness>`, `ovcli.conf`
`plugin`, ov.conf's harness section, defaults — which only Claude Code and
Codex implemented. cursor, trae, trae-cn and zcode read the environment and
nothing else before this, so a `plugin` entry named after them was inert and
`ov config switch` moved their credentials while leaving their behaviour
behind.

The two per-plugin config files are gone with it. pi's `config.json` shipped
with the extension holding exactly the code defaults, so nothing could tell an
operator's choice from the factory setting; opencode searched four paths for
`openviking-config.json`. Both harnesses read `ovcli.conf` now, as the others
do. The `.d.mts` stubs move to lib/ beside the modules they describe — pi and
openclaw each carried a different, both incomplete, declaration of
`recall-core` — and sync ships them to the targets written in TypeScript.

The switches move into the capability modules that own them:
`isRecallEnabled` and `isCaptureEnabled` accept every spelling and treat any
of them saying off as off, which is the one mechanism in this repository that
has actually held a name still.

* build(plugins): generate the packaged plugins' shared copies at pack time

`sync.mjs` had zero references under `.github/`: the generator ran on whoever
last remembered, and CI only checked the result byte for byte. So every change
to a 6,000-line library arrived as a 20,000-line diff, and the copies were
committed for plugins that never needed them committed.

The split is delivery, not taste. A host that installs Claude Code, Codex or
the Agent Plugins package points at a directory in this repository and can only
load what git holds, so those copies stay committed — and a push to main
regenerates them, which is what stops one going stale behind a merged pull
request. opencode, dsh and openclaw publish as npm packages and pi is tarred by
the installer, so those four build their copies on the way out: a `prepack`
script for the packages, the marketplace staging script for the archive the
installer reads, and `sync.mjs` in a source checkout for a direct `install.sh`
run. `npm pack` was verified to regenerate a deleted directory and ship the
same file set.

Two things had been holding those directories in place. Their release triggers
matched only `examples/<plugin>/**`, so a shared fix reached npm through the
vendored copy changing — the triggers name the library now. The version gate
found a changed plugin the same way, so it treats a change under
`memory-plugin-shared/lib` as a change to every plugin, and gains cursor and
trae, which are distributed the same way and were never gated. pi's own
`recall-ledger` moves to `lib/`, where the extension's other own modules live,
so the generated directory holds nothing but generated files.

* test(plugins): pin the layer order for the harnesses that had no config test

cursor, trae, trae-cn and zcode compose one shared loader and none of them owns
a config test, so nothing would have caught the `plugin` section going inert
again. This asserts the whole stack through that loader — the shared block, a
per-harness override under either spelling, the workspace file over ovcli.conf,
and the environment over all of it — and pins that a default never reports as
configured, since several fields reach the server only when the user asked for
them.

* docs(plugins): point opencode and pi at ovcli.conf, and say when copies are made

Both plugins documented a configuration file that no longer exists, down to the
four paths opencode searched for it and the nested blocks neither loader reads
any more. Their knobs live in `ovcli.conf`'s `plugin` section now, so the
examples are ovcli.conf examples, the flattened names are the ones the schema
declares, and each document says the resolution order and links to the one file
that declares every knob.

Two claims that had gone the other way: opencode and pi read the workspace
`.openviking/config.json` now, so a `peer.id` written there does apply, and the
shared README says which plugins keep their generated copies in git and which
build them at pack time, because a fresh checkout has to run the generator once
before their tests will resolve an import.

* fix(opencode): name the file the 401 hint should send the user to

* fix(plugins): let the canonical knob name win over its own alias

A file that carries both spellings — what a rename leaves behind — resolved to
whichever the layer loop reached last, which was the older name.

* fix(plugins): write each generated copy through a rename

The marketplace staging script runs the generator now, so it can rewrite a
vendored module while a test is byte-comparing it. A partial file read that way
fails as a drifted copy, which is a confusing way to say nothing drifted.

* fix(dsh,pi): honour the recall switch these two never read

Both gained a capture switch when `syncTurns` became an alias, but their recall
paths still retrieved on every prompt: nothing in either called the switch, so
`autoRecall: false` — settable from ovcli.conf, a workspace file or the
environment — was accepted everywhere and obeyed nowhere.

* docs(agent-integrations): say which harnesses read which configuration layer

The capability reference had cursor, trae, trae-cn and zcode down as
environment-variable-only, opencode reading a four-level `openviking-config.json`
search and pi its own `config.json`, and the workspace files and `plugin`
section scoped to Claude Code and Codex. All nine resolve through the same
loader now, so the tables, the quick-scope list and the profile cards say so,
and the module table gains the schema the layers project from.

The distribution paragraph was counting modules against a `HOOK_SHARED_FILES`
list that no longer exists and calling zcode the only harness that vendors the
hook runtime, which stopped being true when its copies were dropped. The counts
are the real ones, and the paragraph now says when each target's copies are
generated, since that is what decides whether git holds them.

* build(plugins): keep node_modules out of the marketplace archive

Three of the staged plugin trees carry installed development dependencies,
so the copy shipped 134MB nobody unpacks and spent most of the release test's
54 seconds on them. Copying through tar leaves them behind while still
carrying the generated shared copies, which a tracked-file listing would miss.

* build(plugins): read the installer's shared-module list from the sync

The installer carried a second, hand-written copy of the closure sync.mjs
already computes, and only a regex over the bash text held the two equal. The
sync writes that closure to lib/MANIFEST now and the installer copies what it
names, so the set has one source and the test compares two lists instead of
scraping a script. The installer cannot just run the sync: a marketplace
archive is flat, and the generator resolves an empty list there.

* build(plugins): derive what the marketplace archive must contain

The stage script hand-listed 56 required paths beside three generators that
already knew them, and it had drifted from all three: eleven of the twenty-two
shared modules the installer assembles, four of the thirty-eight copies the sync
generates, and neither marketplace manifest. The list is now computed from each
plugin's own manifests, the imports those entrypoints reach, and the sync's
target list, leaving the script with only the directories it ships.

* fix(plugins): resolve credentials from the calling harness's ov.conf section

The shared resolver read `ov.conf`'s `codex` block for every caller, so the api
key, account, user and peer of opencode, pi, dsh, cursor, trae, trae-cn and
zcode all came out of a block named after another harness, while the block
named after them did nothing. A user who wrote `opencode.apiKey` saw no effect
and a stale `codex` block quietly authenticated everything else.

`resolveOpenVikingCredentials` now takes the harness and reads the section named
after it, under either spelling. The layer keeps its place — after ovcli.conf,
ahead of `server.root_api_key` — and the ovcli-pinned mode still skips ov.conf
entirely, so codex, the caller that keeps the default, is unchanged.

For users this means the `<harness>` block finally configures that harness, and
anyone who was relying on the `codex` block to supply credentials to a different
harness has to copy them into their own block. dsh merges its section in
explicitly because the cordis patch occupies its legacy layer, and codex now
honours an api key set through `ovcli.conf`'s `plugin` section, which the
credential chain has never seen. cursor, trae, trae-cn and zcode honour it too:
the hook runtime spread the resolved credentials over the settings, so an empty
key from the chain used to erase the one `plugin.<harness>.apiKey` had supplied.
The doctor warns about all of these blocks now, since the server refuses to
start on any of them.

* fix(plugins): let the credential chain supply the peer on claude-code and dsh

Claude Code had no peer logic at all: it took only the user agent from the
shared credential module, so `actor_peer_id` in ovcli.conf and `peerId` in
ov.conf's `claude_code` block were read by every other harness and ignored
here. `ov config switch` moved the peer and Claude Code kept writing under the
one derived from the workspace. It now takes the peer from the same chain, and
a `plugin` entry or a workspace file still names the more specific answer for
that directory, which is the rule the thin harnesses already follow.

dsh had the opposite fault: it assigned the credential peer over whatever the
layers resolved, and that chain is the empty string whenever no actor peer is
named, so `plugin.dsh.peerId` was resolved and then thrown away. The credential
peer is a fallback now instead of an overwrite.

* fix(plugins): resolve the peer through one chain on every harness

Six loaders each wrote their own peer order, and two of them disagreed with the
rest: codex let a `peerId` written for the harness win, while opencode and pi
let `ovcli.conf`'s account-wide `actor_peer_id` win. One ovcli.conf therefore
produced two different peers depending on which host read it, and `ov config
switch` moved the peer for some harnesses and not others.

`resolvePluginPeerId()` in the shared library is now that order, once: a peer
the host named, then `OPENVIKING_PEER_ID` (suppressed when the credentials are
pinned to ovcli.conf, where the environment is meant not to apply), then the
workspace file, the registry and `ovcli.conf`'s `plugin` section, then
`ovcli.conf`'s `actor_peer_id`, and last `ov.conf`'s harness block, which keeps
the place it has always had. Nothing named means nothing explicit, which is
what makes `peer.source` derive one.

For opencode and pi users this is a precedence change: a `peerId` under
`plugin` or `plugin.<harness>` in ovcli.conf, or a `peer.id` in a workspace
file, now outranks that file's `actor_peer_id` instead of being outranked by
it. Anyone who wrote both and wanted the actor peer has to drop the plugin one.
It is one for claude-code too: `ov.conf`'s `claude_code.peerId` was the only
peer that harness read, and it now sits under `actor_peer_id` like every other
harness's block. Otherwise nothing moves: `ov.conf`'s harness block is ranked
here rather than left to the credential chain, which drops it whenever the
credentials are pinned to ovcli.conf, so that block names the peer in the same
place whether they are pinned or not.

* fix(plugins): shape the cursor and trae MCP proxies like every other one

Both proxies hand-built the config object the shared `buildMcpProxyConfig`
produces everywhere else, and had done so since before that helper existed, so
they never picked up the fixes it carries. The visible one is the actor peer:
they put whatever peer the config layers resolved straight onto
`X-OpenViking-Actor-Peer`, while the default `recallPeerScope` of `all` means
broad recall and every other proxy deliberately sends no such header. Cursor
and TRAE therefore recalled at a narrower scope than the rest on identical
configuration. Two smaller ones come with it: the watch list now includes the
default credential paths, so a proxy started before the first `ov login` picks
up the file it creates instead of never reloading, and the request timeout is
clamped rather than taken raw.

The guard is the file list in `mcp-proxy-config.test.mjs`, which named five
proxies by hand and so never covered the two that were wrong. It is a directory
scan now, pinned to a count so a rename cannot quietly empty it, and it asserts
that every proxy in the tree reaches the shared builder.

* fix(plugins): put one set of headers on the wire from every harness

Seven request builders each wrote their own header block and three of them
disagreed. Codex sent the api key twice, as `Authorization: Bearer` and again
as `X-API-Key`, which the open-source server prefers when both arrive — so a
gateway rewriting one of them changed which credential authenticated. Claude
Code read `cfg.accountId` where the other six read `cfg.account`. And only
Codex asked whether the server was in trusted mode before naming the operator:
everywhere else `X-OpenViking-Account` / `X-OpenViking-User` went out whenever
they resolved, including to `api_key` servers that read both out of the key,
ignore the headers, and leave the identity visible to every proxy on the path.
Both doctors have been warning users about that as if it were already fixed.

On the wire, from this commit:

- `X-API-Key` stops. Codex's `ov-session.mjs`, `auto-recall.mjs` and
  `session-start-commit.mjs` were the only senders; `Authorization: Bearer` is
  unchanged and remains the only credential header. A gateway that needs
  `X-API-Key` has to add it itself. openclaw keeps sending it and is untouched.
- `X-OpenViking-Account` and `X-OpenViking-User` are sent only under
  `sendIdentityHeaders`, which is `authMode === "trusted"`. Codex already
  behaved this way; claude-code, opencode, dsh, pi and the cursor / trae /
  trae-cn / zcode hook runtime now do too, and so do the two senders outside
  the hook stacks: every harness's stdio MCP proxy, which takes the switch
  through `buildMcpProxyConfig` the way it already takes the actor peer, and
  Claude Code's status-line server probe. Auth mode resolves through
  `resolveAuthMode()` in the shared credentials module: `plugin.<harness>`
  `authMode`, then `plugin.authMode`, then `ov.conf` `<harness>.authMode` on
  the three harnesses whose loader reads that section as a settings layer
  (claude-code, codex and dsh), then `ov.conf` `server.auth_mode`, then trusted
  whenever an account or a user resolved at all — the chain Codex already had,
  now shared. An operator whose server is in `api_key` mode and who has an
  account in ovcli.conf stops sending it; nothing else changes.
- `X-OpenViking-Actor-Peer`, `User-Agent` and `Content-Type` are unchanged.

Claude Code's config now also exposes the resolved identity as `account` /
`user` beside the existing `accountId` / `userId`, and its doctor and its
status-line probe use the headers the plugin would really send instead of
always sending both. The Agent Plugins package resolves an auth mode of its
own, so its proxy still names the operator to a trusted server.

The free half of the same theme: `session-start-commit.mjs` carried a private
`requestJSON` / `commitOvSession` pair that duplicated `ov-session.mjs`'s down
to the envelope. It imports them now, which is one header block fewer to keep
in step.

`wire-headers.test.mjs` drives six of the seven stacks through a stubbed fetch
and asserts one header map for all of them, in trusted mode and in api_key
mode; `auto-recall.test.mjs` covers the seventh, which only exists as a
subprocess, against a real server socket; and `mcp-proxy-config.test.mjs` puts
the proxy's two identity headers on the same switch.

* test(openclaw): run only the live test copies under tests/ut

Six files sat directly under tests/ as copies of suites that had since moved
on, and vitest's default include meant two of them still ran on every
`npm test` against an older shape of the code. What each one is:

- `tests/context-engine-assemble.test.ts` is an earlier cut of
  `tests/ut/context-engine-assemble.test.ts` — same imports one directory
  shallower, 360 lines against the live copy's 838. Five of its six cases are
  in the live copy verbatim; the sixth, "records senderId from runtimeContext
  in assemble diagnostics", is covered by the `senderIdFound` assertions in
  `tests/ut/context-engine-modules.test.ts` and
  `tests/ut/context-engine-afterTurn.test.ts`.
- `tests/context-bloat-730.test.ts` imports `memory-ranking.js`, `config.js`
  and `auto-recall.js`, and every symbol it exercises already has a home under
  `tests/ut`: `postProcessMemories` and `pickMemoriesForInjection` in
  `memory-ranking.test.ts`, `buildMemoryLinesWithBudget` and
  `estimateTokenCount` in `build-memory-lines.test.ts`,
  `recallScoreThreshold` and `recallMaxInjectedChars` in `config.test.ts` and
  `query-config.test.ts`.
- `tests/test-memory-chain.py` and `tests/test-tool-capture.py` are earlier
  drafts of `tests/e2e/test-memory-chain.py` and
  `tests/e2e/test-tool-capture.py`, which drive the same phases against a live
  gateway at greater length.
- `tests/demo-memory-ajie.py` and `tests/demo-memory-xiaomei.py` are one
  manual gateway demo under two personas, differing in the user name and the
  port, and named by no doc, script or manifest.

Nothing referenced them: the only prose pointer to an assemble test names the
`tests/ut` copy, and `.clawhubignore` plus `tsconfig.build.json` already kept
the whole directory out of the published package.

So that a file dropped beside the suite is not silently picked up again,
`vitest.config.ts` now pins `test.include` to `tests/ut/**/*.test.ts`. The
CI job's `--exclude` filters still apply on top of it — 42 files listed, 41
after the architecture-boundaries exclude. The one file left in `__tests__`
falls outside the pin until it moves into `tests/ut` with the rest.

`architecture-boundaries.test.ts` kept `tests/context-bloat-730.test.ts` in
the list of files it reads for index-facade imports, which would have thrown
on the missing path, so that entry goes too.

* test(openclaw): move the shouldBypassSession cases under tests/ut

`vitest.config.ts` now collects only `tests/ut`, and the six cases in
`__tests__/bypass-session-patterns.test.ts` were the sole behaviour coverage of
`compileSessionPatterns`, `matchesSessionPattern`, `shouldBypassSession` and the
`ingestReplyAssistIgnoreSessionPatterns` fallback in the config schema, so they
had stopped running. They move beside the other `text-utils.ts` tests, one
directory deeper, and the emptied `__tests__` directory goes with the two build
files that still had to name it.

* docs(openclaw): drop five reports that nothing points at any more

Four are dated one-off write-ups of manual runs, and three of them name the
repository and branch they were made against, neither of which is this one:

- `docs/openviking-install-real-scenario-verification-report.md` (2026-06-05)
  walks one machine's install of plugin `2026.6.2` from
  `iaasng/arkclaw-openviking-plugin`, against a hosted endpoint and a specific
  OpenClaw build.
- `docs/openviking-dynamic-query-config-test-report.md` (2026-06-04) is the
  acceptance run for the runtime query-config work in that same repository;
  `docs/openviking-runtime-query-config.md` is the reference that documents the
  feature and stays.
- `docs/workmemory-v2-test-report.md` (2026-05-02) is a `locomo10` benchmark of
  Working Memory v2, beside the design note that still describes it.
- `openclaw-multi-tenant-test-report.md` records a session driven through two
  ad-hoc remote ports and a live Feishu bot, reproducible by nobody.

The fifth, `docs/oc-resource-skill-import-design.md`, is an RFC whose own
opening paragraph says the implementation went the other way: no unified
`ov_import(kind=...)` tool and no `/ov-import` command, which is the shape the
rest of the document is about.

One line pointed at any of them — the Working Memory entry in the guide's
reference list, whose neighbour is the design note and stays. Nothing shipped
moves: `docs/` is outside the package's `files` list, the top-level report was
never in it, and the ClawHub release workflow names no document.

* test(openclaw): drop the unreferenced tool-result compression bench

tests/toolresult_compression_tests/ was manual measurement tooling: run-sccs-bench.mjs
spawns an external openclaw binary turn by turn and reports token usage, so it asserts
nothing and needs three repositories cloned to hardcoded /root paths plus a live server
before it can run at all. Nothing outside the directory named it — the two invocation
lines the plan cites were in its own README.

The behaviour it exercised is covered by tests/ut: tool-round-trip.test.ts asserts that
an externalized tool result survives conversion with its preview text, its
viking://session/<id>/tool-results/<id> ref and original_chars intact, and tools.test.ts
asserts the restore half through openviking_tool_result_read / _search / _list.

* test(openclaw): assert the prepack script that regenerates shared at pack time

The packaged plugin's prepack gained a `sync.mjs` prefix when the generated
shared copies stopped being committed, but the manifest contract still pinned
prepack to `npm run build` alone. The openclaw-tests job runs that file, so the
suite has been red on a stale expectation rather than on a real contract break.

* chore(claude-code): drop the two unwired debug scripts

Neither debug-recall.mjs nor debug-capture.mjs is reachable from hooks.json,
install.sh, or the sync target, so they drifted away from the hooks they were
meant to mirror; debug-capture also rewrites the live session's capture cursor
through an API flow the plugin no longer uses. The doctor skill, both READMEs
and the capability reference lose their pointers in the same change.

* docs(pi): rewrite DESIGN.md around the modules that exist

The spec was written in the future tense of a plan only partly executed: it
specified an `index_builder.ts` that was never built (the profile block from
shared/profile-inject.mjs folded into systemPrompt took its role), gave no
section at all to config.ts or takeover.ts, carried per-file line estimates
that went stale on every commit, and repeated its Knowledge Index sample
twice. Its Implementation Order and Testing Strategy described work the code
and tests/ have both already done.

It is now one section per module actually shipped, each written from that
module's current code, plus the event walkthrough, the design ancestry and
the two rationales the code cannot state for itself. TAKEOVER.md's model,
runtime flow, compaction and failure modes move into the takeover section so
the one harness capability that is unique to pi is documented beside the
module that implements it; its configuration table was already in README.md.

* docs(codex): fold VERIFICATION.md into a README Testing section

The 320-line manual SOP was pinned to plugin v0.6.0 and every step it
walked is now asserted by the node --test suite that CI runs. Only its
two live legs -- extraction landing in the user namespace and the
interactive Codex smoke test -- need a real server, so they stay as a
short appendix next to the command that actually runs the suite.

* chore(opencode): drop three unreferenced helpers from lib/utils.mjs

makeMultipartRequest was a near-copy of makeRequest that no caller ever
reached, and the wired URI check is lib/viking-uri-guard.mjs, not
validateVikingUri; ensureRemoteUrl had no caller either.

* chore(pi): drop six unreachable OVClient methods

createSession, addMessageParts, addMessagePayload and deleteSession have no
caller: the OV session id is derived locally and the server-side session is
created implicitly by the batch messages endpoint, messages go out as one
addMessage or through that batch endpoint, and nothing in the extension
deletes a session. resolveScopeSpace and resolveTargetUri were a URI-space
rewriter that no tool path ever reached, and they held the only readers of
RESERVED_USER, RESERVED_AGENT and the resolvedSpaces cache.

The sync-barrier stub kept an addMessagePayload key for the same reason; sync.ts
queues and replays through fetchJSON, so the key asserted nothing.

* chore(dsh): drop nine unreachable OpenVikingClient methods

health, ensureSession, getSessionArchive, find, read, list, stat, forget and
addResource had no caller: the runtime only ever uses fetchJSON, healthResult,
ensureSessionResult, getSession, addMessage and commitSession, and the tool
surface the read and write helpers look like they serve is bridged through the
stdio MCP proxy, so they could never be reached.

Only tests called them. The peer-override and live-recall cases move onto
ensureSessionResult and healthResult, which take the same arguments, and the
409/ALREADY_EXISTS tolerance moves to a runtime test: initializeState carries
its own copy of that check, so the behaviour stays pinned where it now lives.

* chore(codex): drop the ov-credentials pass-through

scripts/ov-credentials.mjs re-exported two names from shared/credentials.mjs
and repeated that module's CLI verb-for-verb. Nothing ran it as a command, so
the file only added a second path to the same resolver, one that every
credential fix since the shared library landed had to remember to keep in step.

Its three importers now reach the shared module directly. The test file keeps
its name because .github/workflows/pr.yml lists it by path, and it asserts the
shared resolver either way.

* chore(install): drop the pre-stdio layout migrations

The rc-wrapper stripper and the two legacy-marketplace migrations cleaned up
after installs that predate the stdio MCP proxy and the unified `openviking`
marketplace name, both of which landed in July 2026. Every install since then
writes the current layout, so the three helpers ran on every upgrade only to
find nothing, while keeping four constants and a TOML rewriter alive for a
shape the installer can no longer produce.

The installer-side cleanup is what goes; both doctors still detect a leftover
`openviking-plugins-local` install and tell the user how to remove it by hand.
The Codex README's step list loses the line that promised the migration, and
the installer test that asserted a Cursor-only run left the rc blocks alone
goes with the code it was pinning.

* chore(claude-code): drop the dead archive-abstract loop

SessionStart rendered up to five `<archive-abstract>` entries from the session
context's `pre_archive_abstracts`, but the server hard-codes that field to an
empty array: openviking/session/session.py returns `[]` from both context
builders and says so in a comment ("保留字段返回空数组,保持 API 向下兼容").
The loop is dead twice over, since the field's entries are objects while the
filter kept only strings.

What resumed and compacted sessions actually receive is unchanged: the
`<session-archive>` block still carries `latest_archive_overview`, which is the
one field the endpoint fills. The capability reference loses the "≤5
pre_archive_abstracts" claim it made for claude-code alone.

* refactor(plugins): put the four JS hook stacks on one HTTP module

Claude Code, Codex, dsh and the thin-harness runtime each carried their own
AbortController, header block and envelope parser for the same server, which is
how the wire drifted apart in the first place. lib/ov-http.mjs owns that shape
now — Bearer only, identity headers only when the config says the server is
trusted with the operator's name — so the next change to it lands once instead
of four times.

Also fixes the usage line in credentials.mjs, which still named the wrapper
that was deleted out from under it.

* refactor(plugins): move the Claude Code and Codex session stacks onto the runtime

Claude Code's scripts/lib/ov-session.mjs kept its own copy of the session
helpers the shared hook runtime already had — a fetch builder, the pending-queue
enqueue, add-message, commit, and the two session getters — so a fix to how a
failed write is parked had to be made twice, and the two copies had already
drifted: only Claude Code's set pendingQueued / pendingEnqueueFailed and warned
about a non-retryable failure.

The runtime carries that contract now. makeAgentFetchJSON takes the timeout and
the actor-peer getter its callers vary (Codex knows its peer only after loading
state under the session lock, and Claude Code names one per call), and resolves
the workspace peer on demand so a caller that never reads it pays nothing.
addAgentMessage and commitAgentSession report what became of a failed write the
way the capture hooks log it, and commitAgentSession takes the retention payload
Claude Code's threshold commit sends. Both harnesses' modules are wrappers over
it, keeping Codex's result-or-null fetchJSON beside the envelope fetchJSONRes.

That report now reaches further than Claude Code: the three thin harnesses on
this runtime — cursor, trae/trae-cn and zcode — name a write no retry can fix
on stderr too.

* refactor(plugins): put the last header blocks on the shared builder

opencode and pi still carried a whole fetch stack of their own, and the doctor,
the MCP proxy, the Claude Code statusline probe and the Codex recall hook still
spelled the header block out by hand. All six go through lib/ov-http.mjs now:
opencode's fetchJSON and makeRequest are a shell over it that keeps its throwing
contract and its two hints, pi's client keeps its typed methods over the same
envelope, and the four header-only callers ask buildOvHeaders.

A test asserts what that buys: X-OpenViking-Actor-Peer appears in the plugin
family's non-test sources only in lib/ov-http.mjs, so the next hand-rolled
header block fails before it can drift. Its scope comes from the sync targets,
so a harness added there is covered without touching the test.

Codex's recall hook stops overriding the per-call actor peer, which makes its
legacy-peer sweep ask for the legacy peer instead of asking twice for the
effective one.

* refactor(claude-code): put auto-recall back on the shared recall core

Claude Code's auto-recall.mjs carried a line-for-line copy of the recall core's
query profile, lexical overlap boost, ranking, dedup, user-space resolution,
multi-source search and budgeted block builder — 280 lines that only its own
statusline numbers kept it from calling. Those numbers now come from the shared
side: buildRecallBlockDetailed returns the block together with the counts the
fallback builder already computed, plus a stage that says which path produced
it, so a host can tell an empty recall the server had nothing to offer from one
the score threshold emptied.

buildRecallBlock stays the string-returning shell over it that opencode and pi
call. The hook is left with the Claude Code envelope and the last-recall.json
snapshot the statusline reads, and a test covers all eight reasons that file
records — including a guard that fails if the ranking pipeline is ever copied
back into this plugin.

* chore(claude-code): drop the dead per-message capture filter

auto-capture.mjs's shouldCapture has had no call site since the hook moved to
batches: its length bounds, slash-command, punctuation-only and question-only
rules each drop a whole multi-turn batch for what one turn in it looks like, and
the comment at the batch gate has said so ever since. That left four regexes
reachable only from a function nothing calls.

The batch gate keeps what it always did — skip an empty batch, and in keyword
mode require some user turn to carry a trigger phrase — and its comment stops
naming the function that is gone.

* refactor(claude-code): read the transcript through the shared capture utilities

Claude Code's transcript walk was written twice — once in auto-capture, once in
subagent-stop — and neither copy was the shared one every other harness uses, so
a fix to block handling reached nine harnesses and skipped this one.

Both now go through scripts/cc-transcript.mjs, which re-exports the shared
capture utilities and adds only what is genuinely Anthropic-shaped: a
tool_result nests its output in a content array and names its call by id, and
that output is dropped from the turn's text while travelling verbatim in the
turn's tool part. The kept turns, their order and their parts are unchanged, so
the capture cursor still means what it did.

The shared sanitizer now also strips <system-reminder> blocks and
[Subagent Context] lines; without them this switch would have started sending
Claude's own notes to itself to OpenViking as if the user had written them.

* test(codex): pin the digest URI repair the shared compressor performs

The recall compressor is a small model spawned through `codex exec`, and it
occasionally rewrites a long viking:// URI while rephrasing a bullet, which
leaves a dead link in the injected digest. Since codex went onto the shared
`compressRecallContext`, every citation is snapped back onto the URIs the
server actually returned before the digest is injected; this test pins that
a mangled URI comes back repaired, with the short-input short-circuit
disabled so the compressor is actually exercised.

* refactor(plugins): open every hook entry with the shared stage

The Claude Code and Codex hook entries each read stdin, re-resolved the config
for the payload's directory and answered the enabled and bypass gates in their
own words, and the copies had drifted: Codex's PreCompact never re-checked its
own switch after the reload, and the two auto-recalls asked the two gates in
opposite orders.

runHookStage is that opening. An entry hands it a config loader, its gates and
its envelope, and keeps only its own work, which now returns what the envelope
should carry instead of writing stdout from wherever it happens to stop. The
stage also carries the bypass verdict for the hook that must not stop on it —
Codex's SessionStart still sweeps and replays for other sessions inside a
bypassed repository — and a single-shot emit for the one that answers before its
worker starts.

The write-path preamble stays in the entry: a detaching hook has to spawn its
worker before stdin is consumed, and only the entry knows the response its host
expects. Claude Code's uri-guard keeps its own code as well; it reads no config
and answers no gates.

* refactor(plugins): fold the agent URI guard into the shared one

lib/agent-uri-guard.mjs was a second module over lib/uri-guard.mjs, holding one
more hint table and nothing else, and the three harnesses that imported it each
carried their own stdin read, entrypoint check and deny envelope — thirty-odd
lines apiece for the choice between two envelope shapes.

evaluateUriGuard is that module's body with the two things a host actually
differs on made arguments: the hint table, and the tool names that host guards
at all — Claude Code and opencode never see the shell in their matchers, pi
does, and the set was previously implied by whichever table a host happened to
import. The deny envelopes and the hook plumbing move in beside it, so cursor,
trae and zcode keep only the envelope they answer with.

* refactor(plugins): let the last four URI guards call the shared evaluator

Claude Code, opencode, dsh and pi each kept a private copy of the guard loop —
normalize the tool name, look it up in a local table, sweep the arguments for a
viking:// URI, format the message — because their tables name different
replacement tools. The loop is the same everywhere; only the table is host data,
and it has to stay host data: read(uris="..."), openviking_read(uris=[...]) and
viking_read(uri=..., level=...) cannot be spelled from one set of strings.

So each of the four now hands its table to evaluateUriGuard and keeps only the
decision its host answers with. Claude Code's table was character-identical to
the shared one, so it passes the guarded set instead — read, glob and grep,
leaving Bash to the model as its test has always pinned — and its stdin read,
entrypoint check and deny envelope go the way the other hook guards' did.

* refactor(plugins): run both doctors from one shared entrypoint

The Claude Code and Codex doctors were forked from one commit and still carry
the same run: parseArgs and tryJson byte for byte, expandHome inlined, the same
eight sections in the same order, the same --json envelope and exit code. The
copies had drifted apart in the details — Codex never learned to say which node
PATH resolves to when it differs from the one running the script.

runDoctor owns that run now. A harness hands it its name, its CLI, the three
sections only it can produce (install, config, activity) and the spelling it
uses for the configured account and user; it gets the argument parsing, the
section order, the envelope and the exit code back. Codex keeps assessHooksFeature,
parseFeaturesList and the isDirectRun guard its test imports, and its auth-mode
verdict arrives through onSummary.

Both wrappers also stop scanning shell rc files for the pre-2026-07 wrapper
blocks: the installer that wrote them is gone, so only the OPENVIKING_* exports
those files may still carry are worth reporting.

* refactor(plugins): compose the doctor configuration section from shared segments

Both doctors printed the same section from two copies that had drifted, so a
check written for one host was invisible on the other. The section is now six
shared segments a host calls in order, and the three checks that only one of
them had — Codex's hook-budget warning, Claude Code's extra_headers X-API-Key
and NODE_TLS_REJECT_UNAUTHORIZED warnings — run on both.

* refactor(plugins): declare which recall knobs the request omits by default

recall-core sends limit, max_tokens, query_expansion and rewrite_max_bullets
only when a layer actually set them, so the server's own defaults stand where
the user expressed no preference. That decision lived in five hand-written
`<name>Configured` projections and nowhere in the schema, so a loader had no
way to know which knobs it owed a flag. The schema marks the four now, and a
test ties the marked set to the fields recall-core actually gates.

* refactor(plugins): assemble every harness configuration in one place

Six loaders spelled out the same sequence — read the credential files,
resolve the knobs, resolve the peer, derive the timeouts, the log path,
the user agent and the send-only-when-configured flags — and each spelled
a slightly different subset of it. ov.conf's `<harness>.authMode` reached
only the three that passed a legacy layer, two of the four knobs the
server defaults for itself were reported as configured on two harnesses,
and Claude Code resolved its api key on a chain of its own.

buildPluginConfig() is that sequence, once. What is left in a loader is
what only that harness knows: Claude Code's four-valued credential source,
Codex's on/off reading of the digest switch, opencode's three section
knobs, pi's older debug-log variable, dsh's cordis input.

The api key now resolves the same way everywhere, with ovcli.conf's
`plugin` section ranked where the file it lives in ranks — under that
file's own `api_key`, over ov.conf. Codex read it last, behind
`server.root_api_key`; its doctor, its reference table and the capability
reference said so, and now say what the code does.

* refactor(plugins): resolve the portable bundle's connection from the shared loader

The Agent Plugins package kept its own copy of the credential chain, the
User-Agent, the timeout and the debug logger, so every fix to the shared
resolution had to be mirrored by hand and the proxy fabricated a credential
source out of whichever config file happened to load. Importing the full
loader is not the answer either: it would pull the knob schema and the
workspace layers into a bundle that has no hooks to run them, and those
copies are committed. `buildProxyConnection()` is the connection half of
that loader, which is all a stdio proxy needs.

The api_key still ends at ov.conf's `server.root_api_key` even when
ovcli.conf pins the chain to itself: this package ships without an
installer, so an install that names only a url there has nobody to migrate
its key.

* refactor(plugins): run every thin-harness hook from one entry

Cursor, TRAE and ZCode kept eleven shim scripts between them whose whole
body was an event assignment and an import of the harness dispatcher, and
each spelled the plugin root its own way — `${CURSOR_PLUGIN_ROOT}`,
`__OPENVIKING_TRAE_ROOT__`, `${ZCODE_PLUGIN_ROOT}` — so the installer
carried one substitution branch per spelling and a template that used the
wrong one failed only at the first hook of a fresh install. The shared
entry takes the event and the client from argv, keeping TRAE's trailing
client-id form, and one placeholder leaves one regex to render it.

The installed hooks.json is now checked against the tree it was rendered
for: a command naming a script the install did not put on disk used to
surface only when the host first ran it.

* refactor(plugins): merge the three thin harnesses into one plugin

Cursor, TRAE and ZCode were three marketplace directories running the same
state machine — the same 2000ms session-start debounce, the same prompt
dedup by event id and 500ms window, the same recall cache and cross-process
lock — around four things that genuinely differ: the event vocabulary, the
response envelope, how a prompt is read out of the payload, and how a
finished turn is captured. Everything else was triplicated, so a fix landed
in whichever copy the author happened to open: the URI guard existed three
times over one shared evaluator, the MCP proxy three times over one shared
builder, and the guard that pins how many proxies exist counted eight.

`agent-hook-plugin` keeps one dispatcher on stage 4's `runHookStage`, one
URI guard, one MCP proxy and one doctor, and puts the four differences in
`hosts/<id>.mjs`. The adapters sit at that depth because
`../../memory-plugin-shared/lib` has to resolve both here and in an
installed `agent-integrations/<client>/`, so `hosts/<id>/` holds only what a
host reads as configuration. The installer copies per host and reads the
templates from there, and the thin harnesses get the doctor entry the full
plugins have had since stage 5.

The archive guard was reaching none of this: the three plugins' hooks.json
named the shared entry across the plugin boundary, so nothing required
their own dispatchers to be in the release archive. hooks.json names the
plugin's own `scripts/hook.mjs` again, which puts every adapter back in the
derived requirement set, and the checker is handed the directories the
staging script declares instead of listing whatever the stage happens to
hold.

* fix(install): reclaim the URI-guard hook entries on uninstall

`--uninstall` recognised an entry as OpenViking's only by the hook script it
named, and the URI guard was not on that list. Uninstalling Cursor therefore
left beforeReadFile and beforeShellExecution in ~/.cursor/hooks.json, and TRAE
kept its PreToolUse entry, all of them running a uri-guard.mjs under
agent-integrations/<client>/ that the same uninstall had just deleted: the host
reported a failing hook on every file read and shell command until the user
edited the file by hand.

The uninstall filter now reclaims anything the installer wrote, by the
OPENVIKING_INTEGRATION_ID the rendered command carries, the way the install-time
filter and the TRAE CLI uninstall already did.

* ci(plugins): select the plugin test files by glob

The step named 54 paths by hand, so a new test file was only covered once
someone remembered to add it there too. Seven globs select the same set: the
union and the old list differ in neither direction (82 files each), and no
generated shared/ copy holds a test for a glob to pick up by accident.

  examples/*/scripts/*.test.mjs            19
  examples/*/scripts/lib/*.test.mjs         2
  examples/*/servers/*.test.mjs             2
  examples/*/tests/*.test.mjs              20
  examples/*-plugin/*.test.mjs             11
  examples/memory-plugin-shared/*.test.mjs 27
  agent-plugins/*.test.mjs                  1

Two things the job could not see before. A stale lib/MANIFEST or committed
shared copy passed CI, because nothing looked at the tree after the generator
ran; `git diff --exit-code` does now. And the two installer tests ran beside
the other 80: staging the marketplace regenerates the shared copies those
files import, and both fork whole installs, which is the load the two
unexplained single-test failures appeared under during this work. They now run
in a step of their own at --test-concurrency=1, and
install-agent-hooks.test.mjs copies examples/ into a tmpdir so `--source dev`
no longer installs out of the tree everything else is reading.

Neither failure reproduced in five full runs here. The only failure five runs
did find was deterministic and local: a gitignored openclaw-plugin/shared left
behind by work that has moved to another branch, which the generator reports
as stale but never deletes.

The openclaw job keeps its exclude; only its counts change, measured on this
branch: 42 files, 675 tests, and 2 pre-existing architecture-boundaries
failures rather than 4.

* ci(plugins): bring pi into the plugin version gate

pi's version was a hand-written constant in config.ts, which put it out of
reach of check-plugin-version-bumps.sh: the extension is copied wholesale by
the installer and reports that constant as the build talking on the wire, so
a shipped change under a frozen version had nothing to catch it. The
extension now carries a package.json, EXTENSION_VERSION reads it through the
shared readManifestVersion, and the gate watches that file the way it
watches the other five manifests.

The manifest changes nothing about how the extension loads or ships. pi's
loader reads a package.json only for a pi.extensions field and falls back to
index.ts without one; pi install parses a local path as a directory rather
than an npm package, and only installs dependencies for git-cloned sources;
the installer's tar copies the directory whole, so the file is in the
marketplace archive and the installed copy resolves the same 0.3.0 the
constant held. "type": "module" states the format node was already detecting
per load, which is what the warning naming this file asked for.

Against origin/main the gate still reports four plugins: pi's manifest is
new on this branch, so it takes the same no-baseline skip agent-hook-plugin
takes, and is enforced from the next base onward.

* ci(plugins): merge the two npm plugin releases into one matrix

The dsh and opencode release workflows were the same 81 lines with seven
values swapped, so every fix to the publish flow had to be made twice and one
copy could drift unnoticed. plugin-npm-release.yml carries the flow once and
lists the two packages as matrix entries.

Every way the two files differed, and how the matrix expresses it:

  workflow name    one "Plugin npm Release"; the per-package "Publish
                   @openviking/..." survives as the job name
  job name         name: Publish ${{ matrix.package }}
  work directory   defaults.run.working-directory: ${{ matrix.directory }}
  concurrency      moved from the workflow to the job and keyed by
                   matrix.directory, so the two packages still queue
                   independently rather than behind each other
  push paths       the union of both plugin directories plus this filename;
                   the shared lib entry was already identical in both
  install command  matrix.install: npm ci for dsh, npm install for opencode,
                   which ships no lockfile
  name assertion   compared against matrix.package through an env var
                   instead of a literal inside the node script

The union filter means a dsh-only push also starts the opencode leg. That leg
validates and then stops at the npm view already-published check, the same
check that already made a shared-lib push a no-op for whichever package was
not bumped. Everything else is byte-identical to what both files ran:
permissions, checkout, node 24, the validate step, and the publish step with
its NPM_TOKEN-or-OIDC fallback and --provenance. actionlint reports nothing
on the new file.

RELEASE.md named the opencode workflow by filename, so its bullet now names
the merged workflow and both packages.

* refactor(plugins): give each skill copy its own sync target

SKILL_TARGETS was the one list in the generator with its own shape — a skill
plus an array of directories — so the delivery flag every other target carries
had nowhere to live, and nothing asserted that git holds the skill copies a
host installs by path. One entry per copy, `{ skill, dir, committed }`, and the
test that decides which generated copies belong in git reaches skills as well
as vendored modules.

Every skill copy is committed and stays that way: .gitignore covers the
vendored shared/ directories only, and a host installs a skill by copying its
path out of this repository, so a copy git does not hold ships nothing.
Nothing else moves — the same two skills reach the same six directories and the
generator writes the same bytes. openclaw-plugin and agent-plugins stay out on
purpose: their skills are different files over different tool surfaces, not
copies of these.

* refactor(install): move the installer's JavaScript into lib/install

install.sh carried a 328-line JSONC editor and three near-identical hooks/mcp
merges as node heredocs. Nothing could exercise them except running the whole
installer against a scratch HOME, and they had already drifted: the uninstall
copy of the ownership test learned to reclaim the URI-guard hook entries and
the install copy never did, so reinstalling left a stale guard behind.

jsonc-edit.mjs is the editor, moved verbatim behind one entry point.
host-json-config.mjs is the merge, once: a single read that refuses to
overwrite what it could not parse, a single atomic write, a single answer to
"is this hook entry ours", and three commands over them — write, remove and
merge-zcode. The ownership list is the uninstall side's, so an install now
replaces a stale uri-guard.mjs entry instead of appending beside it, and the
zcode fold reclaims by the same rule as the other three hosts rather than by a
substring of its own.

lib/install/ is the installer's code, not the plugins'. No shared module
imports it, so it enters no vendoring closure and no lib/MANIFEST; one
`./install/...` import from a module that hooks do import would put it in both,
and install-lib-closure.test.mjs now fails on exactly that.

Finding it is the one thing that is not a straight move. `--uninstall` runs
before any source is resolved and the documented uninstall pipes this script
from a URL, where there is no sibling directory to read — reaching for the
checkout there would clone a repository just to remove hooks. So the assembled
runtime keeps a copy of the directory beside the manifest modules, and
install_lib_dir looks next to the running script, then there, then at the
checkout.

install-opencode-jsonc.test.mjs is six in-process cases over the editor —
comments wherever they sit, trailing commas, a single-quoted value holding a
brace and a `//`, an existing nested mcp object, idempotence, and a server the
user disabled — plus the one end-to-end install that proves install.sh still
hands the module the right arguments. The capability reference's install.sh
line count goes with them; it was already wrong and this makes it wronger.

* refactor(tests): share the mock server and the hook runner

Seven test files carried their own copy of the same three helpers and the
copies had drifted: two writeJson signatures, and two withMockOpenViking
contracts, one of which called .catch() on the handler's return value and so
could not take a synchronous handler at all. Three more carried a hook runner
that turned a non-zero exit into a rejected promise — a result no test could
assert on, which is why the async ZCode test spawns the hook by hand where it
wants to read the exit code.

testing/support.mjs already held buildConfigForTest and is the right home for
the rest: it sits outside lib/, so sync.mjs vendors none of it into a shipped
plugin, and a helper imported from a *.test.mjs file would have registered
that file's own tests a second time.

Two things the shared versions do that no copy did. The mock logs every
request it saw and hands the log to the callback, which the three tests that
only recorded pathnames now assert against instead of keeping an array of
their own. And runHookScript never rejects: the exit code comes back like
stdout does, and the call sites say expectExit where a clean exit is part of
the expectation.

writeJson takes the status last and defaults it to 200, so the two-argument
callers read unchanged and claude-code's auto-capture moves its status to the
end. claude-code's auto-recall is not one of the seven — it never carried
readRequestBody — and keeps its two local copies.

* refactor(tests): make the config and credential contracts shared

The credential chain is one resolver every harness calls, but only codex
tested it, so a change to the chain broke six harnesses and one suite
reported it. That file moves to memory-plugin-shared, where the glob picks
it up as the shared contract it always was.

The five per-harness config suites had the same problem in reverse: each one
re-tested the layer stack, the peer order and the cwd rule that
plugin-config.test.mjs already holds every loader to, and buried the one or
two cases only that harness answers. Three contract tests finish that file —
a knob walked through every layer, a default told apart from a choice, and
the workspace layer read from the cwd the loader is handed rather than the
process's — and the per-harness suites keep what is theirs.

Trimmed: claude-code and codex scripts/config.test.mjs, opencode and pi
tests/config.test.mjs. Every case deleted from them is answered by a shared
one in plugin-config.test.mjs, workspace-peer.test.mjs or the credentials
contract this commit moves. dsh loses nothing: the cordis input is a layer no
other harness has.

Two things only a harness knows had no test at all, and now do: claude-code's
tri-state digest mode, which it reads from the shared on/off switch under
either env spelling, and codex's boolean reading of that same switch, where a
compressor counts as configured only once it has been told what to run.

* refactor(tests): test the MCP proxy contract once

The proxy is one shared module, but its protocol contract was tested through
two harness entrypoints — twelve cases under codex, one of them again under
opencode — and both entrypoints re-exported the factory they import purely so
a test could reach it. A change to the core meant editing two suites, and the
opencode case differed from codex's only in which User-Agent string its
fixture made up.

The twelve cases and their fixture move to mcp-proxy-core.test.mjs, beside the
two transport-error cases already there, and the file now has one proxy
builder: it takes a fetchImpl, so an injected fetch and a real loopback
upstream are the same harness. Neither host file keeps a case of its own —
every one of the twelve exercises the shared core through the harness's config
shape, which mcp-proxy-config.test.mjs already covers per harness.

One case the split never had: a proxy with no local tool provider. Most
harnesses run it that way, so the local-tool branches have to stay invisible —
the listing is the upstream's, and a call named like a local tool is still the
upstream's to answer.

`examples/*/servers/*.test.mjs` now matches nothing. The pattern stays, for the
next case that really is one host's, under nullglob so it contributes nothing
rather than reaching node as a literal; opencode's test script drops it.
opencode keeps its servers/mcp-proxy.mjs export map entry, which describes the
entrypoint the package exposes, not the deleted test.

* refactor(tests): test the shared runtime where it lives

Three suites tested shared code from a harness directory, so a second
harness reusing that code was covered only by accident. The recall
timeout case, the queue cases that never touch cc's ov-session, and
findLastHumanTurnIndex now sit beside the module they exercise.

findLastHumanTurnIndex moves with its cases: it is a generic helper on
the shared turn shape, and codex's capture-utils.mjs already re-exports
everything shared, so its one caller needs no edit.

* refactor(plugins): label each credential from the chain that resolved it

Three doctors each re-walked ovcli.conf and ov.conf to say where the url, the
key and the identity came from — the walk `resolveOpenVikingCredentials` had
just finished. They were not the same walk. Claude Code and Codex rebuilt the
key's origin by hand and ignored the `apiKeySource`/`credentialPath` every
config now carries, so a key the chain took from one layer could be reported as
coming from another; the thin-harness doctor read `apiKeySource` but then let
ov.conf's harness block name the account under a pin the chain stops above.

`credentialSources(cfg, cliConf, ovConf, { section })` lives in doctor-core now,
with the section defaulting to the calling harness. The key's layer comes off
`cfg.apiKeySource`; the files are asked only which field inside that layer holds
the value, which is the one thing the layer cannot say — `plugin.<harness>.
apiKey` from `api_key`, a harness block from `server.root_api_key`. The identity
follows the chain step for step, plugin section included.

The stage-5 guard could not see the three copies because the core never exported
the name. It does now, so a fourth one fails that test. The two harness test
copies move to doctor-core.test.mjs, beside the function they describe.

* ci(plugins): generate OpenClaw shared runtime before tests

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(plugins): define hook and MCP development standard

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(plugins): publish bilingual development guide

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(plugins): restore runtime and distribution contracts

Generate shared dependencies before validation and source installs. Keep Codex retries owned by the transcript cursor and defer threshold commits until the tail is delivered. Enforce the shared hook enable gate, restore auth-mode environment precedence, and preserve pi request options.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* refactor(plugins): use a host-neutral hook manifest

Move the shared hook integration metadata out of the Claude-specific directory and update version checks, diagnostics, installation verification, archive validation, tests, and documentation.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-15 18:28:20 +08:00
Nick Jamesandpc.yu 3841e6f299 fix(plugins): drain the dsh pending queue in-process so a transient write failure self-heals (#4779)
* fix(plugins): drain the pending queue in-process so a transient write failure self-heals

The dsh memory plugin latches capture and commit on the first retryable
write failure (hasPendingWrites) and only reset the latch at session
init, so the long-lived dsh process stayed stuck until restart.

Add a per-process single-flight drainer (default 60s, env
OPENVIKING_PENDING_DRAIN_INTERVAL_MS) that follows the session-start
flow: probe health, replay the queue without consuming retry budgets,
then re-derive every session's latch from the queue. replayPending gains
an optional consumeRetries flag (default true, byte-compatible):
drainers release a failed claim back to its original filename instead of
incrementing the retry count, so the session-start path keeps owning all
retry accounting and D4 deletions. Latch and health transitions are
logged once per flip for observability.

* test(plugins): cover the drainer and non-consuming replay mode

Add pending-queue coverage for consumeRetries:false (retryable failures
stay retryable and ordered, non-retryable and exhausted entries still
delete, commitSession failures keep the run going, default mode
unchanged) and runtime drainer coverage (recovery clears the latch,
outages keep it and leave entries retryable, empty queue means zero
HTTP, commit resumes after the drain, single-flight, per-session latch
isolation, interval wiring with env fallback).

---------

Co-authored-by: pc.yu <nick@fourieralpha.com>
2026-09-10 13:26:46 +08:00
t0saki 708dba60bd feat(plugins): configurable regex input filters for recall queries and captured turns (#4858)
* feat(plugins): ordered regex input filters for plugin input

The memory plugins feed the raw user prompt into recall and the raw
transcript into capture. Nothing between the harness and the server lets
an operator shape that text: a thinking-keyword prefix (`ultrathink ...`)
goes into the search query verbatim, a slash command becomes a memory,
and a token pasted into a prompt is stored as it was typed. The only
user-configurable pattern today is `bypassSessionPatterns`, which matches
a session id or a cwd, never the text; every text rule -- `ACK_RE`,
`SLASH_COMMAND_RE`, `sanitizeCapturedText` -- is hard-coded.

`lib/input-filters.mjs` adds the missing layer: an ordered list of
sed-style rules, `s` to substitute, `d` to drop the text on a match and
`k` to keep it only on a match, each optionally scoped to one role. The
rules are plain strings so they survive the existing string-list config
coercion (env CSV, `ovcli.conf` array) without a new config type. Nothing
in the module throws: an unparsable rule, an unknown flag or a pattern
RegExp rejects becomes an entry in `errors` and is skipped, because a
typo in a config file must not take a hook down. Compilation is memoised
per rule list so a long-lived host compiles once, and rule count and
pattern length are capped. The time budget is deliberately advisory --
`applyInputFilters` reports `slow`/`elapsedMs` and runs every rule
anyway, since skipping the tail of the list would skip exactly the
redaction rule an operator added.

`capture-utils.mjs` grows the capture-side seam. `filterCaptureParts`
prunes blank text parts as before, then takes one drop verdict per turn
on the aggregate of its text parts and rewrites the individual parts with
the substitutions only, so a rule can never both keep and drop the same
turn; tool call/result payloads are carried through unmatched, though a
turn dropped on its text takes them with it. `shouldCaptureText` gains
the same step for the text path, behind a `filters` option that
`extractCaptureTurns` turns off when parts are on the wire -- one
application per stored string, and `turn.text` stays faithful for callers
that scan it for trigger words.

No loader supplies either knob yet, so this commit changes no behaviour:
with `captureFilters` unset every path is the identity it was.

* feat(claude-code): recallQueryFilters / captureFilters

Wires the shared filter engine into the Claude Code plugin. Both knobs
are string lists shaped exactly like `bypassSessionPatterns`: an env CSV
replaces the configured array wholesale, entries are trimmed and
non-strings dropped, and a rule that needs a literal comma has to come
from the `ovcli.conf` array because the env value is split on one.

Recall filters run in `auto-recall.mjs` immediately above the
`minQueryLength` gate, so a prompt whose only content was a stripped
prefix reports `short_query` rather than searching for the empty string,
and a dropped prompt gets its own `last-recall.json` reason,
`query_filtered` -- distinct from `filtered_out`, which is the score
threshold. The statusline only tests for `ok`, so it needs no change.

Capture filters run at the send site rather than in the extractor,
because the cursor CC persists is an index into the extracted turn list:
dropping a turn earlier would leave `capturedTurnCount` describing a list
the next run does not reproduce. `sanitizePartsForSend` in
`auto-capture.mjs` and the equivalent in `subagent-stop.mjs` are the two
places every payload passes through, and both now delegate their blank-
part pruning to `filterCaptureParts`, which keeps that behaviour when no
rules are configured.

`ov-memory-doctor` lists the active rules per knob and warns, with the
exact parse or RegExp error, about the ones it could not compile --
config errors have to be visible somewhere other than a debug log,
because a skipped rule is silent by design.

The README gains an "Input filters" section with the grammar, six worked
examples (every one of them asserted in the shared engine test), an
ovcli.conf sample, and the caveats that actually bite: the env comma
split, that drops win over substitutions, that filters run before the
built-in ack/slash heuristics, that a mid-session drop rule shortens the
turn list and reads as a transcript rewrite, and that nothing already
stored is rewritten.

* feat(codex): recallQueryFilters / captureFilters

The same two knobs on the Codex side. The loader gains the string-list
helper the harness did not have yet -- an env CSV replaces the configured
array wholesale, both paths trim and drop non-strings -- and reads
`cx.recallQueryFilters` / `cx.captureFilters`, so `ovcli.conf`
`plugin.codex`, `plugin` and the legacy `ov.conf` `codex` block all work
without further change.

Recall filters go in `auto-recall.mjs` immediately above the
`minQueryLength` gate: a dropped prompt emits the empty envelope before
the health check, so a filtered turn costs no request at all.

Capture needs no edit here. Codex's four write hooks funnel through
`catchUpTurns` into the shared `extractCaptureTurns`, which is where the
filters already run -- and it has to be there rather than at the send
site, because this cursor advances by payloads durably sent: dropping a
turn later would leave the cursor short and replay the surrounding turns
on the next hook. The test covers exactly that, running the same
transcript twice and asserting the second pass finds nothing new.

`ov-memory-doctor` reports the active rules and the compile errors, and
the README documents the grammar, both env examples, the ovcli.conf array
for rules that need a literal comma, and the ordering and mid-session
caveats.

* docs(agent-integrations): input filter env vars

The Claude Code integration guide's env table is where most people look
first, so the two new knobs belong there next to the bypass patterns they
resemble. The grammar and the worked examples stay in the plugin README,
which the table already links to.

* refactor(plugins): drop machinery the input filters do not need

A review pass against the actual requirement — let an operator configure
a regex over plugin input — found five constructions that solve problems
this feature does not have. All of them go:

- The compile memo cache, its FIFO eviction and the test-only reset. It
  was justified as "a long-lived host compiles once", but the only hosts
  that supply these knobs are the Claude Code and Codex hooks, which are
  short-lived subprocesses; the per-turn capture path is the one repeat
  caller, and compiling five rules measures 3 microseconds. The cache
  cost more code than it saved work.
- `maxRules` / `maxPatternLength` as injectable options nothing passed,
  and the caps themselves. Rules come from the operator's own config, so
  a 33rd rule or a 600-character pattern is self-inflicted and harmless,
  and `bypassSessionPatterns` — the same shape of knob, also operator
  regexes — has never capped anything.
- The advisory time budget: `slow` / `elapsedMs` and the injectable
  `now` whose only non-default caller was the test that made it look
  slow. The pattern that actually hurts is one that backtracks, and that
  hangs rather than reporting a number.
- The try/catch around rule application. Every hook already ends in
  `main().catch(...)` that logs and approves, so an exception here was
  never going to take a session down. The try/catch around `new RegExp`
  stays: that one parses operator input, and the doctors render its
  message.
- The warnings channel, which existed to carry one advisory string.
  Stripping `g` from a `d`/`k` rule is load-bearing — `.test()` on a
  global regex advances `lastIndex` and would alternate between calls —
  but it needs no announcement, since the rule then means exactly what
  it looked like it meant.

`filterCaptureParts` also returned `ruleIndex` / `op` / `slow` that no
caller read, and `extractCaptureTurns` tested `decision.reason ===
"filtered"` inside a condition where that disjunct could never be the
deciding one: `filters` is passed as `shaped.parts.length === 0`, so the
reason can only appear when the other half is already true.

Docs lose the retired caps and a duplicated note; the Claude Code recall
test loses the case Codex's suite already covers. Engine 303 -> 218
lines, behaviour identical for every rule an operator can write.

* docs(agent-integrations): point the filter knobs at their grammar

The env-var rows added to the Claude Code guide named the two knobs but
said nothing about how to write a rule, and described only the
comma-separated environment form — which reads as if that were the only
way to configure them. Both rows now link to the plugin README's Input
filters section, and a note under each table says the knobs also live in
`ovcli.conf` under `plugin.<harness>.<key>` or the shared `plugin.<key>`
as a JSON array, which is the better form: the env vars are split on
commas, so a rule containing a literal comma can only be written in the
array. The grammar itself stays in the plugin READMEs, where these guides
already send readers for the full env-var list.

The Codex guide had no mention of the feature at all; it gets the same
two rows, the same note, and a link to its own README.

The capability reference carried counts this feature invalidated:
`memory-plugin-shared/lib/` is 24 modules rather than 23, the hook set
`sync.mjs` gives every hook-driven plugin is 13 rather than 12, and every
per-target total shifts with it (dsh 15, opencode 17, zcode 19,
claude-code and codex 22 each). pi's entry was already wrong before this
change — it takes `batch-send` as well as `setup-wizard`, so 15, not 13 —
and is corrected in the same sentence. `input-filters.mjs` joins the
module table, and the `capture-utils.mjs` row now says "built-in capture
heuristics" so the two rows do not both read as "capture filtering".

* docs(configuration): document the ovcli.conf plugin section

`docs/*/configuration/02-client.md` is titled "ovcli Configuration" and
covers connection, command behaviour, upload filters and the workspace
layers — but never described the `plugin` section itself. It appeared
only twice in passing: two rows of the workspace precedence table, and
one sentence about `peerSource`. The nearest thing to a general
explanation was buried in the agent-integrations overview, inside a
discussion of recall latency.

That gap is why the input filter note added in the previous commit had to
explain the section's shape inline for two knobs, which reads as an
oddity rather than a reference. The section is now documented once, where
someone editing `ovcli.conf` would look: what `plugin` and
`plugin.<harness>` mean, that keys are the camelCase counterparts of the
`OPENVIKING_*` tuning variables and that the mapping does not go both
ways, that list-valued knobs are JSON arrays here while their environment
counterparts are comma-separated, the full resolution order, when an edit
takes effect versus when the agent has to restart, that only Claude Code
and Codex read it today, and that `ov-memory-doctor` flags unrecognised
keys. The per-knob lists stay in the plugin READMEs, which the section
links.

The two integration guides now point at it in one line instead of
restating it, the workspace precedence table links the section it already
named, and the complete example grows a `plugin` block.
2026-09-09 17:12:16 +08:00
t0saki 3ae94afeea fix(skills): 让 Experience 技能兼容没有 viking://~ 的旧服务端 (#4857)
* fix(skills): resolve an explicit user root when the server has no viking://~

The Experience workflow tells the agent to scope its search by
`viking://~/memories/experiences`. `viking://~` is the home alias for
the caller's own space, added in v0.4.16 (#4167); a server older than
that has no branch for the `~` scope, so the URI never resolves and the
request comes back as `INVALID_URI` (HTTP 400) instead of a search. The
skill offered no second spelling, so on those servers the first step of
the workflow failed and Experience retrieval stopped there — reported
in #4828, where every read succeeded once the caller substituted its
own `viking://user/<user_id>/memories/...` root by hand.

The alias is the only spelling that works everywhere it exists, so the
skill keeps leading with it and now says what to do when it is
rejected: rebuild the root from a canonical `viking://user/<user_id>/`
URI already visible in the session, or repeat the query unscoped and
keep the hits whose URI contains `/memories/experiences/`, whose URIs
carry the canonical user id for the reads that follow. Both paths
resolve the id from evidence, which is what keeps the existing "never
hardcode `default`" rule intact — the reporter's server resolved to
`default`, another account's will not.

The uid-less `viking://user/memories/experiences` is called out as a
non-fallback: only pre-v0.4.17 servers expand it, only for USER and
ADMIN callers, and #4196 made current servers reject it, so an agent
reaching for the obvious shorthand would fail on both ends of the
version range.

The same note goes to the agent-plugins memory skill, which scopes
recall the same way, and a sync test pins the pairing so a later edit
cannot drop the fallback while keeping the alias.

* chore(plugins): bump the plugins that ship the experience skill

Claude Code and Codex cache a plugin under
`cache/<marketplace>/<plugin>/<version>/`, so a skill edit behind a
frozen manifest version never reaches an installed user: `plugin
update` sees the same version and does nothing. The three plugins whose
skills changed move one patch step — claude-code 0.4.5 -> 0.4.6 (its
`package.json` stays in lockstep with the manifest), codex 0.8.1 ->
0.8.2 and agent-plugins 0.1.0 -> 0.1.1. The openclaw plugin resolves
its version at release time, so its copy needs no manual bump.
2026-09-09 15:36:58 +08:00
Xinmin Zeng 58bafa5ba1 fix(codex): reuse shared recall compressor (#4445)
* fix(codex): reuse shared recall compressor

* fix(plugins): harden recall compressor fallbacks
2026-09-08 15:45:13 +08:00
Nick Jamesandpc.yu 98f24e1690 fix(plugins): treat camelCase isError as an error tool result (#4724)
* fix(plugins): treat camelCase isError as an error tool result

DSH emits tool-result blocks with a camelCase isError field
({type:"tool-result", toolCallId, content, isError}), but the shared
capture-utils toolStatus() only checks is_error / error / state.error.
Failed results were therefore labeled tool_status=completed, while
their error text still landed in tool_output. The mislabel does not
mislead LLM memory extraction (verified end-to-end), but it does
corrupt status-driven consumers: experience read lineage
(experience_lineage.py), usage reporting, working-memory formatting,
and rollout training artifacts.

Recognize block.isError alongside is_error. Vendored copies are
regenerated via examples/memory-plugin-shared/sync.mjs. Adds a
capture-utils regression test plus a DSH capture test for the real
tool-result wire shape.

* fix(plugins): register capture-utils test in CI and cover state.isError

Review follow-up: add examples/memory-plugin-shared/capture-utils.test.mjs
to the pr.yml plugin-tests list (it was author-local only), and recognize
block.state.isError alongside state.error in toolStatus with a matching
test case. Vendored copies regenerated via sync.mjs.

---------

Co-authored-by: pc.yu <nick@fourieralpha.com>
2026-09-07 17:22:02 +08:00
now-ingandmac f7c6e84386 fix(memory-plugins): report client-side MCP proxy timeouts as -32004 instead of unreachable (#4741)
A request aborted by the proxy's own timeout budget fell into mapError()'s
catch-all and surfaced as -32001 'check the URL / server reachable' even
while the server was healthy and still computing — rerank-inclusive
find/search legitimately runs tens of seconds past the default 15s budget,
so every such call was mislabeled as an outage (#4739).

Branch on AbortError before the catch-all and return a dedicated -32004
naming the elapsed budget, the endpoint, and the OPENVIKING_TIMEOUT_MS
knob; -32001 keeps its meaning of genuine connection failure. -32003 is
already taken by the empty-response error, so timeouts use -32004.

The change is applied to the shared lib and propagated to every generated
copy via sync.mjs; the new mcp-proxy-core.test.mjs covers both the abort
and the connection-failure contrast and is registered in the plugin-tests
list in pr.yml.

Signed-off-by: mac <bishopapril850965@yahoo.com>
Co-authored-by: mac <bishopapril850965@yahoo.com>
2026-09-07 17:20:39 +08:00
854ff4ceb0 fix(pi-extension): replay offline backlog through the batch endpoint (#4692)
* fix(pi-extension): replay offline backlog through the batch endpoint

After a server outage pi's local pending queue can hold hundreds of
addMessage entries. The takeover barrier requires that queue to be empty
for the session, but replayed it with the shared replayPending(): one
POST per entry, at most one replay window (50) per attempt. At the
~0.67s/entry measured in #4504 a 670-entry backlog needed 14 takeover
attempts of ~33s each, and pendingTokens kept growing meanwhile.

- flushForTakeover drains the session's backlog via
  /messages/batch (sendSessionMessages, BATCH_LIMIT=100), so the whole
  backlog clears in a handful of requests within one attempt. Entries
  are claimed one batch at a time; a failed batch costs a retry only for
  the entries it contained, the rest stay untouched.
- syncBranch sends the whole turn in one batch request instead of one
  POST per payload, with retryable failures queued as before.
- pi's shared dir now vendors batch-send.mjs (already used by the
  claude-code, codex, opencode and zcode plugins).

Fixes #4504

Co-Authored-By: ktz03 <2484593937@qq.com>
Co-Authored-By: jiale li <2946192893@qq.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uued6mAGJ4jfTW4XRmNWwL

* fix(pi-extension): enqueue non-retryable batch failures and bound drain (#4702)

Address #4692 review nits from now-ing and jiale-li-orion:
- enqueueRemainder always queues on enqueue-on-failure (incl. 400/403) so the sync watermark advances
- drainSessionBacklog soft-bounded by OPENVIKING_PENDING_DRAIN_BUDGET_MS / MAX_BATCHES
- tests for watermark enqueue and maxBatches stop

* fix(pi-extension): keep batch-send drop policy, advance watermark in pi

#4702 made sendSessionMessages enqueue payloads after a non-retryable
rejection so pi's sync watermark would advance. That reverses a policy
pinned by the shared batch-send tests (poison payloads are dropped, not
queued) for every harness, and the other vendored copies were not
regenerated.

Keep the shared policy and fix the watermark where the need is: pi's
sendPayloads counts non-retryable drops as accepted, matching the
outcome replayPending() applies to such entries.

Co-Authored-By: ktz03 <2484593937@qq.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uued6mAGJ4jfTW4XRmNWwL

---------

Co-authored-by: ktz03 <2484593937@qq.com>
Co-authored-by: jiale li <2946192893@qq.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:18:11 +08:00
t0saki 75be3bd0f6 fix(plugins): stop the installer aborting inside the credentials wizard (#4689)
* fix(plugins): stop the installer aborting inside the credentials wizard

The API key prompt advertises "enter = keep <masked key>", but taking it
up killed the installer before it wrote ovcli.conf or installed a single
plugin. `[ -z "$current_key" ] && WIZ_KEY=""` is prompt_connection's last
command, so a stored key makes the test false, the function returns 1,
and `set -Eeuo pipefail` unwinds the whole script from step 2 of 3. The
user sees the ERR trap fire on a line that reads like an internal
detail, an unchanged ovcli.conf, and no plugin anywhere.

Digit shortcuts fed that same branch. tui_menu and tui_choose_cli_format
confirmed on the digit itself and left the Enter pressed right after it
in the tty buffer, where the next prompt read it as an empty answer.
Picking "Volcengine OpenViking Cloud" with `2` + Enter therefore skipped
past the URL menu with its default, wrote the stray "2" as the server
URL, and hit the abort on the API key prompt -- with the key the user
then typed going nowhere. Digits now only move the cursor, which is what
tui_menu's own English hint ("1-9 jump . enter confirm") already
promised; Enter still confirms.

Both menus are drawn on /dev/tty and cannot be driven without a pty, so
the wizard tests stub tui_menu and feed fd 3 directly, and the key
handlers are pinned by reading the script.

* fix(plugins): keep an empty list element from aborting the installer

`split_csv_list` and `split_harnesses` end their loop body with
`[ -n "$item" ] && printf ...`, so an empty last element -- a trailing
comma is enough -- makes the loop, the pipeline under `pipefail`, and
the function itself exit 1. `normalize_bin_list` passes that status
straight to its caller, where it lands in an assignment and `set -e`
kills the run before the first step:

    $ OPENVIKING_CLAUDE_BIN="claude," bash install.sh --yes
    xx  OpenViking installer stopped unexpectedly.
        Exit status: 1
        Script line: 514
        Command: CLAUDE_BINS="$(normalize_bin_list "$CLAUDE_BINS_ARG" claude)"

`--claude-bin`, `--codex-bin` and their OPENVIKING_* env spellings all
reach it. `split_harnesses` has the same shape and was saved only by
every call site expanding it inside a heredoc, where the status is
discarded; give both the `if` form so neither depends on that.

Two smaller things in the same area. The ERR handler sets up
`>/dev/tty` before `2>/dev/null`, so on a machine with no controlling
terminal bash reports that redirection failing on its own line, ahead of
the diagnostic the handler exists to print. And the credentials step
tested for a url or key change but only ever printed the url pair, so
rotating just the key reported `url: <same> -> <same>`; it now names the
field that moved and masks both sides of the key.
2026-09-04 16:02:50 +08:00
t0saki 37ef554bb2 refactor(plugins): converge the harness forks back onto the shared library (#4594)
* refactor(plugins): ship each harness only the shared modules it imports

`sync.mjs` grouped its lists by how they had grown rather than by what each target imports, so three modules travelled to plugins that never load them: `setup-wizard.mjs` reached dsh and zcode, neither of which ships a setup entry point, and `async-writer.mjs` reached opencode, which its host imports in-process and so has no hook subprocess to detach a write from. Split the groups by capability — the hook set, the wizard, the stdio proxy pair, the batch sender, the async write path — and give pi its own list, so a target's entry says which capabilities it has.

`sync.test.mjs` kept its own copy of those lists, and the copy had drifted: it was missing `plugin-config`, `retryable`, `recall-compress-core` and `mcp-proxy-config`, so a stale vendored copy of any of them would have passed CI. Import the lists from `sync.mjs` instead, guarding the sync behind an entrypoint check, and add the check the duplicate could never make: every module in `lib/` is claimed by some target, and no target holds a banner-carrying file the sync no longer ships.

* refactor(zcode): capture and shape the proxy config through the shared modules

zcode sent every turn it parsed straight to the server: no length cap, no acknowledgement filter, no slash-command or injected-status guard — the four things `shouldCaptureText` does for every other harness. It re-derived the proxy config object by hand too, which is how it came to watch a narrower set of credential files than `buildMcpProxyConfig` watches.

Route both through the shared modules. The dedup key stays keyed on the raw turn, so raising the cap later never resends a turn the server already holds in truncated form.

* refactor(pi): log through the shared JSON Lines logger

pi carried two copies of a hand-written `debugLog` — one in `index.ts`, one in `sync.ts` — that appended `<ISO timestamp> <message>` lines, read `OV_DEBUG_LOG` directly, and could only be turned on through the environment. Both are the shared `debug-log.mjs` with the structure taken out: no stage field, no JSON payload, no config knob, and a spelling of the variable no other harness uses.

Use `createLogger` in both places, add a `debugLogPath` config key so the log can be turned on the way every other pi setting is, and read `OPENVIKING_DEBUG_LOG` with `OV_DEBUG_LOG` kept as a deprecated alias so existing setups keep logging.

* fix(dsh): honor syncTurns on every write path, not just capture

`syncTurns: false` gated `capture()` alone, so a read-only session still committed on `turn/end`, still committed again on dispose, and still replayed whatever an earlier session had queued. The toggle promised no writes and made three.

Gate the commit paths and the replay on it too, and document it — the README and the integration page never mentioned the key at all. A backlog queued while capture was on stays on the queue for a session that still writes.

* chore(plugins): bump the zcode and dsh plugin versions

Both changed behavior in this branch — zcode now filters and truncates what it captures and watches the full credential set, dsh now writes nothing when `syncTurns` is off — and installed copies are keyed by version.

* fix(plugins): make the installer and the plugin test matrix work in a git worktree

`resolve_self_checkout` looked for `.git` as a directory. A linked worktree keeps it as a file pointing at the real gitdir, so `CHECKOUT_DIR` stayed empty there: `--source dev` resolved the marketplace to `/examples` and failed outright, and every other path fell through to `remote`, which clones from GitHub. On this machine that turned six of the thirteen installer tests red and made `release-marketplace.test.mjs` hang for twenty minutes on the network — long enough that the CPU starvation failed an unrelated recall timing assertion too. Test for existence instead of for a directory.

Three test files were never run by CI, so nothing noticed that one of them had gone stale: the pi wiring assertion still required `new RecallManager(...)` to end at the session-id getter, which stopped being true when the recall ledger was added a fourth argument. Assert only the getter, and register all three files in `pr.yml` — the matrix now covers every `*.test.mjs` under `examples/`.
2026-09-04 13:59:29 +08:00
t0saki 1d89f8d465 feat(plugins): derive the workspace peer from git, and let a repository carry its own config (#4595)
* 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.
2026-09-04 12:50:43 +08:00
starslittle 4e3770d4f2 feat(openclaw): assemble auto-recall context server-side (#4450)
* feat(openclaw): assemble auto-recall context server-side

* refactor(openclaw): reuse shared context search contract
2026-09-02 22:54:28 +08:00
t0saki 1f90903283 fix(skills): shorten over-long skill descriptions and guard the limit in sync tests (#4560)
* fix(skills): shorten skill descriptions over the 1024-char limit

The ov-memory-doctor (Claude Code + Codex) and install-openviking-memory
skill descriptions exceeded the 1024-character maximum, so the skills were
rejected at load time. Trim them while keeping the trigger phrases.

* test(skills): guard skill description length and sync ov-experience-memory

Add ov-experience-memory to the skill sync targets so its per-plugin copies
cannot drift, and assert every shipped SKILL.md keeps its frontmatter
description under the 1024-character loader limit.
2026-09-01 12:41:59 +08:00
Axiomoth cf5cc308ca fix(codex): avoid stale actor peer in MCP proxy (#4400)
Signed-off-by: Axiomoth <alearner@splrad.com>
2026-08-27 18:33:20 +08:00
t0saki 206054cf15 feat(memory-plugin): add ov-memory-doctor skill and diagnostics script for Claude Code and Codex (#4389)
* feat(memory-plugin): add ov-memory-doctor skill and diagnostics script for Claude Code and Codex

* docs(memory-plugin): link docs and mark the Volcengine-hosted service in the doctor skill

* feat(memory-plugin): add a Server health section to the doctor for local deployments

When the resolved url is loopback the doctor now inspects the server side:
ov.conf startup blockers (plugin-only keys the server rejects, dev mode on a
non-loopback bind, empty root_api_key, port mismatch, relative workspace,
unexpanded $VAR secrets, provider credential rules), the server process and
port owner (pid file, lsof/ss, docker container and its /app/.openviking
mount), the vector index's recorded embedding vs the configured one, the
server log when log.output is a file, and GET /ready. Remote servers get the
/ready probe only. The docker pending_initialization stub is recognised in
the Connection section. Skills, references and READMEs describe the new
section; provider-level validation stays with openviking-server doctor.

* refactor(memory-plugin): trim the doctor's Server health section to the port, plugin-only ov.conf keys and /ready

The section replicated the server's own config validation (top-level and
server.* key allowlists, provider credential rules, vlm, workers) and inspected
the pid file, docker mounts, systemd, the vector collection metadata and the
server log. All of that is what openviking-server reports itself at startup or
what `openviking-server doctor` covers, and the allowlists would drift with
every new config field. Keep what the server cannot tell the client: whether
anything listens on the port, the plugin-only ov.conf keys the server refuses
to start on, and GET /ready.

doctor-core.mjs is now synced only to the plugins that ship a doctor script;
the opencode and zcode copies were never imported.
2026-08-27 16:58:44 +08:00
t0saki 3b1db2082f fix(memory-plugin): setup wizard first-run path, proxy hint, config source reporting (#4387) 2026-08-27 16:31:20 +08:00
Nguyen Thanh Dat 24185a0848 fix(memory-plugin): stop the uri-guard from reading file content as a path (#4188) (#4233)
findVikingUri() checked the path-like keys and then swept every remaining
argument value, so a local write or edit whose CONTENT merely mentioned a
viking URI was denied and no file was created:

  write { file_path: "/home/me/notes.md",
          content: "docs say viking://user/default/ is virtual" } -> deny

The sweep still runs — it is what catches an unusual or nested path key — but
it now skips arguments that carry content rather than a location
(content, new_string, old_string, file_text, ...). A URI in file_path, path,
uri, an unknown nested path key, or a bash command still denies.

Vendored copies regenerated with examples/memory-plugin-shared/sync.mjs.
2026-08-26 12:33:59 +08:00
Yu ZhangandClaude Sonnet 4.6 5356ced5ba fix(plugin): honor explicit recall context timeout (#4256)
* fix(plugin): honor explicit recall context timeout

Let operator-configured recallContextTimeoutMs apply even when context recall skips rewrite and query expansion, so low-latency configs can still extend the request deadline explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin): sync recall timeout override

Keep the explicit recall context timeout behavior in the shared plugin source so generated plugin copies stay synchronized.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:01:07 +08:00
t0saki a83b81715b feat(uri)!: remove uid-less current-user shorthand in favor of viking://~ (#4196)
* feat(uri)!: reject uid-less current-user shorthand in favor of viking://~

viking://user/<segment> (memories/resources/skills/peers/privacy/sessions
without a user id) was ambiguous with a user literally named after the
segment, and a user actually named e.g. "memories" was unreachable for
USER/ADMIN callers. Now that the viking://~ home alias (#4167) covers the
same need unambiguously, the shorthand fails closed at the request
boundary instead of expanding:

- resolve_current_user_uri raises NamespaceShapeError with a corrective
  hint naming both viking://~/<rest> and the explicit-uid form. Silently
  parsing the reserved segment as a peer user id would misdirect reads
  and writes, so rejection is the only safe removal.
- Bare viking://user falls through to the canonical parser and keeps
  container semantics (a user key listing it sees only its own space).
- The self-id escape stays: a caller whose user_id equals a reserved
  name keeps viking://user/<own-id> as their canonical root. ROOT-role
  literal parsing and the legacy viking://session alias are unchanged.
- AddTargetsConfig normalizes stored legacy config spellings
  (viking://user/resources|skills) to the viking://~ form at validation
  so existing ov.conf/user_config deployments keep working; the accepted
  per-user spelling is now viking://~/resources and viking://~/skills.
- usage_reporter keeps canonicalizing the historical shorthand found in
  old transcripts and additionally recognizes viking://~/memories/.

BREAKING CHANGE: requests using the uid-less viking://user/<segment>
spelling now fail with 400; use viking://~/<segment> or an explicit
viking://user/{user_id}/<segment> URI.

* refactor(clients): migrate first-party emitters to the viking://~ home alias

Every in-repo client that emitted the removed uid-less current-user
shorthand now sends viking://~/... instead: vikingbot fallbacks and
default sentinels, the LangChain store/tools defaults, the shared
recall-core.mjs (all synced plugin copies), the codex/claude-code/
openclaw/openwebui/dsh/zcode/pi plugin emitters, quick-app examples,
Go SDK example, tau2 benchmark targets, and the eval golden dataset.

Compat kept where legacy strings live in stored user configs: bot and
ov_dream sentinels accept both spellings while emitting only ~, and
recall-core still rewrites legacy viking://user/<reserved> config values
client-side. langchain_openviking._uri now classifies viking://~ with
the explicit-user shape so canonicalized server responses keep matching
a ~ root. Plugin READMEs note the server requirement for the alias.

* docs: replace current-user shorthand guidance with the viking://~ home alias

Rewrite every EN/ZH doc and model-facing prompt that advertised the
uid-less viking://user/<segment> spelling: URI concept catalogue,
context-types/storage/extraction/retrieval/session/privacy concepts,
configuration guide (with the legacy add_targets auto-normalization
note), resources/skills/sessions/retrieval/admin API references, FAQ,
capability reference, and the openviking-memory / ov-experience-memory /
openclaw / ov-resources skills. The stale MCP viking://user/<path>
dialect passage in the MCP guide is replaced by ~ guidance, and bare
viking://user is documented as the container of user spaces.

* test(api): migrate live API session-used tests off the removed shorthand

tests/api_test/sessions sent uid-less viking://user/skills/... URIs to
record_used, which the request boundary now rejects with 400 (caught by
the API & CLI Integration Tests CI job; these tests need a live server
and are not part of the local suites). The api_test client authenticates
as an admin-role user key, so the viking://~ home alias expands for it.
tests/api_test/common/test_edge_cases.py is left as is: it asserts a 400
for a non-resource add target, which still holds.
2026-08-21 19:00:19 +08:00
t0saki c7044075ef feat(dsh): serve tools over the shared stdio MCP proxy (#4157)
* feat(dsh): serve tools over the shared stdio MCP proxy

Replace the dsh bundle's seven hand-registered `viking_*` tools with the
OpenViking MCP surface, reached through the same stdio proxy every other
memory integration starts, and collapse the four duplicated proxy
entrypoints onto a shared config builder.

The bundle now mounts `@deepseek-ai/dsh-mcp-client` (which ships with dsh
itself) on `servers/mcp-proxy.mjs`. Pointing an MCP SDK client straight at
the server's `/mcp` endpoint does not work: with `stateless_http=True` the
server still answers `GET /mcp` with an idle 200 SSE stream, and once the
SDK client opens that standalone stream it stops resolving POST responses,
so `tools/list` never returns. The stdio proxy owns the transport itself
and is unaffected.

`trimSlash`, `normalizePath`, `uniq`, the watched-credential-path list and
the cfg -> proxyConfig mapping existed in four near-identical copies
(claude-code, codex, opencode, agent-plugins; the last one carried a
"keep in sync with claude-code" comment). They move to
`memory-plugin-shared/lib/mcp-proxy-config.mjs` and all five entrypoints —
including the new dsh one — now shape their config through
`buildMcpProxyConfig`. Behavior is preserved per field, including codex's
explicit `mcpUrl` override, claude-code's `ovcli.conf` credential-source
probe, and opencode's extra watched config file.

The bridge is mounted last in `apply()` so a proxy that fails to start
cannot hold up profile injection, recall, capture, commit, or the URI
guard registrations above it.

* feat(dsh): add to the unified installer and ship the shared skill

The bundle now registers its own isolated `ctx.skills` provider serving the
shared `openviking-memory` skill, so DSH gets the same guidance the Claude
Code, Codex, and Cursor integrations ship. `sync.mjs` distributes the skill
to the bundle, and the provider uses `includeDefaultRoots: false` so it
never shadows DSH's own project/user skill catalog.

`install.sh` grows a `dsh` harness id, auto-detected like the others, plus a
profile prompt that defaults to `web` (`--dsh-profile` / `OPENVIKING_DSH_PROFILE`
answer it up front). The installer always installs the published package:
`dsh plugin` forwards to pnpm, and a linked source tree cannot resolve the
dsh peers the bundle imports because Node resolves them from the checkout's
realpath rather than from the profile.

Documentation is restructured around installing rather than internals. The
integration page now leads with the one-line installer and keeps behavior at
the level the other harness pages use, with configuration in a details block;
design rationale moves to the bundle README, which itself leads with Install
and groups the rationale under "Design notes". Capability-reference claims
that dsh is outside the unified installer are corrected.

* chore(dsh): release 0.2.0

The MCP tool surface, the stdio proxy transport, and the bundled skill all
change what the bundle does for an existing user, so this is a minor bump
rather than a patch. 0.1.0 remains the native-`viking_*` tool surface.

* docs(dsh): note pnpm's 24h minimum release age

pnpm 11 refuses releases younger than minimumReleaseAge (24 hours by
default), and surfaces it as a registry 404, so installing a freshly
published version reads as "the package does not exist".

* fix(dsh): honour dev source mode in the installer

install_dsh ignored SOURCE_MODE and always fetched the published package,
so selecting "current checkout" installed npm's build instead of the
working tree and validation still reported success.

npm is the bundle's only distribution channel, so the github/tos choice
does not apply to it: every mode except dev now installs the published
package, and dev packs the checkout with npm pack first. It has to arrive
as a real package rather than a link, because a linked source tree
resolves its dsh peers from its own realpath and misses the profile's
hoisted node_modules. The install line reports which source was used.

* fix(dsh): make repeated installs actually overwrite

Two ways a re-run silently kept stale code:

pnpm treats an already-satisfied version as a no-op regardless of which
tarball the file: dependency points at, so a dev re-install after editing
the checkout left the previous build in place. Local installs now drop the
package before adding it back; that is confined to local sources, since
doing it for the registry path would leave nothing installed when add
fails.

A bare package name has the same effect in reverse: a profile holding a
dev build satisfies it, so switching back to the published package was a
no-op. The registry path now asks for @latest.

The packed tarball is named after a fingerprint of the checkout's shipped
files, so an unchanged checkout skips the pack and keeps a stable path in
the profile lockfile.
2026-08-20 19:05:18 +08:00
t0saki 458e7fae37 docs: add a cross-integration capability reference page (#4076)
* docs: fix integration docs and comments that contradict the code

- codex: credential resolution in the default `auto` mode is env-first — `credentials.mjs`
  only falls back to `ovcli.conf` when no credential env var is set, while the docs and the
  `config.mjs` header comment claimed `ovcli.conf` wins by default. Also document
  `OPENVIKING_CREDENTIAL_SOURCE=cli`, which was undocumented.
- codex: the four hook scripts send the key as `X-API-Key` in addition to
  `Authorization: Bearer`; the README documented Bearer only.
- claude-code: the OV session id is `cc-<cc_session_id>` verbatim (`deriveHarnessSessionId`
  does no hashing), not `cc-<sha256(cc_session_id)>`.
- claude-code: `hooks.json` registers 9 hooks, not 7 — the responsibilities table was
  missing the `PreToolUse` `viking://` guard and the `PostToolUse` skill-experience hook.
- claude-code: archival is triggered client-side (the `Stop` hook commits once
  server-reported pending tokens cross `commitTokenThreshold`, default 20000, plus
  unconditional commits from `PreCompact` / `SessionEnd` / `SubagentStop`). The README
  attributed it to a server-side `auto_commit_threshold`, but
  `memory.session_auto_commit.default_enabled` is false and no plugin sends a policy.
- trae / opencode: the MCP proxy transparently exposes the full server tool set (16 tools);
  the docs listed a 4-item sample or 11-13 tools and omitted `tree` / `write` / `edit`.
- trae-cli: the installer registers the MCP server as `openviking-memory`, but the verify
  step told users to look for `openviking`.
- pi: the manual install block omitted the `pi install <dest>` registration step that the
  one-click installer runs, so a hand-copied extension is never registered.
- install.sh: `--uninstall` handles cursor, trae, trae-cn, trae-cli and zcode; the `--help`
  text still said Cursor/TRAE only.
- mcp_endpoint.py: the module docstring enumerated 13 tools and omitted `recall`,
  `list_watches` and `cancel_watch`; replaced the stale enumeration with a pointer to the
  `@mcp.tool` registrations.

* docs: add a cross-integration capability reference page

The agent-integrations section had per-integration install guides but no place
to compare integrations against each other. This adds one bilingual page that
does that, and wires it into the existing pages in both directions.

- New page `docs/{en,zh}/agent-integrations/16-capability-reference.md`: a
  dimension-first comparison of every OpenViking integration — active tool
  surface, automatic hook surface, install/credential/config layering, recall
  and injection, session and commit lifecycle (including a shutdown-path x
  harness end-state matrix), compaction takeover, write/delete boundaries,
  degradation, and a per-harness profile card for each integration.
- Sidebar: `StructuredSidebarCopy` gains an optional `topItems` field so a
  section can list flat entries next to its overview; agent-integrations uses
  it to place the new page beside the overview. Other sections are unaffected.
- Links both ways: the overview and all 14 per-integration pages link to the
  reference, and the reference links back to each integration page from its
  profile card, from the non-coding integration table, and from the custom
  agent integration paths. Section cross-references (§x.x) are real in-page
  anchor links, generated from the built heading ids.
- trae-cli is documented as TraeCode CLI 2.0 only, installed through a codex
  plugin alias; 1.0 and its standalone plugin are called out as unsupported.
- The MCP tool surface is described as 15 tools throughout, matching the
  removal of the `recall` tool in favour of `search` with `mode="context"`.
  Pages outside this change that still mention an MCP `recall` tool
  (04-codex, 12-cursor, 15-agent-plugins, guides/06-mcp-integration) need a
  follow-up sweep once that removal lands.

* docs: 更新服务端 MCP 工具面描述,简化信息并明确更新方式
2026-08-18 20:38:06 +08:00
t0sakiandTRAE CLI add72f9bed feat(plugins): install TraeCode CLI 2.0 via Codex alias (#4079)
* feat(plugins): install TraeCode CLI via Codex alias

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(installer): keep trae-cli as public harness

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-08-18 16:21:47 +08:00
t0saki eb5aaf78e9 feat(mcp): consolidate recall into context search (#4075) 2026-08-18 12:45:45 +08:00
t0saki ff415b905d feat(plugins): package the openviking-memory skill into coding agent plugins (#3974)
Ship the generic openviking-memory SKILL.md from examples/skills as the
canonical source and vendor it into the codex, claude-code, and cursor
memory plugins through the existing shared-file sync script.

- examples/skills/openviking-memory/SKILL.md is the single source of truth
- sync.mjs copies skills verbatim (no GENERATED banner: it would sit ahead
  of the YAML frontmatter and break every skill loader)
- sync.test.mjs asserts the vendored copies stay byte-identical
- the marketplace staging script now requires the two newly vendored copies

Split out of #3866: this carries only the generic skill packaging. The
Experience / agent-evolution half of that PR (ov-experience-memory skill,
server MCP experience tools, usage attribution) is deliberately excluded.
2026-08-17 18:47:52 +08:00
b7aa01d227 feat(plugins): add OpenViking memory integration for TRAE CLI (#4026)
* feat: add OpenViking memory integration for TRAE CLI

Add TRAE CLI lifecycle hooks and MCP proxy support, wire the integration into the shared installer, and cover idempotent install and uninstall behavior.

Co-authored-by: TRAE CLI <noreply@bytedance.com>

* fix(trae-cli): cover archive installs and hook payload aliases

* fix: keep TRAE CLI installation explicit

Leave TRAE Desktop detection unchanged and avoid auto-selecting TRAE CLI. TRAE CLI remains available through an explicit harness selection or --harness trae-cli.

Co-authored-by: TRAE CLI <noreply@bytedance.com>

* fix(trae-cli): auto-select installed CLI commands

Detect traecli and traex only when they are available in PATH, then mark and select the TRAE CLI harness automatically.

---------

Co-authored-by: “bianhaonan” <“bianhaonan@bytedance.com”>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-08-17 14:59:43 +08:00
d7ab37c76e feat(plugins): add OpenViking memory for DSH (#3993)
* feat(plugins): add OpenViking memory for DSH

* feat(dsh-plugin): graft review items — source whitelist, dsh constructors, live recall gate

Applies the #task-65 review verdict's graft list from #3991 onto the
#3993 base:

- capture whitelist: drop every plugin-sourced user message (any plugin,
  not just this one) so injected context never mirrors into memory as
  human input; recall queries keep their existing scope
- pre-step: register with prepend so this listener sees the final
  claimed batch, and short-circuit on signal.aborted around each await
- adopt dsh constructors behind exact-pinned peers (devDependencies
  mirror the pins): tools flow through @deepseek-ai/dsh-tools defineTool
  (declarative parameters, output schema/render, presentCall per tool),
  plugin messages through @deepseek-ai/dsh-llm createUserMessage; a
  registration-shape test makes a future rc pin bump fail CI instead of
  a user install when the ToolDefinition contract moves
- live-recall.test.mjs: opt-in (OPENVIKING_E2E=1) real-backend gate —
  store a sentinel via session commit, wait for extraction, assert
  recall returns it; passed against a live OpenViking server in 124s
  (note: commit with the default keep_recent_count=10 extracts nothing
  from short sessions — the test pins keepRecentCount 0)
- README: why injection is pre-step user messages, not the system
  prompt (complete:true personas silently drop prompt assembly), plus
  peer-pin rationale and a Testing section

Tests: 15 pass + 1 env-gated (node --test), requires npm ci for the
pinned dsh devDependencies — CI step lands separately (workflow scope).

* ci(pr): install DSH plugin deps before running memory plugin tests

* fix(dsh-plugin): finalize neutral plugin integration

Remove product-specific identifiers from the DSH plugin surface and harden its lifecycle, HTTP contracts, archive tooling, and ordered offline delivery.

Co-authored-by: TRAE CLI <noreply@bytedance.com>

---------

Co-authored-by: Zayn Jarvis <zaynjarvis@gmail.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-08-14 18:05:22 +08:00
t0sakiandZayn Jarvis 8abd61fcb6 feat(plugins): add Agent Plugins 1.0 portable package (#3998)
* feat(plugins): add Agent Plugins 1.0 portable package

Add agent-plugins/, an Agent Plugins 1.0 conformant package
(https://agent-plugins.org/specification) that any conforming client can
load: plugin.json manifest, an openviking-memory skill teaching the
hook-less recall + persist loop, and an mcp.json stdio entry running a
stdio -> streamable-HTTP proxy that resolves credentials from
OPENVIKING_* env -> ~/.openviking/ovcli.conf -> ~/.openviking/ov.conf,
same as the ov CLI.

servers/shared/* are generated copies of memory-plugin-shared/lib, wired
into sync.mjs / sync.test.mjs TARGETS so they cannot drift silently.
config.mjs / debug-log.mjs / mcp-proxy.mjs are adapted from
claude-code-memory-plugin with the hook-tuning knobs dropped.
plugin.test.mjs validates spec conformance (schema URLs and matching
spec versions, name rules, closed manifest root, semver, skill
frontmatter, referenced files staying inside the plugin root, node
--check on all .mjs) and runs in CI via pr.yml.

The skill treats tree/write/edit as optional, since they only exist on
servers that carry #3936.

Docs: docs/{en,zh}/agent-integrations/15-agent-plugins.md, registered in
the VitePress sidebar and the integration overview tables, plus a link
from the three root READMEs. The docs recommend the per-client plugin
whenever the harness has hooks, with the shared installer one-liner.

Based on #3994 by @ZaynJarvis.

Co-Authored-By: Zayn Jarvis <zaynjarvis@gmail.com>

* docs(agent-plugins): pluralize README title

---------

Co-authored-by: Zayn Jarvis <zaynjarvis@gmail.com>
2026-08-14 14:33:54 +08:00
t0saki 33043cb1b8 feat(plugins): expose session commit trace IDs (#3977)
Preserve result.trace_id across plugin HTTP wrappers, include it in commit success and failure logs, and surface it in user-visible commit confirmations where supported.
2026-08-13 23:29:41 +08:00
agent 00f3738edb feat(usage): emit resource-scoped experience usage records (#3921)
* feat(usage): expand experience tracking and log schema

* fix(usage): preserve experience count event names

* refactor(agent-evolution): use generic OpenViking tools

* fix(usage): capture generic OpenViking tool events

* feat(skills): guide cross-agent experience retrieval

* fix(usage): address generic tool migration review
2026-08-11 22:20:18 +08:00
t0saki 7e26fab61c fix(memory-plugins): report tool output verbatim, let the server externalize (#3933)
Coding-agent plugins capped a tool part's `tool_output` at 2000 chars before
POSTing it to `/api/v1/sessions/{id}/messages`. That cap sits below the server's
own externalization threshold (`tool_output_externalization.threshold_chars`,
default 20000), so output in the 2k-20k band was destroyed for no reason and
anything larger never reached `ToolResultStore` - leaving `tool_output_ref`
permanently empty and the `/tool-results` read-back path unusable.

Raise the `captureToolMaxChars` default to 1000000 (a guard against pathological
payloads, not a truncation policy) and lift the opencode/pi clamps that would
otherwise pin it back to 20000. claude-code had no knob at all - two hardcoded
`TOOL_OUTPUT_PART_MAX_CHARS = 2000` constants - so it gains the same config
entry and both capture scripts now read it.

Also stop pi from sending tool output twice: for a tool-only payload the
rawText-derived text part re-rendered the same output the tool part carries.
2026-08-11 19:17:54 +08:00
t0sakiandTRAE CLI 0ab48f96fc fix(session): recover partial capture sessions (#3820)
Treat messages.jsonl as the materialization boundary for session-aware recall,
repair partial session roots during the existing authoritative append path,
and preserve Claude capture cursors when writes never reach the server.
Also replay explicitly retryable storage conflicts across memory plugins.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-08-06 14:48:42 +08:00