Skip to content

Add recursive Feishu wiki materialization - #1

Closed
sponge225 wants to merge 103 commits into
mainfrom
feat/feishu-wiki-recursive-materialize
Closed

sponge225 wants to merge 103 commits into
mainfrom
feat/feishu-wiki-recursive-materialize

Conversation

@sponge225

Copy link
Copy Markdown
Owner

Description

本 PR 为飞书 Wiki 导入增加递归物化能力。原先处理 wiki/xxx 链接时,只会下载入口 Wiki 页面自身,不会继续发现和访问其下挂载的子节点。因此当飞书 Wiki 中存在“目录文档下挂子文档、PDF/JSON 等普通文件,普通文件节点下又继续挂子文档”的结构时,入口页面之外的子文档和文件会被漏掉,导入到 OV 后内容不完整。

本次实现将飞书导入保持为两个阶段:第一阶段由飞书访问层把远端 Wiki 树完整下载到本地临时目录;第二阶段继续复用 OV 现有的本地目录导入流程,将临时目录解析、切分并写入最终资源库。这样不需要为 Wiki 单独重写后续导入链路,只在远端下载阶段补齐结构物化能力。

物化策略采用方案 1:尽量还原飞书 Wiki 的原始层级。只有存在子节点的 Wiki 节点才生成目录,并在目录中保存节点自身内容;没有子节点的文档或文件直接保存到父目录下。该方案相比统一 children/ 目录的方案,路径更短,也不会引入飞书中不存在的中间目录,浏览和检索体验更接近原始 Wiki 结构。

同时修复 Drive 文件下载的一类问题:部分普通文件本身就是 JSON,例如 qa_doc_mapping.json。旧下载方式会经过 Lark SDK 的通用 JSON 解包路径,导致 JSON 数组文件被误当作 OpenAPI 响应处理并失败。现在 Drive 文件下载改为 raw bytes 保存;只有响应明确是飞书标准错误 envelope 且 code != 0 时,才转成业务错误抛出。

Human Involvement

  • A human participated in the implementation or review loop
  • This PR was generated entirely by AI agents without human participation in the loop

Related Issue

暂无。

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test update

Changes Made

  • 增加飞书 Wiki 递归导入开关,开启后从入口 Wiki 节点开始递归发现子节点,并将完整树结构下载到本地临时目录。
  • 调整 Wiki 节点本地物化规则:有子节点的节点生成目录并保存自身内容,叶子节点直接保存为父目录下的同名文件,避免不必要的同名目录嵌套和额外摘要文件。
  • 支持混合结构递归物化,包括飞书文档、Drive 普通文件、PDF、JSON 文件,以及普通文件节点下继续挂载 Wiki 子节点的场景。
  • 增加递归深度、节点数和循环引用保护,避免异常 Wiki 结构导致无限递归或失控下载。
  • 将 Drive 文件下载切换为 raw bytes 路径,避免 JSON 文件被 SDK 误当成 OpenAPI 响应解析,同时保留飞书业务错误识别。
  • 补充单元测试,覆盖 Wiki 子节点分页、用户 token 透传、递归混合结构物化、非递归 Wiki 行为保持不变、JSON 文件 raw 下载,以及 raw 响应中的飞书业务错误处理。

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this on the following platforms:
    • Linux
    • macOS
    • Windows

本地验证命令:

uv run ruff check openviking/parse/accessors/feishu_accessor.py tests/parse/test_feishu_accessor.py
HOME=/Users/bytedance/ragexp/ov-base/.tmp/test-home OV_CONFIG_DIR=/Users/bytedance/ragexp/ov-base/.tmp/test-config uv run pytest tests/parse/test_feishu_accessor.py -q

验证结果:

  • ruff 通过。
  • tests/parse/test_feishu_accessor.py 通过,结果为 29 passed。
  • 已完成真实飞书 Wiki 人工端到端验收,最终导入成功,root_uri 为 viking://resources/目录文档1,failed_files 为空。

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Screenshots (if applicable)

不适用。

Additional Notes

本 PR 没有改变默认的 Wiki 单文档导入行为;只有显式开启递归导入参数时,才会进入新的递归物化流程。

权限方面,真实飞书 Wiki 递归导入需要用户或应用具备 Wiki 节点读取、文档读取和 Drive 文件下载相关权限。文档内图片下载仍依赖现有图片下载权限配置。

axiomoth and others added 30 commits August 27, 2026 18:33
Signed-off-by: Axiomoth <alearner@splrad.com>
* fix: web url deny error code

* feat: support feishu_app_id in args

* feat: connector task add internal task tag

* fix: remove connector extra meta info

* feat: watch api add source_type

* fix: terminate Git preflight process groups on timeout

* fix: internal task meta
volcengine#4409)

* feat(admin): support wildcard matching in list-accounts and list-users

Add fnmatch-based wildcard filtering to the admin list-accounts operation,
matching the existing semantics of list-users (case-sensitive, substring
via *x*). Both operations now accept an optional name filter and limit.

- server: get_accounts(name_filter, limit) using fnmatch; list_accounts
  router exposes `name` and `limit` query params
- ov_cli: add --name/--limit flags to list-accounts
- SDKs (python/go/typescript): thread filter params through admin clients
- docs: document the new filtering params for the admin API

Also fix two pre-existing, unrelated test failures (test-only):
- test_legacy_cleanup_removes_only_legacy_namespaces: update to the
  per-agent-id cleanup contract introduced in 0102a48
- test_remove_user: raise _wait_for_task budget to accommodate the
  ~2s AGFS recursive cleanup during user deletion

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

* refactor(admin): drop limit param from list-accounts, keep name filter

The account list is consumed by callers that fetch the full set and scan
it (vikingbot root-key namespace-policy lookup, config-wizard account
menu, migration registry checks). Defaulting the HTTP route to limit=100
would silently truncate those consumers once an install has >100
accounts. The original requirement was only wildcard name matching, so
remove the limit param from the account path across route, CLI, SDKs and
manager, keeping only the fnmatch `name` filter. list-users is unchanged
(its limit predates this work and its consumers pass explicit large caps).

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

* feat(admin): add opt-in limit/page pagination to list-accounts and list-users

Both endpoints now return results in lexicographic order of ID and accept
optional limit/page query params. Pagination is opt-in: omitting limit
returns the full set, so internal full-set consumers stay unaffected.

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

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
* fix(metrics): measure volcengine embedding call latency

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

* feat(metrics): classify model call outcomes

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

* feat(metrics): expose model call error codes

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

* feat(metrics): tag memory extraction by type

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

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: Zayn <zayn@users.noreply.github.com>
…e#4391)

ZCode parses config-file hooks with strict zod rules (matcher:string().min(1),
groups .strict()). Empty-string matchers fail validation and safeParse drops
the ENTIRE user hook source silently — hooks never fire while every component
looks healthy. Omitted matcher matches all events, preserving semantics.
Incident reproduction and bundle-extraction provenance documented in operator
workspace: projects/zcode-ov-dsh-plugin/docs/post-install-verification.md

Co-authored-by: SearXNG Agent <agent@searxng.local>
…olcengine#4382)

DSH marks session-backed subagents with a durable origin field, while the OpenViking plugin previously enrolled every session in profile, recall, capture, and teardown commit. Add one opt-in boundary at the plugin hooks so operators can exclude those child sessions without changing existing installations.

Constraint: DSH classifies supported child sessions through SessionHeader.origin=subagent

Rejected: Independent capture and recall filters | teardown commit semantics would require per-state policy and a broader runtime refactor

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Do not classify from parentSession because human-driven forks may also carry lineage

Tested: Node 22.19 and Node 24 plugin checks; 341-pass memory-plugin matrix; npm pack dry-run

Not-tested: Real DSH CLI to OpenViking server E2E; unclassified timer or cron sessions

Co-authored-by: czyyyy <255856754+kwistzzqq-byte@users.noreply.github.com>
* ci: persist BuildKit mount caches across runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: key mount caches on native-build inputs, drop npm/uv from cache-map

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: support local file to external-queue

* fix: update external parser

* fix: supply internal_task

---------

Co-authored-by: chenxiaobin.monkey <chenxiaobin.monkey@bytedance.com>
* feat(acl): add resource access control

Persist direct and inherited ACL fields in context records so filesystem operations and vector retrieval enforce the same permissions.

* docs(acl): align behavior documentation

* feat(acl): 支持用户组授权

* refactor(acl): 收敛权限更新并补齐异步身份

确保 ACL 更新结果与锁内状态一致,移动失败可恢复向量 URI,并让后台解析沿用发起请求的用户组身份。精简重复实现和低价值测试。

* refactor(acl): 仅在共享资源区启用授权

个人资源保持 owner 私有,分享通过移动到共享区完成;跨区移动按目标 scope 继承或清空 ACL,并将向量权限过滤限制在共享区。

* chore(acl): 移除无关解析器清理

恢复 telemetry 空上下文和 import 的基线写法,仅保留 ACL 用户组身份传递。

* fix(acl): 收紧权限读取与过滤分页

ACL 元数据读取失败时终止权限判断,并确保 grep 的 node_limit 作用于权限过滤后的可见结果。

* refactor(acl): 收敛测试与检索取数逻辑

删除重复、时序绑定和实现细节测试,并撤销非必要的 grep 循环扩容。保留用例只覆盖独立的安全、一致性和兼容性故障。

* refactor(acl): 统一资源操作鉴权

将 write、delete 和 reindex 收敛到 read/write/manage 能力检查,并修复 add target 权限校验未等待的问题。

* refactor(acl): 枚举化权限能力

用 AclAction 和 AclLevel 约束内部权限分支,拒绝裸字符串 action,并保持 API 与存储字符串格式兼容。

* fix(acl): route reindex through unified authorization

* refactor(uri): remove redundant user content root helper

* fix(acl): align resource browsing and ingestion permissions

* test(acl): consolidate ACL contract coverage

* feat(acl): grant creators manager access

* fix(acl): scope creator grants to controlled trees

* fix(acl): isolate add-resource parent refresh authorization

* feat(acl): enforce snapshot permissions

* fix(acl): preserve content write permission errors

* feat(acl): add opt-in protection for new shared content

* feat(cli): configure automatic ACL protection

* refactor(acl): simplify authorization flow

* fix(acl): allow write permission for file move and delete

* refactor(acl): simplify group and permission semantics

* fix(acl): allow write access for snapshot file deletion

* fix(acl): preserve user-scoped reindex access

* refactor(acl): compact indexed principal grants

Store one highest-permission token per principal so in-memory authorization and vector filtering share the same ACL representation.

* fix(acl): preserve background task identity

* fix(acl): bypass acl for watch refresh
* feat(web-studio): support trusted user switching

* fix(web-studio): support trusted switching without root key
…volcengine#4447)

Move the transformContext non-user-tail guard to the top of
assembleOpenVikingSession so tool-loop assemble calls skip full-history
token estimation, message digest, session identity bookkeeping, and
client access. The passthrough returns the original messages with
estimatedTokens: 0, matching OpenClaw's legacy context-engine contract
where the caller handles estimation.

Add a lifecycle-seam regression test asserting no estimator, digest,
diagnostic, routing, or client work runs on this path, and update the
assemble integration test to expect the sentinel estimate.

Refs volcengine#4415 (item 4).
* ci: build the release docker image once, not twice

release.yml carries a full copy of the docker build/manifest jobs from
build-docker-image.yml. Both fire on the same release: the tag push triggers
build-docker-image.yml while the release event triggers release.yml's copy, so
every release builds the same image 4 times (2 arch x 2 workflows, ~16 min
each) and both pipelines race to write the same v0.4.x and latest tags.

Drop the copy. build-docker-image.yml has to exist anyway (main images, manual
dispatch) and emits the identical tag set for a tag ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01247pby58vheLBLmHPV3WKQ

* ci: assert the release's docker image actually landed

The tag push and the release event fire in the same second, so the image is
still ~16 min from existing when release.yml starts. Poll the tag's
build-docker-image run, fail on a missing or failed run, then inspect both
registries for the version tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01247pby58vheLBLmHPV3WKQ

* ci: assert latest points at the released tag too

The version tag existing was never the failure mode; latest silently staying
on the previous release was. Compare digests instead of just checking the
version tag is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01247pby58vheLBLmHPV3WKQ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…volcengine#4465)

OpenClaw >=2026.8.1 degrades any context engine to "legacy" every turn
unless info.transcriptSemantics declares currentTurnFence +
turnAdvancementIdempotency and the engine implements commitTurn.
Declare both and add an idempotent commitTurn ack keyed by
advancementKey; capture stays in afterTurn, which the host still calls.

Fixes volcengine#4103


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nup (volcengine#4479)

Group shared uploads into hourly buckets so the upload root never becomes one
huge flat directory (which made cleanup enumerate hundreds of thousands of
entries and take minutes), and rework cleanup to be bounded and best-effort.

Storage layout:
- New uploads: viking://upload/<YYYYMMDDHH>/<uuid>/{content,meta}, UTC hour.
  upload_id is <YYYYMMDDHH>-<uuid>; the 10-digit hour prefix is the bucket and a
  format marker, and the in-bucket directory is just the uuid (no repeated
  prefix). temp_file_id stays shared_<upload_id> (external contract unchanged).
- Legacy flat <13-digit-ms>-<uuid> uploads stay readable; _read_shared_meta
  picks exactly one path by id format, no fallback probe.

Cleanup (best-effort, off the request path, oldest-first via name-ascending ls):
- module-level due_at (epoch) + pending throttle so requests don't pile up
  duplicate jobs;
- YYYYMMDDHH bucket expires at bucket_start + 3600 + ttl and is removed whole
  (rm -r); scan stops at the first live bucket;
- legacy flat uploads expire by created_at + ttl and are removed individually;
- other/malformed dirs are removed only when temp_upload.cleanup_invalid_dirs is
  enabled, and legacy flat uploads are never treated as invalid;
- every deletion logs kind/uri/elapsed_ms.

Shared upload writes, failure rollback, and cleanup deletes use
auto_pathlock=False; VikingFS.rm/write_file/write_file_bytes gain an
auto_pathlock parameter (default True, no behavior change for other callers).

Add temp_upload.cleanup_invalid_dirs config flag (default False).

Co-authored-by: TRAE CLI <traecli@bytedance.com>
…cengine#4353)

* feat(ragfs): unify cache runtime providers

* refactor(ragfs): remove bundled external cache providers

* refactor(ragfs): unify cache runtime and redis backend

* chore: sync contributing guide with main

* fix(cache): harden Redis runtime compatibility

* refactor(cache): gate memory mock behind test feature

* fix(cache): harden Redis runtime integration

---------

Co-authored-by: chrisfang <chrisfang@noreply.gitcode.com>
Co-authored-by: qin-ctx <qinhaojie.exe@bytedance.com>
…e#4388)

* 修复 event paget id错误,导致未新建的问题

* fix(memory): enforce response-scoped page id uniqueness

* fix(memory): reject relations from mismatched page ids
…llback (volcengine#4429)

* feat(codex): commit on SessionEnd hook, keep SessionStart sweep as fallback

Codex ships a SessionEnd hook since rust-v0.145.0. The plugin now commits the OpenViking session from that hook (marker + detached worker within the 1s/3s budget, catching up turns the last Stop never sent) and reduces the SessionStart active-window heuristic to an ended/idle sweep. Adds a per-session lock so the Stop worker, PreCompact, SessionEnd worker and sweep no longer clobber each other's state writes, stops an unreadable transcript from resetting the capture cursor, and merges the three copies of the HTTP/transcript helpers into scripts/ov-session.mjs.

* fix(codex): guard SessionEnd partial catch-up, make the sweep catch up, token the end marker, own the lock

Review follow-up for volcengine#4429: SessionEnd no longer commits after an incomplete catch-up (tail turns were archived away); capture hooks record transcriptPath so the SessionStart sweep catches up before committing; the .ended marker's timestamp acts as a token that the SessionEnd worker and the sweep re-verify under the lock, and Stop/PreCompact/resume only clear markers older than their own start; withSessionLock stamps an owner file and takes over stale locks by atomic rename with an inode check so a taker never moves a lock a racer just created. Plugin 0.8.1.

* fix(codex): catch up ended sessions with no live id, guard unreadable transcripts, make the end marker and the lock takeover race-free

Four defects found reviewing the SessionEnd commit path:

- The SessionStart sweep cleared the `.ended` marker of a state with no live
  ovSessionId before taking the lock, so a session PreCompact had released and
  that then produced more turns lost its tail when its SessionEnd worker died.
  A marker now always enters the lock, and only a catch-up that finds nothing
  new may clear it.
- catchUpTurns reported an unreadable transcript as an empty one, so all three
  callers committed and archived a session whose tail they never saw. It now
  reports `unreadable` and they keep the session live for a later retry.
- clearEnded read the marker's timestamp and then removed the path, deleting a
  marker a concurrent SessionEnd had just written. The timestamp moved into the
  filename, so a conditional removal targets an immutable path.
- session-state.test.mjs was missing from the CI test list.

Also replaces the lock's stale takeover: renaming the directory aside left the
lock path momentarily absent, which let another racer's mkdir succeed next to
the taker. Takers now race for the `owner` file inside the directory instead.

* fix(codex): make the end marker's generation unique within a millisecond

Date.now() alone is not a generation: two SessionEnd hooks in the same
millisecond produced the same marker path, so the first one's conditional
removal took the second one's marker with it. The marker is now created
exclusively and its timestamp bumped until that succeeds; bumping rather than
randomizing keeps the names ordered, which is what the `before` cutoff
compares. The race test drops its artificial 2 ms gap and runs 500 iterations.
…rendering (volcengine#4470)

* feat(web-studio): search modes, panel collapse, and structured JSONL rendering

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C65Cb79Dvr5zLD6ib1T9Ew

* fix(web-studio): keep the palette one width across search and dir browsing

`/` and `//` put the palette into directory browsing, which was still using
the narrow layout, so the dialog resized whenever the query switched between
searching and browsing. Only the empty idle prompt stays narrow now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C65Cb79Dvr5zLD6ib1T9Ew

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
CryoThrust and others added 29 commits September 3, 2026 11:08
* fix(session): honor output language for working memory

* fix(session): preserve multiline language detection

---------

Co-authored-by: Yohanes <CryoThrust@users.noreply.github.com>
…llPart (volcengine#4601)

The chat.message hook in opencode's plugin API declares messageID as
optional (messageID?: string in @opencode-ai/plugin/dist/index.d.ts).
When opencode invokes the hook without a messageID, the null check in
prependSyntheticRecallPart causes it to return false silently — the
recall block is discarded, no context is injected, and no error is
logged. This means the autoRecall feature silently fails for all
opencode versions that don't pass messageID.

Fix: generate a timestamp-based fallback for messageID when absent.
sessionID remains the only hard requirement (it is always provided
by the opencode API).

Verified against opencode 1.18.26: synthetic recall parts are now
injected on every user message (confirmed via opencode session DB).
)

Fixes volcengine#4490

Wrap each flushSession in try/catch so one failed session during dispose does not abort later sessions.
* refactor: share vector URI rewrite rules

* feat: add strict vector transfer transactions

* feat: add transactional VikingFS copy

* feat: coordinate filesystem copy service

* feat: expose filesystem copy API

* feat: add ov cp command

* test: cover filesystem copy end to end

* test: compose CLI collection hooks

* fix: support vector copy on volcengine backend

* fix: harden copy transaction boundaries

* fix: retain copied entry in parent semantics

* fix: scan local vectors with supported sort key

* fix: refresh move target parent semantics

* fix: report missing copy target directory

* feat: rebuild transfer parent semantics from target summaries

* fix: refresh both parents after move

* docs: document filesystem copy API

* fix: adapt copy flow to canonical URI boundary

* fix: adapt transfer semantics after upstream rebase

* fix: harden copy and move transaction boundaries

* fix: preserve transfer metadata and direct ACLs

* ci: exercise copy with available capabilities

* fix: restore move vectors after ACL refresh failure

---------

Co-authored-by: chenpengfei <chenpengfei@bytedance.com>
… storage errors (volcengine#4081)

* fix(privacy): serialize config mutations under pathlock and propagate storage errors

- Hold the AGFS tree pathlock around upsert/activate_version/delete read-modify-write
  so concurrent updates no longer all read empty meta and write version 1.
- Only NotFoundError/FileNotFoundError map to empty results; storage, permission,
  and deserialization errors now propagate instead of being swallowed.
- Regression tests: concurrent upserts keep every version; exists() re-raises
  storage errors.

* fix(privacy): wait for config locks and preserve read errors
Co-authored-by: TRAE CLI <traecli@bytedance.com>
* fix(embedder): bound OpenAI HTTP connection pools

* fix(embedder): validate and close bounded pools
Co-authored-by: jr_blue_551 <jr551@github.com>
…engine#4643)

agent/pre-step currently gates user/message push in dsh-agent-loop. Overlap profileMessage and recallMessage to cut waterfall wall time.

Related to volcengine#4515.
…arker race (volcengine#4668)

The race test asserted state.ovSessionId === null after running a Stop
worker and a session-end worker concurrently, but the system has two
legal outcomes. auto-capture clears end markers older than its own start
(resume semantics), so when the SessionEnd parent writes its marker just
before the Stop hook starts — the exit race this test spawns — the
session-end worker finds no marker under the lock and exits superseded,
leaving the Stop worker's live session for the SessionStart sweep's
idle-TTL pass instead of an inline commit.

Assert the real invariants instead: exactly-once sends, cursor
consistency, only the derived cx-* session may stay live, and no stale
end marker survives either convergence.

Co-authored-by: mac <bishopapril850965@yahoo.com>
…als define none (volcengine#4632)

loadConfig() unconditionally assigned config.peerId from resolved shared
credentials, and applyLegacyConnection() (the only reader of the config
file's peerId) is skipped whenever those credentials exist. When ovcli.conf
or environment variables authenticate without actor_peer_id, the project
config's peerId was silently dropped, so sessions and memories landed in
the shared user tree instead of the peer-scoped tree.

Apply the file config's peerId as a fallback when shared credentials carry
no peer, keeping the documented precedence: shared credentials >
extension config peerId > workspace-derived peer. Same defect class as
volcengine#3649 (Pi extension, addressed by volcengine#3653 for Pi only).

Fixes volcengine#4487

Co-authored-by: mac <bishopapril850965@yahoo.com>
…cengine#4590)

Every config-mutating command replaces the whole of ~/.openviking/ovcli.conf:
`write_config_file` serializes the Rust `Config` struct over it, and
`activate_config` byte-copies the chosen profile over it. `Config` models the
connection fields only and has no serde catch-all, so any other top-level key
in the file is simply not part of what gets written back and disappears. The
casualty is the `plugin` section, which the Claude Code and Codex memory
plugins read their per-harness settings from (examples/memory-plugin-shared/
lib/plugin-config.mjs). Rewriting a URL with `ov config edit` silently wiped a
user's entire plugin configuration; `ov config add --activate`, the wizard,
a rename via `--new-name` and `ov config switch` all did the same.

The whole-file rewrite dates back to the CLI revamp (volcengine#2198); it became
destructive when the `plugin` section moved into ovcli.conf (volcengine#3534).

Enumerate the keys `Config` owns in `KNOWN_CONFIG_KEYS` and re-attach
everything else from the file being replaced before the write lands. Modeled
keys are deliberately not carried over, so unsetting one — `--clear-api-key`,
say — still clears it. On a rename the keys are inherited from the old name
rather than the destination, and on activation from the active file, since the
unmodeled keys are machine-level settings rather than part of the profile; a
profile that names such a key itself still wins.

A test asserts `KNOWN_CONFIG_KEYS` matches what `Config` serializes, so a new
field cannot be added without being listed and thereby made unclearable.
…ry carry its own config (volcengine#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.
…brary (volcengine#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/`.
…ne#4571)

* fix(resource): reject a source with no content at ingestion

Adding a zero-byte file currently succeeds and produces an empty
resource. With the Understanding API disabled -- the default, since
ParserApiConfig.enable is False -- nothing on the internal parse path
looks at the size, so the file is staged, parsed, and indexed as an
empty entry. Directory imports already refuse the same input:
directory_scan.py skips any zero-byte member as an "empty file". A
single-file import should not disagree with that.

Check it in UnifiedResourceProcessor.prepare, the one point every
ingestion path passes through: prepare_durable_source freezes a source
there before the request touches the tree, and process() calls it for
anything not frozen earlier. So local files, uploads, remote downloads,
git and Feishu sources are all covered by one check, at the moment the
bytes are first in hand.

InvalidArgumentError maps to 400 through ERROR_CODE_TO_HTTP_STATUS.
Where ingestion runs asynchronously -- a plain remote URL with the
Understanding API off is queued, and the response has already been
sent -- the same error fails the task instead of the request.

Deliberately narrow:

- Only zero bytes. A one-byte file is still accepted; this is not a
  minimum-size policy.
- Only regular files. A directory has no meaningful size and is skipped,
  so directory and repository imports containing empty files are
  unaffected.
- A stat failure is left to the normal ingestion path to report.
- content/write is untouched. Creating an empty file there is an explicit
  user action, not an ingestion accident.

The error names the caller's own file rather than the temp working copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(resource): clean rejected temporary sources

* fix(resource): preserve queued error codes

* test(resource): focus empty-source regression coverage

* ci: drop dedicated empty-resource regression step

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
)

* feat(bot): support multimodal OpenViking reads

* fix(bot): harden multimodal OpenViking reads

* fix(bot): enforce multimodal request budgets

* fix(bot): prefer recent tool media

* fix(vlm): preserve text tool output serialization

* fix(bot): scope image reads to resource owner
…lcengine#4546)

* fix(openclaw): make peer scope optional again and default to none

Peer routing became an implicit default and then stopped being offered at
all. volcengine#2626 flipped the plugin default from peer_role=none to assistant, and
--peer-role example it documented. The flag still exists in the setup command
and in the installer, but nothing on the guided install path mentions it, so
a user installing through the skill gets assistant scoping with no visible
way to choose otherwise.

Restore the choice and put the default back to none:

- default peer_role is none again in config.ts, the setup command and the
  setup helper, so memories land under the OpenViking user unless the user
  asks for separation
- the install skill asks for the memory scope again and documents all three
  values, and its setup/installer invocations expose --peer-role
- reword the option everywhere in terms of what the user gets ("one shared
  memory" / "a separate memory per assistant" / "a separate memory per
  sender") instead of "peer identity mode"

peer_role=assistant and peer_role=person are unchanged for anyone who has
them configured; only the default and the wording move.

* fix(openclaw): rename person peer role to sender
* fix(vlm): resync rotated Codex credentials

Signed-off-by: AirHua-byte <78540954+AirHua-byte@users.noreply.github.com>

* fix(vlm): preserve external auth ownership

Signed-off-by: AirHua-byte <78540954+AirHua-byte@users.noreply.github.com>

---------

Signed-off-by: AirHua-byte <78540954+AirHua-byte@users.noreply.github.com>
…rd (volcengine#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.
* perf(session): reduce commit task-store lock overhead

* perf(session): reduce phase1 fs round trips

* chore(session): trim benchmark diagnostics

* perf(session): avoid extending session write lock scope
…ne#4691)

* fix(openclaw): restore package build after peer role rename

* chore(openclaw): keep release fix minimal
…engine#4694)

An exact-match lookup — finding a record by a tag that carries an external
id, say — has no meaningful query text. Callers were forced to invent one,
which made the similarity score noise and left the result at the mercy of
whatever recall the made-up query happened to produce.

find() now accepts an empty query as long as a filter narrows the search.
The result is then fully determined by that filter, so there is nothing to
embed or rank: the request is resolved from the metadata store directly and
`score` stays 0 rather than a fabricated value callers might sort on.

Scoping goes through a new filter_in_tenant, which reuses the very same
_build_scope_filter the vector path uses. Tenant isolation, ACL grants and
target-directory limits therefore stay identical between the two paths — a
hand-built filter here would be one refactor away from silently losing them.

Validation stays at the top of find() so a bad request is still rejected
before any initialization, and an empty query with no filter is still an
error: without either one there is nothing to narrow by, and returning an
arbitrary slice of the store would be worse than failing.

search() is deliberately left alone: it expands intent from session context,
which has no meaning without a query.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@sponge225 sponge225 closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.