Compare commits

..

80 Commits

Author SHA1 Message Date
Vincent Koc
b7c0b82027 feat(android): localize every native locale 2026-06-26 18:04:24 -07:00
Vincent Koc
d3531495a4 chore(i18n): refresh native inventory after Android migration 2026-06-26 18:04:24 -07:00
Vincent Koc
15cae07699 feat(android): localize gateway onboarding 2026-06-26 18:04:23 -07:00
Vincent Koc
afbbd2ab16 fix(i18n): restrict native UI extraction 2026-06-26 18:04:06 -07:00
Vincent Koc
9580fad305 fix(i18n): filter non-translatable native literals 2026-06-26 17:58:34 -07:00
Vincent Koc
9df3467360 fix(i18n): cover all native source roots 2026-06-26 17:54:21 -07:00
Vincent Koc
ac70e9ddda fix(i18n): inventory conditional native labels 2026-06-26 17:49:14 -07:00
Vincent Koc
bfca9b2447 fix(i18n): align native scan scope and build exclusions 2026-06-26 17:44:07 -07:00
Vincent Koc
3d06c4bc24 feat(i18n): inventory native resources and wrappers 2026-06-26 17:39:57 -07:00
Vincent Koc
8f9aca8aaa fix(i18n): parse native interpolation expressions 2026-06-26 17:31:58 -07:00
Vincent Koc
6f0d8c2097 fix(i18n): preserve Kotlin native placeholders 2026-06-26 17:26:37 -07:00
Vincent Koc
c5884957ff ci(i18n): run native checks for tooling changes 2026-06-26 17:21:49 -07:00
Vincent Koc
22d0780a89 fix(i18n): preserve native placeholders and whitespace 2026-06-26 17:16:33 -07:00
Vincent Koc
126fc2f0b4 fix(i18n): skip non-runtime native source literals 2026-06-26 17:11:47 -07:00
Vincent Koc
67cf97ef55 fix(i18n): guard native inventory in CI 2026-06-26 17:07:02 -07:00
Vincent Koc
8cbd6c78c8 fix(i18n): keep native refresh inventory clean 2026-06-26 17:03:11 -07:00
Vincent Koc
1545198f8b feat(i18n): define native locale matrix 2026-06-26 16:55:51 -07:00
Vincent Koc
20f5648a2e feat(i18n): inventory native app UI strings 2026-06-26 16:55:51 -07:00
joshavant
ff35f3bb2c Track mobile release SHAs with refs 2026-06-26 18:54:45 -05:00
Vincent Koc
ff18374293 Merge branch 'main' of https://github.com/openclaw/openclaw
* 'main' of https://github.com/openclaw/openclaw:
  fix(codex): wait for native tool completion (#96818)
2026-06-26 16:52:48 -07:00
Vincent Koc
fa78cfbfb7 Reapply "docs: document agent issue and PR routing (#96714)"
This reverts commit c691872b9e.
2026-06-26 16:52:45 -07:00
xingzhou
8252fc009f fix(codex): wait for native tool completion (#96818)
* fix(codex): wait for native tool completion

* fix(codex): track native execution lifecycles

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:52:31 +01:00
Vincent Koc
c691872b9e Revert "docs: document agent issue and PR routing (#96714)"
This reverts commit 43dd34262e.
2026-06-26 16:52:17 -07:00
openclaw-release-bot
e0932e0bc4 chore(release): update appcast for 2026.6.10 2026-06-26 23:46:53 +00:00
Patrick Erichsen
808c227edb feat: scaffold provider plugins from init (#94352)
* feat: scaffold provider plugins from init

* fix: satisfy plugin init scaffold CI guards

* fix: preserve plugin init id argument
2026-06-26 16:43:51 -07:00
mushuiyu886
deb0ffdcdf fix #94040: [Bug]: nodes approve failed: GatewayClientRequestError: unknown requestId (#94452)
* fix(nodes): explain unknown approval request ids

* fix(nodes): keep stale request handling CI-clean

* fix(nodes): point stale approve hint at pending command

* fix(nodes): explain stale approval request ids

* fix(nodes): make stale approval guidance reliable

* fix(nodes): preserve stale approval error context

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:40:29 +01:00
mushuiyu886
a846b879ec fix(mcp): include image source for screenshot results (#90902)
* fix(mcp): emit image content with base64 source

* fix(mcp): keep plugin tool images in SDK schema

* test(mcp): exercise image bridge end to end

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:39:40 +01:00
Eva
43dd34262e docs: document agent issue and PR routing (#96714)
* docs: document agent issue and pr routing

* docs: link contribution routes from readme

* docs: point routing docs at github codeowners

* docs(contributing): keep routing guidance concise

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:34:58 +01:00
Galin Iliev
6883c6c070 fix: wake yielded parent after subagents finish (#97090)
* fix: wake yielded subagent parents after descendants settle

* fix: wake yielded subagent parents after descendants settle

---------

Co-authored-by: Galin Iliev <Galin.Iliev@microsoft.com>
2026-06-26 16:31:20 -07:00
xingzhou
91726e9624 fix(cron): reject invalid no-output timeout (#96516)
* fix(cron): reject invalid no-output timeout

* fix(cron): validate command output limits

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:28:45 +01:00
Bartok
289865b392 fix(matrix): truncate thread starter body on code-point boundaries (#97121)
Matrix thread-starter previews truncated long bodies by raw UTF-16
slice, which could cut an astral character (e.g. emoji) and leave a lone
surrogate, rendering mojibake in the agent's thread context.

Reuse the existing sliceUtf16Safe helper so the cut backs up to a valid
surrogate boundary, preserving the 500-code-unit limit and '...' suffix.
Adds a regression test that fails against the raw-slice implementation.

Salvages the original fix from #96407 (auto-closed by the active-PR
queue cap). Preserves @ly-wang19's authorship; rebased clean onto main
by @Bartok9.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 07:27:38 +08:00
pick-cat
6db4624f43 fix(auth): suppress recovery hint for format failures (#95779)
* fix(auth): suppress recovery hint for format failures

* test(auth): cover format failure recovery copy

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:27:25 +01:00
ly-wang19
b7a9d3005c perf(ui): use sets for usage selection filters (#96945)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 07:23:59 +08:00
Dallin Romney
bdd365a348 docs: clarify macOS release workflow refs (#97116)
* docs: clarify macOS release workflow refs

* docs: clarify macOS release repo roles

* docs: document macOS release approval gate

* docs: align maintainer macOS release repo refs
2026-06-26 16:08:08 -07:00
Vincent Koc
a82902c725 ci: right-size runner registration caps (#97119) 2026-06-27 07:01:26 +08:00
ly-wang19
aca905cce5 perf(sessions): find matching checkpoints without sorting (#96964)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 06:53:48 +08:00
zw-xysk
ab966c214b fix(tools): treat no-op writes and edits as terminal tool-loop failures (fixes #96983) (#97044)
* fix(tools): treat no-op writes and edits as terminal tool-loop failures

Fixes #96983

* fix(tools): treat no-op writes and edits as terminal tool-loop failures

Fixes #96983

* fix(tools): preserve valid sibling edits in mixed no-op batches

Fixes #96983

* fix(tools): terminate apply_patch no-ops safely

* fix(tools): validate no-op edits independently

* fix(tools): preserve no-op edit overlap checks

* fix(tools): preserve no-op patch file formatting

* fix(tools): preserve move no-op formatting

* fix(tools): narrow same-path move no-op typing

* fix(tools): distinguish edit no-op errors

* fix(tools): keep edit previews aligned with execution

* fix(tools): align no-op validation and formatting

* fix(tools): preserve empty patch and preview no-ops

* fix(tools): preview fuzzy edit no-ops cleanly

* fix(tools): isolate fuzzy-equivalent edit no-ops

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 06:41:26 +08:00
ly-wang19
41c00a65d6 perf(status): select recent sessions without full sort (#96955)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 06:33:00 +08:00
Vincent Koc
eba1ca683f test(i18n): include Hindi and Russian registry locales 2026-06-26 15:23:10 -07:00
github-actions[bot]
b3eee03740 chore(ui): refresh fa control ui locale 2026-06-26 22:08:51 +00:00
github-actions[bot]
6109420e5c chore(ui): refresh nl control ui locale 2026-06-26 22:08:49 +00:00
github-actions[bot]
44e522cf6b chore(ui): refresh vi control ui locale 2026-06-26 22:08:46 +00:00
github-actions[bot]
ab8cd3dac9 chore(ui): refresh th control ui locale 2026-06-26 22:08:17 +00:00
github-actions[bot]
816c2cf1f8 chore(ui): refresh pl control ui locale 2026-06-26 22:08:08 +00:00
github-actions[bot]
9cc10a8382 chore(ui): refresh id control ui locale 2026-06-26 22:08:02 +00:00
github-actions[bot]
c6757d7a75 chore(ui): refresh uk control ui locale 2026-06-26 22:07:51 +00:00
github-actions[bot]
a70e7ce24b chore(ui): refresh tr control ui locale 2026-06-26 22:07:28 +00:00
github-actions[bot]
6b98d179b6 chore(ui): refresh ar control ui locale 2026-06-26 22:07:23 +00:00
github-actions[bot]
cb4e9e4118 chore(ui): refresh it control ui locale 2026-06-26 22:07:14 +00:00
github-actions[bot]
0023cc816a chore(ui): refresh fr control ui locale 2026-06-26 22:07:01 +00:00
github-actions[bot]
6d62dae215 chore(ui): refresh ko control ui locale 2026-06-26 22:06:43 +00:00
github-actions[bot]
8d61631b40 chore(ui): refresh ja-JP control ui locale 2026-06-26 22:06:35 +00:00
github-actions[bot]
68bed5e902 chore(ui): refresh es control ui locale 2026-06-26 22:06:32 +00:00
github-actions[bot]
7b549a26e8 chore(ui): refresh de control ui locale 2026-06-26 22:06:08 +00:00
github-actions[bot]
57f62a5fd9 chore(ui): refresh pt-BR control ui locale 2026-06-26 22:06:03 +00:00
github-actions[bot]
ba70d365ac chore(ui): refresh zh-TW control ui locale 2026-06-26 22:05:56 +00:00
github-actions[bot]
ce88d65779 chore(ui): refresh zh-CN control ui locale 2026-06-26 22:05:52 +00:00
Vincent Koc
689baa5c1e feat(i18n): add Hindi and Russian docs and Control UI locales 2026-06-26 15:01:15 -07:00
ly-wang19
4c4396c4c2 perf(memory): copy only requested embedding dimensions (#96952)
* perf(memory): copy only requested embedding dimensions

* perf(memory): copy only requested embedding dimensions

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:56:02 +08:00
ly-wang19
c1336b6b41 perf(plugins): classify cached tool candidates once (#96948)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 05:49:35 +08:00
ly-wang19
d4a01e48bc fix(link-understanding): strip markdown links whose label contains brackets (#96476)
Merged via squash.

Prepared head SHA: 2d69ed259f
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-27 05:43:11 +08:00
ly-wang19
a0e9ca1e95 perf(update): reuse missing plugin payload id set (#96950)
* perf(update): reuse missing plugin payload id set

* perf(update): reuse missing plugin payload id set

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:32:38 +08:00
ly-wang19
1b6557dfa2 fix(markdown): a fenced-code line with trailing text is content, not a closing fence (#96745)
* fix(markdown): a fenced-code line with trailing text is content, not a closing fence

scanFenceSpans accepted any line starting with >=3 matching fence markers as a
closing fence, ignoring trailing text after the marker. Per CommonMark a closing
fence may be followed only by whitespace, so a code-content line such as
"``` not a close" was wrongly treated as a close: the block ended early, the
following lines were reported as outside any fence, and the trailing marker line
became a new unclosed opener.

That made isSafeFenceBreak() return true for offsets inside the real code block
and findFenceSpanAt() return undefined, so chunkers (chunkMarkdownText, the
embedded-agent block chunker) could split inside a fenced code block — the exact
thing this module exists to prevent.

Require the closing fence's trailing text to be whitespace-only. Opening info
strings, bare closes, and longer same-marker closes are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(markdown): honor fence suffix whitespace rules

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:32:10 +08:00
Bartok
2968004680 fix(chunk): keep surrogate pairs whole when hard-splitting an over-long line (#96951)
chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:21:45 +08:00
Bek
9636bea901 perf(memory): add QMD search diagnostics and runtime cache (#96655) 2026-06-26 16:16:12 -04:00
NianJiu
1089253ca9 fix feishu login qr rendering (#97087)
Co-authored-by: NianJiuZst <180004567+users.noreply.github.com>
2026-06-26 12:09:38 -07:00
Hannes Rudolph
e5123e44b0 docs: update changelog for v2026.6.10 (#97079)
* docs: update changelog for v2026.6.10

* docs: update changelog for v2026.6.10
2026-06-26 12:22:40 -06:00
Shakker
1cd6f81a46 fix: close memory test failure paths 2026-06-26 14:05:07 +01:00
Shakker
80c754ddf4 test: normalize feishu env fixtures 2026-06-26 13:19:33 +01:00
maweibin
512f0f1bf7 fix(webchat): make model selector width adaptive to prevent long name… (#96990)
Summary:
- The PR updates `ui/src/styles/chat/layout.css` so the WebChat composer model selector can size to long model/thinking labels and also changes the base inline-select menu width rule.
- PR surface: Source +1. Total +1 across 1 file.
- Reproducibility: yes. from source inspection: current main and `v2026.6.10` combine model and thinking text  ... ro in this read-only review, but the PR's inspected before screenshot demonstrates the reported truncation.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 0cf1a65852.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0cf1a65852
Review: https://github.com/openclaw/openclaw/pull/96990#issuecomment-4807967550

Co-authored-by: 0668000787 <ma.weibin@xydigit.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approved-by: takhoffman
2026-06-26 12:18:28 +00:00
Shakker
338e119533 fix: preserve watcher test state 2026-06-26 13:05:44 +01:00
Shakker
e4f63577d0 test: scope manager reindex state 2026-06-26 12:56:21 +01:00
Shakker
94d93d4c85 fix: retain qmd test environment state 2026-06-26 12:48:24 +01:00
Shakker
7718e25b2a test: preserve memory startup env state 2026-06-26 12:40:55 +01:00
Shakker
8079aa62a2 fix: isolate memory index test state 2026-06-26 12:32:39 +01:00
Shakker
6f162f321a test: scope narrative env cleanup 2026-06-26 12:23:32 +01:00
NIO
527f8f0cbb fix(image-gen): bound image generation provider JSON response reads (#96495)
* fix(image-gen): bound image generation provider JSON response reads

Route success JSON reads through readProviderJsonResponse (16 MiB cap)
in openrouter, google, fal, minimax, openai, and vydra image generation
providers to prevent OOM from oversized or hostile endpoint responses.
Mirrors the response-limit campaign already applied to other provider paths.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(image-gen): size bounded JSON caps for inline image payloads

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-26 07:08:30 -04:00
Shakker
c05d0d5bbf fix: restore dreaming env after setup 2026-06-26 11:12:52 +01:00
Shakker
535af4452b test: scope dreaming env batch one 2026-06-26 11:12:04 +01:00
Jesse Merhi
ec737ee74d fix: rebase clawhub install trust (#81364) 2026-06-26 18:33:19 +10:00
310 changed files with 39147 additions and 1238 deletions

View File

@@ -13,12 +13,13 @@ registration edge limit.
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
capacity.
- GitHub runner registrations are capped at 1,500 per 5 minutes per repository,
organization, or enterprise. The `openclaw` organization shares one bucket.
- GitHub runner registrations for `openclaw` are currently capped at 3,000 per
5 minutes per repository, organization, or enterprise. The `openclaw`
organization shares one bucket.
- Core REST quota does not draw down this bucket. Check
`actions_runner_registration` separately; core quota can be healthy while
runner registration is throttled.
- Use 1,000 registrations per 5 minutes as the operating target. Leave the last
- Use 2,000 registrations per 5 minutes as the operating target. Leave the last
third for other repos, retries, and burst overlap.
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
scans should stay on GitHub-hosted runners unless measured evidence says
@@ -87,7 +88,7 @@ admission. The debounce only suppresses pushes that arrive while
registrations are spent even if a later push cancels the run. If timing is
uncertain, count every sequential push in the window.
Reject a change unless the org-level worst case stays below 1,000 registrations
Reject a change unless the org-level worst case stays below 2,000 registrations
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
and Clawbench.
@@ -127,8 +128,8 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- `runner-admission` on `ubuntu-24.04` with
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
- `preflight` and `security-fast` needing `runner-admission`.
- CI matrix caps: fast/check lanes at 8, compact Node PR plan at current caps,
Windows and Android at 2.
- CI matrix caps: fast/check lanes at 12, Node test shards at 24, Windows and
Android at 2.
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.

View File

@@ -5,7 +5,7 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
# OpenClaw Mac Release
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
@@ -23,7 +23,7 @@ Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Target release-ops repo environment: `openclaw/releases`, env `mac-release`.
Set only after local notary auth validation:
@@ -35,12 +35,24 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Workflow Shape
- `openclaw/openclaw` is the public product repo. Its GitHub Releases page is
where macOS assets are ultimately attached.
- `openclaw/openclaw` `macos-release.yml` is public handoff validation only.
It never signs, notarizes, or uploads macOS assets, regardless of
`preflight_only`.
- `openclaw/releases` is the restricted release-ops repo. Its macOS workflows
sign, notarize, validate, and promote assets onto the
`openclaw/openclaw` GitHub release.
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
- Use `source_ref=release/YYYY.M.PATCH` for release-ops mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- a successful release-ops mac preflight run for the same tag/source SHA
- a successful release-ops mac validation run for the same tag/source SHA
- Release-ops preflight and real publish enter the protected `mac-release`
environment in the `build_sign_and_package` job. Operators may be able to
trigger the workflow while Vincent or another environment reviewer approves
the paused deployment before signing/notarization/promotion proceeds.
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
@@ -52,10 +64,25 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Dispatch
Private preflight:
Public handoff validation:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
gh workflow run macos-release.yml --repo openclaw/openclaw \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f preflight_only=true \
-f public_release_branch=release/YYYY.M.PATCH
```
- Use the public release branch as the workflow ref so the Actions list displays
`release/YYYY.M.PATCH`, matching prior stable macOS handoff runs.
- Do not use `--ref main` or `--ref vYYYY.M.PATCH` for this public handoff
validation. The workflow checks out the tag from the `tag` input internally.
Release-ops preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f preflight_only=true \
@@ -64,18 +91,24 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
-f public_release_branch=release/YYYY.M.PATCH
```
Private validation for a branch-variation preflight:
Wait for the run to reach the `mac-release` environment approval if GitHub
pauses it, then get approval from Vincent or another configured environment
reviewer. Record the successful preflight run id.
Release-ops validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH
```
Record the successful validation run id.
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f preflight_only=false \
-f smoke_test_only=false \
@@ -85,6 +118,14 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
-f public_release_branch=release/YYYY.M.PATCH
```
Wait for the `mac-release` environment approval again if GitHub pauses the real
publish run before it promotes assets.
- Release-ops `openclaw/releases` publish/validate workflows run from their own
trusted `main` workflow ref. Real publish has a guard that rejects any other
workflow ref. That displayed `main` ref is expected; the public OpenClaw
source is selected by `tag` and optional `source_ref`.
## Verify
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.

View File

@@ -203,8 +203,9 @@ Stable publication is not complete until `main` carries the actual shipped relea
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
Dispatch the release-ops mac workflows for the original `tag=vYYYY.M.PATCH`
with `source_ref=release/YYYY.M.PATCH` and
`public_release_branch=release/YYYY.M.PATCH`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
tags for emergency hotfixes that must publish a new npm package/release
@@ -579,8 +580,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
workflow in `openclaw/openclaw` and the release-ops mac preflight in
`openclaw/releases` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
@@ -608,16 +609,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
- Stable npm promotion from `beta` to `latest` uses the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
public npm release workflow stays OIDC-only.
- Prefer fixing the private workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the private
- Stable npm promotion from `beta` to `latest` uses the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
because `npm dist-tag` management needs `NPM_TOKEN`, while the public npm
release workflow stays OIDC-only.
- Prefer fixing the release-ops workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the release-ops
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
and 2FA bypass for automation.
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
or stale, use the local tmux + 1Password fallback:
- If the release-ops dist-tag workflow cannot promote because `NPM_TOKEN` is
absent or stale, use the local tmux + 1Password fallback:
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
are observable and recoverable.
- Hard rule: never run `op` directly in the main agent shell during release
@@ -635,21 +636,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
- Direct stable publishes can also use that private dist-tag workflow to point
`beta` at the already-published `latest` version when the operator wants both
tags aligned immediately.
- Direct stable publishes can also use that release-ops dist-tag workflow to
point `beta` at the already-published `latest` version when the operator wants
both tags aligned immediately.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the private mac publish workflow accept
- The npm workflow and the release-ops mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id and the
successful Full Release Validation run id for the same tag/SHA so the publish
job promotes the prepared tarball instead of rebuilding it and attaches the
correct release evidence.
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
- Real release-ops mac publish requires a prior successful release-ops mac
preflight run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
- The release-ops mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
@@ -670,27 +671,27 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
use only `main` or `release/YYYY.M.PATCH`.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo. It still rebuilds the JS outputs needed for
operators to the release-ops repo. It still rebuilds the JS outputs needed for
release validation, but it does not sign, notarize, or publish macOS
artifacts.
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
is the required private mac validation lane for `swift test`; keep it green
- `openclaw/releases/.github/workflows/openclaw-macos-validate.yml` is the
required release-ops mac validation lane for `swift test`; keep it green
before any real stable mac publish run starts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac validation lane runs on GitHub's standard macOS runner.
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml`.
- The release-ops mac validation lane runs on GitHub's standard macOS runner.
- The release-ops mac preflight path runs on GitHub's xlarge macOS runner and uses
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
- Release-ops mac preflight uploads notarized build artifacts as workflow
artifacts instead of uploading public GitHub release assets.
- Release-ops smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- For stable releases, npm preflight, Full Release Validation, public mac
validation, private mac validation, and private mac preflight must all pass
before any real publish run starts. For beta releases, npm preflight and Full
Release Validation must pass before npm publish unless the operator explicitly
waives the full gate; mac beta validation is still only required when
requested.
validation, release-ops mac validation, and release-ops mac preflight must all
pass before any real publish run starts. For beta releases, npm preflight and
Full Release Validation must pass before npm publish unless the operator
explicitly waives the full gate; mac beta validation is still only required
when requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
@@ -699,21 +700,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
private mac preflight artifact preparation and real publish artifact
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for
release-ops mac preflight artifact preparation and real publish artifact
promotion.
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
- Real release-ops mac publish uploads the packaged `.zip`, `.dmg`, and
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
private repo `mac-release` environment.
release-ops repo `mac-release` environment.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
`macos-appcast-<tag>` artifact from the successful release-ops mac workflow
and then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
- The release-ops repo targets a dedicated `mac-release` environment. If the
GitHub plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on restricted repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for the OpenClaw package
publish path; package publishing uses trusted publishing.
@@ -800,12 +801,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
18. For stable releases, start `.github/workflows/macos-release.yml` in
`openclaw/openclaw` and wait for the public validation-only run to pass.
19. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
`openclaw/releases/.github/workflows/openclaw-macos-validate.yml` with the
same tag and wait for the release-ops mac validation lane to pass.
20. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` with
`preflight_only=true` and wait for it to pass. Save that run id because the
real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
@@ -861,22 +862,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
Then start the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`, then
verify `latest` now points at that version.
Then start the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
to promote that stable version from `beta` to `latest`, then verify
`latest` now points at that version.
29. If the stable release was published directly to `latest` and `beta` should
follow it, start that same private dist-tag workflow to point `beta` at the
stable version, then verify both `latest` and `beta` point at that version.
follow it, start that same release-ops dist-tag workflow to point `beta` at
the stable version, then verify both `latest` and `beta` point at that
version.
30. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for the
real publish with the successful release-ops mac `preflight_run_id` and wait
for success.
31. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, verify the feed, then
release-ops mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
33. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production

View File

@@ -848,6 +848,32 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
- name: Check Android app i18n resources
if: needs.preflight.outputs.run_android == 'true'
run: pnpm android:i18n:check
checks-fast-core:
permissions:
contents: read
@@ -858,7 +884,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -977,7 +1003,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1058,7 +1084,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1212,8 +1238,8 @@ jobs:
strategy:
fail-fast: false
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
# widen this large matrix within the current runner-registration budget.
max-parallel: 24
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1351,7 +1377,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix:
include:
- check_name: check-guards
@@ -1493,7 +1519,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix:
include:
- check_name: check-additional-boundaries-a
@@ -1843,7 +1869,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
@@ -2324,7 +2350,7 @@ jobs:
exit 1
- name: Setup Java
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: temurin
# Keep sdkmanager on the stable JDK path for Linux CI runners.

View File

@@ -73,7 +73,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -102,7 +102,7 @@ jobs:
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -260,7 +260,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -250,7 +250,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -190,7 +190,7 @@ jobs:
mantis-slack-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -362,7 +362,7 @@ jobs:
install-bun: "true"
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false
@@ -445,7 +445,7 @@ jobs:
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}

View File

@@ -337,7 +337,7 @@ jobs:
mantis-telegram-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -275,7 +275,7 @@ jobs:
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml

View File

@@ -0,0 +1,51 @@
name: Plugin Init Scaffold Validation
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate-provider-scaffold:
name: Validate provider scaffold
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Generate and validate provider scaffold
run: pnpm test:plugins:init-provider-scaffold

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -115,7 +115,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"

View File

@@ -143,6 +143,9 @@ Skills own workflows; root owns hard policy and routing.
## GitHub / PRs
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.

View File

@@ -10,42 +10,92 @@ Docs: https://docs.openclaw.ai
## 2026.6.10
Automatic fast mode starts short conversations quickly, then returns longer or fallback work to normal mode without losing visible state. Provider routing, channel progress, session identity, and trusted tool policies are more reliable, with smaller improvements spanning provider setup, diagnostics, and transcript tooling.
### Highlights
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
#### Automatic fast mode
### Changes
- Adds [`/fast auto`](https://docs.openclaw.ai/tools/thinking) so short conversational calls can start quickly, while longer or fallback work returns to normal mode with the effective state still visible. [PR #85104](https://github.com/openclaw/openclaw/pull/85104), [Issue #85087](https://github.com/openclaw/openclaw/issues/85087). Thanks @alexph-dev and @vincentkoc.
- Shows the effective automatic fast-mode state in status instead of reducing it to on/off, and avoids carrying a cleared Codex service-tier choice into later runs. [8845f2f](https://github.com/openclaw/openclaw/commit/8845f2fd6143becc37110ab5021dd5e1517f0cdc). Thanks @vincentkoc.
- Keeps automatic fast-mode timing consistent when a turn switches to a fallback model. [075091d](https://github.com/openclaw/openclaw/commit/075091d0cab94053ff094268efc0acb225d514f4). Thanks @vincentkoc.
- Keeps the original fast-mode timing and progress behavior when a live model switch retries a turn. [d1e190f](https://github.com/openclaw/openclaw/commit/d1e190fbe822ad6ae4e660ce376b60ec9fdb0fba). Thanks @vincentkoc.
- Keeps automatic fast-mode progress and reset behavior distinct from explicit fast mode after a run switches modes. [20aec98](https://github.com/openclaw/openclaw/commit/20aec985545db7a24ea066e5bff1c47b789cbded). Thanks @vincentkoc.
- Shows the effective fast-mode value in connected-agent sessions instead of the configured value, so status reflects what the session is actually using. [9509aa0](https://github.com/openclaw/openclaw/commit/9509aa063c0ef3e32be1516fcb0c23606b6d5c7b). Thanks @vincentkoc.
- Keeps the effective automatic fast-mode setting visible through fallback transitions in connected-agent sessions. [7f5423c](https://github.com/openclaw/openclaw/commit/7f5423ca97174a3f16c211db54a6c96e5b3a6089). Thanks @vincentkoc.
- Keeps automatic fast-mode timing and progress consistent when reply and [scheduled-agent runs](https://docs.openclaw.ai/automation/cron-jobs) retry or switch models. [6c29f88](https://github.com/openclaw/openclaw/commit/6c29f88913796bfe05696556cd82246670b126f0). Thanks @vincentkoc.
- Keeps fast-mode cleanup and status consistent when a run switches between fallback models. [c4694f8](https://github.com/openclaw/openclaw/commit/c4694f84ffd52064f89609098cc4f8570fb72e1b). Thanks @vincentkoc.
- Shows the automatic fast-mode reset only when fallback work is finished, so status messages match the end of the transition. [f4d93c8](https://github.com/openclaw/openclaw/commit/f4d93c855bff6930f5e5d739b95e0c2612ec4899). Thanks @vincentkoc.
- Shows reset and delivery progress at the right time when auto-reply or other follow-up runs retry or leave automatic fast mode. [684e440](https://github.com/openclaw/openclaw/commit/684e44013778bd47d159e64b2595e4d09a92ebea). Thanks @vincentkoc.
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
### Channels and Messaging
### Fixes
#### Channel delivery and progress updates
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
- Prevents the next turn after a [scheduled message](https://docs.openclaw.ai/automation/cron-jobs) from losing what was delivered or whether delivery failed, so replies can use that context without exposing cron details in the channel. [PR #93580](https://github.com/openclaw/openclaw/pull/93580). Thanks @jalehman and @scotthuang.
- Prevents streamed channel progress from dropping a repeated status that represents a separate step, so each meaningful step remains visible in the draft. [2d42e52](https://github.com/openclaw/openclaw/commit/2d42e52ac5513e0bd824b8a0e069db83e04bc056). Thanks @vincentkoc.
- Prevents keyed streamed progress from staying on an older status, so viewers see the latest state instead of stale text. [8bb6472](https://github.com/openclaw/openclaw/commit/8bb6472c4de2eea06f1ba31d6ed679e2ac4581b0). Thanks @vincentkoc.
### Complete contribution record
### Providers and Models
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Provider model catalogs and reasoning controls
#### Pull requests
- Treats Zhipu/GLM overload responses as overloads, so a configured fallback is selected for the right reason instead of following the wrong failover path. [PR #93241](https://github.com/openclaw/openclaw/pull/93241), [Issue #93211](https://github.com/openclaw/openclaw/issues/93211). Thanks @0xghost42 and @zhengli0922.
- Prevents Telegram, Slack, and Discord `/think` menus for live Ollama models from hiding supported levels, so users can choose valid reasoning settings without guessing. [PR #94067](https://github.com/openclaw/openclaw/pull/94067), [Issue #93835](https://github.com/openclaw/openclaw/issues/93835). Thanks @civiltox and @openperf.
- Expands [`zai/glm-5.2` thinking choices](https://docs.openclaw.ai/tools/thinking) beyond binary on/off and sends high or max requests as the intended Z.AI reasoning effort. [PR #94136](https://github.com/openclaw/openclaw/pull/94136). Thanks @borclaw.
- Prevents bundled [Z.ai GLM-5 models](https://docs.openclaw.ai/providers/zai) from falling through to OpenAI and producing misleading API-key errors, so they use Z.AI by default. [PR #94461](https://github.com/openclaw/openclaw/pull/94461), [Issue #94269](https://github.com/openclaw/openclaw/issues/94269). Thanks @chrysb and @pandah97.
- Adds GLM-5.2 and Kimi K2.7 Code to the [OpenCode Go catalog](https://docs.openclaw.ai/providers/opencode-go) with current limits, so users can select the models from OpenClaw. [66f84a9](https://github.com/openclaw/openclaw/commit/66f84a9bf1082de26f92b2b3741cc2f34aba34fa). Thanks @samson1357924.
- Corrects `kimi-k2.7-code` capability listings so OpenCode Go users are not offered unsupported video prompts when the model accepts text and images. [715dc71](https://github.com/openclaw/openclaw/commit/715dc718fc5a2a5d6f7e9ec16e0269382b726e83).
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
#### Provider plugin onboarding
- Prevents first-run setup from skipping the selected provider's credential prompt after plugin installation, so onboarding continues with that provider instead of falling back to OpenAI. [PR #95792](https://github.com/openclaw/openclaw/pull/95792), [Issue #95765](https://github.com/openclaw/openclaw/issues/95765). Thanks @snowzlmbot.
### Memory, Sessions, and State
#### Session transcript SDK helpers
- Adds a durable [session-transcript SDK contract](https://docs.openclaw.ai/plugins/sdk-runtime) so plugins can read, append, publish, and lock the intended transcript without treating [legacy file paths](https://docs.openclaw.ai/plugins/sdk-subpaths) as identity. [PR #95030](https://github.com/openclaw/openclaw/pull/95030). Thanks @jalehman.
#### Cross-channel session identity
- Prevents a shared direct-message [session](https://docs.openclaw.ai/concepts/session) from carrying the previous [channel's identity](https://docs.openclaw.ai/channels/channel-routing) after a switch, so status, reactions, threads, and message references target the current channel. [PR #95328](https://github.com/openclaw/openclaw/pull/95328), [Issue #95325](https://github.com/openclaw/openclaw/issues/95325). Thanks @gorkem2020, @jalehman, and @zengwen-dt.
### Gateway, Security, and Trust
#### Prompt context boundaries
- Keeps empty prompts separate from hook-added context during compaction or session reuse in [Copilot and Codex sessions](https://docs.openclaw.ai/plugins/copilot), so prompt boundaries remain consistent. [PR #94838](https://github.com/openclaw/openclaw/pull/94838). Thanks @vincentkoc.
#### Trusted tool policy enforcement
- Keeps [approval-sensitive Gateway and plugin tools](https://docs.openclaw.ai/plugins/hooks) protected when connected extensions change, so configured safeguards continue to apply. [PR #94545](https://github.com/openclaw/openclaw/pull/94545). Thanks @jesse-merhi.
#### Trusted package redirects
- Prevents authenticated package-source tokens from being sent to an allowed redirect on another origin, while the valid redirected download still completes. [b0df6dc](https://github.com/openclaw/openclaw/commit/b0df6dc10eb5b9e9fdca93063a16316f8589954e).
### Clients and Interfaces
#### Docker and Podman setup timeouts
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
### Plugins, Packaging, and QA
#### Codex service-tier clearing
- Prevents cleared [Codex service tiers](https://docs.openclaw.ai/tools/thinking) from being persisted as explicit stale state, so resumed or switched conversations use the normal default instead. [cd32d9f](https://github.com/openclaw/openclaw/commit/cd32d9ff91caf84c0ead38796ef096cdc5bea06e). Thanks @vincentkoc.
#### StepFun provider installation
- Restores [ClawHub discovery](https://docs.openclaw.ai/plugins/reference/stepfun) for the [StepFun provider](https://docs.openclaw.ai/providers/stepfun) plugin, so operators can install it through either ClawHub or npm. [ecb82f1](https://github.com/openclaw/openclaw/commit/ecb82f1be93024be23c1b191ebea92c63230b6c0). Thanks @vincentkoc.
### Docs and Operator Workflows
#### Doctor check ordering
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
## 2026.6.9

View File

@@ -97,6 +97,23 @@ Welcome to the lobster tank! 🦞
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Issue, PR, and Contact Routing
Start from this routing map before creating GitHub items:
| Situation | Use | Required evidence |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Product bug, regression, crash, or behavior defect | [Bug report](https://github.com/openclaw/openclaw/issues/new?template=bug_report.yml) | Repro steps, expected vs actual behavior, version, OS, model/provider route when relevant, logs/screenshots, impact |
| Documentation bug or missing/contradictory docs | [Docs bug report](https://github.com/openclaw/openclaw/issues/new?template=docs_bug_report.yml) | Affected docs path or URL, verification steps, expected docs content, actual docs content, impact, evidence |
| New feature, architecture change, or product improvement | [Feature request](https://github.com/openclaw/openclaw/issues/new?template=feature_request.yml) or Discord first | Problem, proposed solution, alternatives, impact, examples or prior art |
| Onboarding, setup help, or general support question | Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) | Do not open a GitHub issue unless there is a concrete product defect or docs gap |
| Security vulnerability | See [Report a Vulnerability](#report-a-vulnerability) below | Do not file public issues for private security reports |
| PR for an existing or newly filed issue | Use the [PR template](.github/pull_request_template.md) | Visible `Closes #<issue>` or `Related: #<issue>`, problem, shipped solution, user impact, validation evidence |
For agent-authored or otherwise non-trivial work, create or reuse the issue first, then open the PR against it. Bugs and very small fixes may go straight to PR, but still link existing context when it exists and fill out the PR template.
Do not guess who to tag. Let issue forms, labels/automation, `.github/CODEOWNERS`, and the maintainer areas above route the work. Mention a maintainer only when their listed area or owned path is directly relevant and you need a decision; otherwise rely on normal review. For coordinated change sets, ask in **#clawtributors** before opening more than the PR limit.
## PR Limits
We cap at **20 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.

View File

@@ -304,6 +304,9 @@ by Peter Steinberger and the community.
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
Use the [issue chooser](https://github.com/openclaw/openclaw/issues/new/choose) for bugs, docs bugs, and feature requests;
ask setup/support questions in [Discord](https://discord.gg/clawd); and report vulnerabilities through [SECURITY.md](SECURITY.md).
PRs should link the relevant issue when possible and follow the [PR template](.github/pull_request_template.md) with problem, impact, and evidence.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for

View File

@@ -2,6 +2,53 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.10</title>
<pubDate>Fri, 26 Jun 2026 23:37:36 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606001090</sparkle:version>
<sparkle:shortVersionString>2026.6.10</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.10</h2>
<h3>Highlights</h3>
<ul>
<li><strong>Automatic fast mode for talks:</strong> OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.</li>
<li><strong>More reliable model routing:</strong> Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
<li><strong>Safer session and channel state:</strong> channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.</li>
<li><strong>Trusted policies survive hook composition:</strong> composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.</li>
</ul>
<h3>Changes</h3>
<ul>
<li><strong>Agent and channel runtime:</strong> fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
<li><strong>Provider behavior:</strong> model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li><strong>Fast-mode and policy correctness:</strong> fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.</li>
<li><strong>Model and delivery edge cases:</strong> Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
<li><strong>Provider plugin onboarding:</strong> setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.</li>
</ul>
<h3>Complete contribution record</h3>
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
<h4>Pull requests</h4>
<ul>
<li><strong>PR #86627</strong> Keep core doctor health in contribution order. Thanks @giodl73-repo.</li>
<li><strong>PR #93580</strong> fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.</li>
<li><strong>PR #95030</strong> refactor: add SDK transcript identity target API. Thanks @jalehman.</li>
<li><strong>PR #94838</strong> refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.</li>
<li><strong>PR #95328</strong> fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.</li>
<li><strong>PR #94461</strong> fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.</li>
<li><strong>PR #93241</strong> fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.</li>
<li><strong>PR #94067</strong> fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.</li>
<li><strong>PR #94136</strong> fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.</li>
<li><strong>PR #85104</strong> feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.</li>
<li><strong>PR #94545</strong> fix: keep trusted policies with hook registry. Thanks @jesse-merhi.</li>
<li><strong>PR #95792</strong> fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.10/OpenClaw-2026.6.10.zip" length="56115790" type="application/octet-stream" sparkle:edSignature="MEeGG8+WePhUg9uDShznmdhhAgy/WWe7bAwr4XRTauNdrM441iziQYIlwhfNrtHDHX+uE1/tkRtIMcELfuekAg=="/>
</item>
<item>
<title>2026.6.8</title>
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
@@ -124,132 +171,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
</item>
<item>
<title>2026.6.1</title>
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026060190</sparkle:version>
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
<h3>Highlights</h3>
<ul>
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
<li>iOS: support native iPad display layouts.</li>
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
<li>Providers: add MiniMax M3 model support. (#88860)</li>
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
</item>
</channel>
</rss>

17117
apps/.i18n/native-source.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,38 @@ Recommended workflow:
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Release SHA tracking
Successful Play build uploads create a non-tag Git ref that records the source
commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
```
Example:
```text
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm android:release:upload` checks the ref before uploading the Play build and
records it only after `upload_to_play_store` succeeds. Existing refs are
immutable: the same ref at the same SHA is accepted, while the same ref at a
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
not record it because no Play build is published.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
```
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.

View File

@@ -2,6 +2,7 @@ package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.ui.mobileCardSurface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
@@ -51,6 +52,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -100,7 +102,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
title = { Text(stringResource(R.string.trust_this_gateway), style = mobileHeadline, color = mobileText) },
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
@@ -119,7 +121,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
) {
Text("Trust and continue")
Text(stringResource(R.string.trust_and_continue))
}
},
dismissButton = {
@@ -127,7 +129,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
) {
Text("Cancel")
Text(stringResource(R.string.cancel))
}
},
)
@@ -158,9 +160,10 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
Text(stringResource(R.string.gateway_connection), style = mobileTitle1, color = mobileText)
Text(
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
if (isConnected) stringResource(R.string.connected_gateway_ready)
else stringResource(R.string.connect_gateway_get_started),
style = mobileCallout,
color = mobileTextSecondary,
)
@@ -191,7 +194,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.endpoint), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
}
}
@@ -213,7 +216,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.status), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
}
}
@@ -238,7 +241,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
Text(stringResource(R.string.disconnect), style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
}
} else {
Button(
@@ -307,7 +310,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
contentColor = Color.White,
),
) {
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
Text(stringResource(R.string.connect_gateway), style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
}
}
@@ -354,7 +357,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
Text(stringResource(R.string.copy_report_for_claw), style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
@@ -373,7 +376,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced controls", style = mobileHeadline, color = mobileText)
Text(stringResource(R.string.advanced_controls), style = mobileHeadline, color = mobileText)
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
}
Icon(
@@ -395,15 +398,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.connection_method), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MethodChip(
label = "Setup Code",
label = stringResource(R.string.setup_code),
active = inputMode == ConnectInputMode.SetupCode,
onClick = { inputMode = ConnectInputMode.SetupCode },
)
MethodChip(
label = "Manual",
label = stringResource(R.string.manual),
active = inputMode == ConnectInputMode.Manual,
onClick = { inputMode = ConnectInputMode.Manual },
)
@@ -419,14 +422,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.setup_code), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = {
setupCode = it
validationText = null
},
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
placeholder = { Text(stringResource(R.string.paste_setup_code), style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
@@ -460,7 +463,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.host), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualHostInput,
onValueChange = {
@@ -502,7 +505,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text(stringResource(R.string.use_tls), style = mobileHeadline, color = mobileText)
Text(
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = mobileCallout,
@@ -525,7 +528,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = { viewModel.setGatewayToken(it) },
@@ -546,7 +549,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
OutlinedTextField(
value = passwordInput,
onValueChange = { passwordInput = it },
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
placeholder = { Text(stringResource(R.string.password), style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
@@ -563,7 +566,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
HorizontalDivider(color = mobileBorder)
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
Text(stringResource(R.string.run_onboarding_again), style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
}
}
}

View File

@@ -103,6 +103,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -212,7 +213,13 @@ fun OnboardingFlow(
AlertDialog(
onDismissRequest = viewModel::declineGatewayTrustPrompt,
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
title = {
Text(
stringResource(R.string.trust_this_gateway),
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
},
text = {
Text(
"Verify the certificate fingerprint before continuing.\n\n${prompt.fingerprintSha256}",
@@ -222,12 +229,12 @@ fun OnboardingFlow(
},
confirmButton = {
TextButton(onClick = viewModel::acceptGatewayTrustPrompt) {
Text("Trust")
Text(stringResource(R.string.trust_and_continue))
}
},
dismissButton = {
TextButton(onClick = viewModel::declineGatewayTrustPrompt) {
Text("Cancel")
Text(stringResource(R.string.cancel))
}
},
)
@@ -534,20 +541,24 @@ private fun GatewaySetupScreen(
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) {
item {
OnboardingHeader(title = "Gateway Setup", subtitle = "Connect to your Gateway", onBack = onBack)
OnboardingHeader(
title = stringResource(R.string.gateway_setup),
subtitle = stringResource(R.string.connect_to_gateway),
onBack = onBack,
)
}
item {
GatewayOption(
icon = Icons.Default.QrCode2,
title = "Scan setup code",
subtitle = "Use your Gateway QR or setup code",
title = stringResource(R.string.scan_setup_code),
subtitle = stringResource(R.string.use_gateway_qr),
onClick = onScan,
)
}
item {
GatewayOption(
icon = Icons.Default.WifiTethering,
title = "Nearby gateway",
title = stringResource(R.string.nearby_gateway),
subtitle = nearbyGateway.subtitle,
status = nearbyGateway.status,
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
@@ -556,8 +567,8 @@ private fun GatewaySetupScreen(
item {
GatewayOption(
icon = Icons.Default.Link,
title = "Enter gateway URL",
subtitle = "Connect using a manual URL",
title = stringResource(R.string.enter_gateway_url),
subtitle = stringResource(R.string.connect_manual_url),
onClick = { advancedOpen = true },
)
}
@@ -638,7 +649,7 @@ private fun GatewayRecoveryScreen(
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
OnboardingHeader(title = "Gateway Recovery", onBack = onBack)
OnboardingHeader(title = stringResource(R.string.gateway_setup), onBack = onBack)
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
@@ -923,7 +934,9 @@ private fun PermissionTopBar(onBack: () -> Unit) {
AlertDialog(
onDismissRequest = { showHelp = false },
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Permissions", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
title = {
Text(stringResource(R.string.permissions), style = ClawTheme.type.section, color = ClawTheme.colors.text)
},
text = {
Text(
"Choose what this phone can share with OpenClaw. You can change these later in Settings.",
@@ -933,7 +946,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
},
confirmButton = {
TextButton(onClick = { showHelp = false }) {
Text("Done")
Text(stringResource(R.string.done))
}
},
)

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">اتصال البوابة</string>
<string name="connect_gateway">توصيل البوابة</string>
<string name="disconnect">قطع الاتصال</string>
<string name="trust_this_gateway">هل تثق بهذه البوابة؟</string>
<string name="trust_and_continue">الثقة والمتابعة</string>
<string name="cancel">إلغاء</string>
<string name="endpoint">نقطة النهاية</string>
<string name="status">الحالة</string>
<string name="connected_gateway_ready">بوابتك نشطة وجاهزة.</string>
<string name="connect_gateway_get_started">اتصل ببوابتك للبدء.</string>
<string name="copy_report_for_claw">نسخ التقرير لـ Claw</string>
<string name="advanced_controls">عناصر التحكم المتقدمة</string>
<string name="connection_method">طريقة الاتصال</string>
<string name="setup_code">رمز الإعداد</string>
<string name="manual">يدوي</string>
<string name="paste_setup_code">الصق رمز الإعداد</string>
<string name="host">المضيف</string>
<string name="use_tls">استخدام TLS</string>
<string name="token_optional">الرمز المميز (اختياري)</string>
<string name="password">كلمة المرور</string>
<string name="run_onboarding_again">تشغيل الإعداد الأولي مرة أخرى</string>
<string name="resolved_endpoint">نقطة النهاية التي تم حلها</string>
<string name="gateway_setup">إعداد البوابة</string>
<string name="connect_to_gateway">الاتصال ببوابتك</string>
<string name="scan_setup_code">مسح رمز الإعداد</string>
<string name="use_gateway_qr">استخدم رمز QR الخاص ببوابتك أو رمز الإعداد</string>
<string name="nearby_gateway">بوابة قريبة</string>
<string name="enter_gateway_url">أدخل عنوان URL للبوابة</string>
<string name="connect_manual_url">الاتصال باستخدام عنوان URL يدوي</string>
<string name="permissions">الأذونات</string>
<string name="done">تم</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Gateway-Verbindung</string>
<string name="connect_gateway">Gateway verbinden</string>
<string name="disconnect">Trennen</string>
<string name="trust_this_gateway">Diesem Gateway vertrauen?</string>
<string name="trust_and_continue">Vertrauen und fortfahren</string>
<string name="cancel">Abbrechen</string>
<string name="endpoint">Endpunkt</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Ihr Gateway ist aktiv und bereit.</string>
<string name="connect_gateway_get_started">Verbinden Sie sich mit Ihrem Gateway, um loszulegen.</string>
<string name="copy_report_for_claw">Bericht für Claw kopieren</string>
<string name="advanced_controls">Erweiterte Steuerungen</string>
<string name="connection_method">Verbindungsmethode</string>
<string name="setup_code">Einrichtungscode</string>
<string name="manual">Manuell</string>
<string name="paste_setup_code">Einrichtungscode einfügen</string>
<string name="host">Host</string>
<string name="use_tls">TLS verwenden</string>
<string name="token_optional">Token (optional)</string>
<string name="password">Passwort</string>
<string name="run_onboarding_again">Onboarding erneut ausführen</string>
<string name="resolved_endpoint">Aufgelöster Endpunkt</string>
<string name="gateway_setup">Gateway-Einrichtung</string>
<string name="connect_to_gateway">Mit Ihrem Gateway verbinden</string>
<string name="scan_setup_code">Einrichtungscode scannen</string>
<string name="use_gateway_qr">Verwenden Sie Ihren Gateway-QR- oder Einrichtungscode</string>
<string name="nearby_gateway">Gateway in der Nähe</string>
<string name="enter_gateway_url">Gateway-URL eingeben</string>
<string name="connect_manual_url">Über eine manuelle URL verbinden</string>
<string name="permissions">Berechtigungen</string>
<string name="done">Fertig</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Conexión de Gateway</string>
<string name="connect_gateway">Conectar Gateway</string>
<string name="disconnect">Desconectar</string>
<string name="trust_this_gateway">¿Confiar en este gateway?</string>
<string name="trust_and_continue">Confiar y continuar</string>
<string name="cancel">Cancelar</string>
<string name="endpoint">Endpoint</string>
<string name="status">Estado</string>
<string name="connected_gateway_ready">Tu gateway está activo y listo.</string>
<string name="connect_gateway_get_started">Conéctate a tu gateway para empezar.</string>
<string name="copy_report_for_claw">Copiar informe para Claw</string>
<string name="advanced_controls">Controles avanzados</string>
<string name="connection_method">Método de conexión</string>
<string name="setup_code">Código de configuración</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Pegar código de configuración</string>
<string name="host">Host</string>
<string name="use_tls">Usar TLS</string>
<string name="token_optional">Token (opcional)</string>
<string name="password">Contraseña</string>
<string name="run_onboarding_again">Ejecutar la incorporación de nuevo</string>
<string name="resolved_endpoint">Endpoint resuelto</string>
<string name="gateway_setup">Configuración de Gateway</string>
<string name="connect_to_gateway">Conéctate a tu Gateway</string>
<string name="scan_setup_code">Escanear código de configuración</string>
<string name="use_gateway_qr">Usa el QR o código de configuración de tu Gateway</string>
<string name="nearby_gateway">Gateway cercano</string>
<string name="enter_gateway_url">Introduce la URL del gateway</string>
<string name="connect_manual_url">Conectar usando una URL manual</string>
<string name="permissions">Permisos</string>
<string name="done">Listo</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">اتصال دروازه</string>
<string name="connect_gateway">اتصال به دروازه</string>
<string name="disconnect">قطع اتصال</string>
<string name="trust_this_gateway">به این دروازه اعتماد دارید؟</string>
<string name="trust_and_continue">اعتماد و ادامه</string>
<string name="cancel">لغو</string>
<string name="endpoint">نقطه پایانی</string>
<string name="status">وضعیت</string>
<string name="connected_gateway_ready">دروازه شما فعال و آماده است.</string>
<string name="connect_gateway_get_started">برای شروع، به دروازه خود متصل شوید.</string>
<string name="copy_report_for_claw">کپی گزارش برای Claw</string>
<string name="advanced_controls">کنترل‌های پیشرفته</string>
<string name="connection_method">روش اتصال</string>
<string name="setup_code">کد راه‌اندازی</string>
<string name="manual">دستی</string>
<string name="paste_setup_code">کد راه‌اندازی را جای‌گذاری کنید</string>
<string name="host">میزبان</string>
<string name="use_tls">استفاده از TLS</string>
<string name="token_optional">توکن (اختیاری)</string>
<string name="password">رمز عبور</string>
<string name="run_onboarding_again">اجرای دوباره فرایند شروع به کار</string>
<string name="resolved_endpoint">نقطه پایانی حل‌شده</string>
<string name="gateway_setup">راه‌اندازی دروازه</string>
<string name="connect_to_gateway">به دروازه خود متصل شوید</string>
<string name="scan_setup_code">اسکن کد راه‌اندازی</string>
<string name="use_gateway_qr">از QR دروازه یا کد راه‌اندازی خود استفاده کنید</string>
<string name="nearby_gateway">دروازه نزدیک</string>
<string name="enter_gateway_url">URL دروازه را وارد کنید</string>
<string name="connect_manual_url">اتصال با استفاده از URL دستی</string>
<string name="permissions">مجوزها</string>
<string name="done">انجام شد</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Connexion à la passerelle</string>
<string name="connect_gateway">Connecter la passerelle</string>
<string name="disconnect">Déconnecter</string>
<string name="trust_this_gateway">Faire confiance à cette passerelle ?</string>
<string name="trust_and_continue">Faire confiance et continuer</string>
<string name="cancel">Annuler</string>
<string name="endpoint">Point de terminaison</string>
<string name="status">État</string>
<string name="connected_gateway_ready">Votre passerelle est active et prête.</string>
<string name="connect_gateway_get_started">Connectez-vous à votre passerelle pour commencer.</string>
<string name="copy_report_for_claw">Copier le rapport pour Claw</string>
<string name="advanced_controls">Contrôles avancés</string>
<string name="connection_method">Méthode de connexion</string>
<string name="setup_code">Code de configuration</string>
<string name="manual">Manuel</string>
<string name="paste_setup_code">Coller le code de configuration</string>
<string name="host">Hôte</string>
<string name="use_tls">Utiliser TLS</string>
<string name="token_optional">Jeton (facultatif)</string>
<string name="password">Mot de passe</string>
<string name="run_onboarding_again">Relancer lintégration</string>
<string name="resolved_endpoint">Point de terminaison résolu</string>
<string name="gateway_setup">Configuration de la passerelle</string>
<string name="connect_to_gateway">Connectez-vous à votre Gateway</string>
<string name="scan_setup_code">Scanner le code de configuration</string>
<string name="use_gateway_qr">Utilisez le QR de votre Gateway ou le code de configuration</string>
<string name="nearby_gateway">Passerelle à proximité</string>
<string name="enter_gateway_url">Saisir lURL de la passerelle</string>
<string name="connect_manual_url">Se connecter avec une URL manuelle</string>
<string name="permissions">Autorisations</string>
<string name="done">Terminé</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">गेटवे कनेक्शन</string>
<string name="connect_gateway">गेटवे कनेक्ट करें</string>
<string name="disconnect">डिस्कनेक्ट करें</string>
<string name="trust_this_gateway">इस गेटवे पर भरोसा करें?</string>
<string name="trust_and_continue">भरोसा करें और जारी रखें</string>
<string name="cancel">रद्द करें</string>
<string name="endpoint">एंडपॉइंट</string>
<string name="status">स्थिति</string>
<string name="connected_gateway_ready">आपका गेटवे सक्रिय और तैयार है।</string>
<string name="connect_gateway_get_started">शुरू करने के लिए अपने गेटवे से कनेक्ट करें।</string>
<string name="copy_report_for_claw">Claw के लिए रिपोर्ट कॉपी करें</string>
<string name="advanced_controls">उन्नत नियंत्रण</string>
<string name="connection_method">कनेक्शन विधि</string>
<string name="setup_code">सेटअप कोड</string>
<string name="manual">मैन्युअल</string>
<string name="paste_setup_code">सेटअप कोड पेस्ट करें</string>
<string name="host">होस्ट</string>
<string name="use_tls">TLS का उपयोग करें</string>
<string name="token_optional">टोकन (वैकल्पिक)</string>
<string name="password">पासवर्ड</string>
<string name="run_onboarding_again">ऑनबोर्डिंग फिर से चलाएँ</string>
<string name="resolved_endpoint">रिज़ॉल्व किया गया एंडपॉइंट</string>
<string name="gateway_setup">गेटवे सेटअप</string>
<string name="connect_to_gateway">अपने गेटवे से कनेक्ट करें</string>
<string name="scan_setup_code">सेटअप कोड स्कैन करें</string>
<string name="use_gateway_qr">अपने गेटवे QR या सेटअप कोड का उपयोग करें</string>
<string name="nearby_gateway">नज़दीकी गेटवे</string>
<string name="enter_gateway_url">गेटवे URL दर्ज करें</string>
<string name="connect_manual_url">मैन्युअल URL का उपयोग करके कनेक्ट करें</string>
<string name="permissions">अनुमतियाँ</string>
<string name="done">हो गया</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Koneksi Gateway</string>
<string name="connect_gateway">Hubungkan Gateway</string>
<string name="disconnect">Putuskan koneksi</string>
<string name="trust_this_gateway">Percayai gateway ini?</string>
<string name="trust_and_continue">Percayai dan lanjutkan</string>
<string name="cancel">Batal</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Gateway Anda aktif dan siap.</string>
<string name="connect_gateway_get_started">Hubungkan ke gateway Anda untuk memulai.</string>
<string name="copy_report_for_claw">Salin Laporan untuk Claw</string>
<string name="advanced_controls">Kontrol lanjutan</string>
<string name="connection_method">Metode koneksi</string>
<string name="setup_code">Kode Penyiapan</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Tempel kode penyiapan</string>
<string name="host">Host</string>
<string name="use_tls">Gunakan TLS</string>
<string name="token_optional">Token (opsional)</string>
<string name="password">Kata sandi</string>
<string name="run_onboarding_again">Jalankan onboarding lagi</string>
<string name="resolved_endpoint">Endpoint yang diselesaikan</string>
<string name="gateway_setup">Penyiapan Gateway</string>
<string name="connect_to_gateway">Hubungkan ke Gateway Anda</string>
<string name="scan_setup_code">Pindai kode penyiapan</string>
<string name="use_gateway_qr">Gunakan QR Gateway atau kode penyiapan Anda</string>
<string name="nearby_gateway">Gateway terdekat</string>
<string name="enter_gateway_url">Masukkan URL gateway</string>
<string name="connect_manual_url">Hubungkan menggunakan URL manual</string>
<string name="permissions">Izin</string>
<string name="done">Selesai</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Connessione al gateway</string>
<string name="connect_gateway">Connetti gateway</string>
<string name="disconnect">Disconnetti</string>
<string name="trust_this_gateway">Considerare attendibile questo gateway?</string>
<string name="trust_and_continue">Considera attendibile e continua</string>
<string name="cancel">Annulla</string>
<string name="endpoint">Endpoint</string>
<string name="status">Stato</string>
<string name="connected_gateway_ready">Il tuo gateway è attivo e pronto.</string>
<string name="connect_gateway_get_started">Connettiti al tuo gateway per iniziare.</string>
<string name="copy_report_for_claw">Copia report per Claw</string>
<string name="advanced_controls">Controlli avanzati</string>
<string name="connection_method">Metodo di connessione</string>
<string name="setup_code">Codice di configurazione</string>
<string name="manual">Manuale</string>
<string name="paste_setup_code">Incolla codice di configurazione</string>
<string name="host">Host</string>
<string name="use_tls">Usa TLS</string>
<string name="token_optional">Token (opzionale)</string>
<string name="password">Password</string>
<string name="run_onboarding_again">Esegui di nuovo l&apos;onboarding</string>
<string name="resolved_endpoint">Endpoint risolto</string>
<string name="gateway_setup">Configurazione gateway</string>
<string name="connect_to_gateway">Connettiti al tuo Gateway</string>
<string name="scan_setup_code">Scansiona codice di configurazione</string>
<string name="use_gateway_qr">Usa il QR del tuo Gateway o il codice di configurazione</string>
<string name="nearby_gateway">Gateway nelle vicinanze</string>
<string name="enter_gateway_url">Inserisci URL del gateway</string>
<string name="connect_manual_url">Connetti usando un URL manuale</string>
<string name="permissions">Autorizzazioni</string>
<string name="done">Fine</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">ゲートウェイ接続</string>
<string name="connect_gateway">ゲートウェイに接続</string>
<string name="disconnect">切断</string>
<string name="trust_this_gateway">このゲートウェイを信頼しますか?</string>
<string name="trust_and_continue">信頼して続行</string>
<string name="cancel">キャンセル</string>
<string name="endpoint">エンドポイント</string>
<string name="status">ステータス</string>
<string name="connected_gateway_ready">ゲートウェイはアクティブで準備完了です。</string>
<string name="connect_gateway_get_started">開始するにはゲートウェイに接続してください。</string>
<string name="copy_report_for_claw">Claw 用レポートをコピー</string>
<string name="advanced_controls">詳細コントロール</string>
<string name="connection_method">接続方法</string>
<string name="setup_code">セットアップコード</string>
<string name="manual">手動</string>
<string name="paste_setup_code">セットアップコードを貼り付け</string>
<string name="host">ホスト</string>
<string name="use_tls">TLS を使用</string>
<string name="token_optional">トークン(任意)</string>
<string name="password">パスワード</string>
<string name="run_onboarding_again">オンボーディングを再実行</string>
<string name="resolved_endpoint">解決済みエンドポイント</string>
<string name="gateway_setup">ゲートウェイ設定</string>
<string name="connect_to_gateway">ゲートウェイに接続</string>
<string name="scan_setup_code">セットアップコードをスキャン</string>
<string name="use_gateway_qr">ゲートウェイの QR またはセットアップコードを使用</string>
<string name="nearby_gateway">近くのゲートウェイ</string>
<string name="enter_gateway_url">ゲートウェイ URL を入力</string>
<string name="connect_manual_url">手動 URL で接続</string>
<string name="permissions">権限</string>
<string name="done">完了</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">게이트웨이 연결</string>
<string name="connect_gateway">게이트웨이 연결</string>
<string name="disconnect">연결 해제</string>
<string name="trust_this_gateway">이 게이트웨이를 신뢰하시겠습니까?</string>
<string name="trust_and_continue">신뢰하고 계속</string>
<string name="cancel">취소</string>
<string name="endpoint">엔드포인트</string>
<string name="status">상태</string>
<string name="connected_gateway_ready">게이트웨이가 활성화되어 준비되었습니다.</string>
<string name="connect_gateway_get_started">시작하려면 게이트웨이에 연결하세요.</string>
<string name="copy_report_for_claw">Claw용 보고서 복사</string>
<string name="advanced_controls">고급 제어</string>
<string name="connection_method">연결 방법</string>
<string name="setup_code">설정 코드</string>
<string name="manual">수동</string>
<string name="paste_setup_code">설정 코드 붙여넣기</string>
<string name="host">호스트</string>
<string name="use_tls">TLS 사용</string>
<string name="token_optional">토큰(선택 사항)</string>
<string name="password">비밀번호</string>
<string name="run_onboarding_again">온보딩 다시 실행</string>
<string name="resolved_endpoint">확인된 엔드포인트</string>
<string name="gateway_setup">게이트웨이 설정</string>
<string name="connect_to_gateway">게이트웨이에 연결</string>
<string name="scan_setup_code">설정 코드 스캔</string>
<string name="use_gateway_qr">게이트웨이 QR 또는 설정 코드 사용</string>
<string name="nearby_gateway">주변 게이트웨이</string>
<string name="enter_gateway_url">게이트웨이 URL 입력</string>
<string name="connect_manual_url">수동 URL을 사용하여 연결</string>
<string name="permissions">권한</string>
<string name="done">완료</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Gatewayverbinding</string>
<string name="connect_gateway">Gateway verbinden</string>
<string name="disconnect">Verbinding verbreken</string>
<string name="trust_this_gateway">Deze gateway vertrouwen?</string>
<string name="trust_and_continue">Vertrouwen en doorgaan</string>
<string name="cancel">Annuleren</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Je gateway is actief en klaar voor gebruik.</string>
<string name="connect_gateway_get_started">Verbind met je gateway om te beginnen.</string>
<string name="copy_report_for_claw">Rapport voor Claw kopiëren</string>
<string name="advanced_controls">Geavanceerde bediening</string>
<string name="connection_method">Verbindingsmethode</string>
<string name="setup_code">Setupcode</string>
<string name="manual">Handmatig</string>
<string name="paste_setup_code">Setupcode plakken</string>
<string name="host">Host</string>
<string name="use_tls">TLS gebruiken</string>
<string name="token_optional">Token (optioneel)</string>
<string name="password">Wachtwoord</string>
<string name="run_onboarding_again">Onboarding opnieuw uitvoeren</string>
<string name="resolved_endpoint">Opgelost endpoint</string>
<string name="gateway_setup">Gateway instellen</string>
<string name="connect_to_gateway">Verbinden met je Gateway</string>
<string name="scan_setup_code">Setupcode scannen</string>
<string name="use_gateway_qr">Gebruik je Gateway-QR-code of setupcode</string>
<string name="nearby_gateway">Gateway in de buurt</string>
<string name="enter_gateway_url">Gateway-URL invoeren</string>
<string name="connect_manual_url">Verbinden met een handmatige URL</string>
<string name="permissions">Machtigingen</string>
<string name="done">Gereed</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Połączenie z bramą</string>
<string name="connect_gateway">Połącz z bramą</string>
<string name="disconnect">Rozłącz</string>
<string name="trust_this_gateway">Ufać tej bramie?</string>
<string name="trust_and_continue">Zaufaj i kontynuuj</string>
<string name="cancel">Anuluj</string>
<string name="endpoint">Punkt końcowy</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Twoja brama jest aktywna i gotowa.</string>
<string name="connect_gateway_get_started">Połącz się ze swoją bramą, aby rozpocząć.</string>
<string name="copy_report_for_claw">Kopiuj raport dla Claw</string>
<string name="advanced_controls">Zaawansowane ustawienia</string>
<string name="connection_method">Metoda połączenia</string>
<string name="setup_code">Kod konfiguracji</string>
<string name="manual">Ręcznie</string>
<string name="paste_setup_code">Wklej kod konfiguracji</string>
<string name="host">Host</string>
<string name="use_tls">Użyj TLS</string>
<string name="token_optional">Token (opcjonalnie)</string>
<string name="password">Hasło</string>
<string name="run_onboarding_again">Uruchom ponownie wdrażanie</string>
<string name="resolved_endpoint">Rozpoznany punkt końcowy</string>
<string name="gateway_setup">Konfiguracja bramy</string>
<string name="connect_to_gateway">Połącz ze swoją bramą</string>
<string name="scan_setup_code">Zeskanuj kod konfiguracji</string>
<string name="use_gateway_qr">Użyj kodu QR bramy lub kodu konfiguracji</string>
<string name="nearby_gateway">Pobliska brama</string>
<string name="enter_gateway_url">Wprowadź URL bramy</string>
<string name="connect_manual_url">Połącz, używając ręcznego URL</string>
<string name="permissions">Uprawnienia</string>
<string name="done">Gotowe</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Conexão do Gateway</string>
<string name="connect_gateway">Conectar Gateway</string>
<string name="disconnect">Desconectar</string>
<string name="trust_this_gateway">Confiar neste gateway?</string>
<string name="trust_and_continue">Confiar e continuar</string>
<string name="cancel">Cancelar</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Seu gateway está ativo e pronto.</string>
<string name="connect_gateway_get_started">Conecte-se ao seu gateway para começar.</string>
<string name="copy_report_for_claw">Copiar relatório para o Claw</string>
<string name="advanced_controls">Controles avançados</string>
<string name="connection_method">Método de conexão</string>
<string name="setup_code">Código de configuração</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Colar código de configuração</string>
<string name="host">Host</string>
<string name="use_tls">Usar TLS</string>
<string name="token_optional">Token (opcional)</string>
<string name="password">Senha</string>
<string name="run_onboarding_again">Executar integração novamente</string>
<string name="resolved_endpoint">Endpoint resolvido</string>
<string name="gateway_setup">Configuração do Gateway</string>
<string name="connect_to_gateway">Conecte-se ao seu Gateway</string>
<string name="scan_setup_code">Escanear código de configuração</string>
<string name="use_gateway_qr">Use o QR do seu Gateway ou o código de configuração</string>
<string name="nearby_gateway">Gateway próximo</string>
<string name="enter_gateway_url">Inserir URL do gateway</string>
<string name="connect_manual_url">Conectar usando uma URL manual</string>
<string name="permissions">Permissões</string>
<string name="done">Concluído</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Подключение к шлюзу</string>
<string name="connect_gateway">Подключить шлюз</string>
<string name="disconnect">Отключить</string>
<string name="trust_this_gateway">Доверять этому шлюзу?</string>
<string name="trust_and_continue">Доверять и продолжить</string>
<string name="cancel">Отмена</string>
<string name="endpoint">Конечная точка</string>
<string name="status">Статус</string>
<string name="connected_gateway_ready">Ваш шлюз активен и готов.</string>
<string name="connect_gateway_get_started">Подключитесь к своему шлюзу, чтобы начать.</string>
<string name="copy_report_for_claw">Скопировать отчет для Claw</string>
<string name="advanced_controls">Расширенные настройки</string>
<string name="connection_method">Способ подключения</string>
<string name="setup_code">Код настройки</string>
<string name="manual">Вручную</string>
<string name="paste_setup_code">Вставьте код настройки</string>
<string name="host">Хост</string>
<string name="use_tls">Использовать TLS</string>
<string name="token_optional">Токен (необязательно)</string>
<string name="password">Пароль</string>
<string name="run_onboarding_again">Запустить настройку заново</string>
<string name="resolved_endpoint">Разрешенная конечная точка</string>
<string name="gateway_setup">Настройка шлюза</string>
<string name="connect_to_gateway">Подключитесь к своему шлюзу</string>
<string name="scan_setup_code">Сканировать код настройки</string>
<string name="use_gateway_qr">Используйте QR-код или код настройки вашего шлюза</string>
<string name="nearby_gateway">Шлюз поблизости</string>
<string name="enter_gateway_url">Введите URL шлюза</string>
<string name="connect_manual_url">Подключиться с помощью URL вручную</string>
<string name="permissions">Разрешения</string>
<string name="done">Готово</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">การเชื่อมต่อเกตเวย์</string>
<string name="connect_gateway">เชื่อมต่อเกตเวย์</string>
<string name="disconnect">ตัดการเชื่อมต่อ</string>
<string name="trust_this_gateway">เชื่อถือเกตเวย์นี้หรือไม่?</string>
<string name="trust_and_continue">เชื่อถือและดำเนินการต่อ</string>
<string name="cancel">ยกเลิก</string>
<string name="endpoint">เอนด์พอยต์</string>
<string name="status">สถานะ</string>
<string name="connected_gateway_ready">เกตเวย์ของคุณเปิดใช้งานและพร้อมใช้งานแล้ว</string>
<string name="connect_gateway_get_started">เชื่อมต่อกับเกตเวย์ของคุณเพื่อเริ่มต้นใช้งาน</string>
<string name="copy_report_for_claw">คัดลอกรายงานสำหรับ Claw</string>
<string name="advanced_controls">การควบคุมขั้นสูง</string>
<string name="connection_method">วิธีการเชื่อมต่อ</string>
<string name="setup_code">รหัสตั้งค่า</string>
<string name="manual">ด้วยตนเอง</string>
<string name="paste_setup_code">วางรหัสตั้งค่า</string>
<string name="host">โฮสต์</string>
<string name="use_tls">ใช้ TLS</string>
<string name="token_optional">โทเค็น (ไม่บังคับ)</string>
<string name="password">รหัสผ่าน</string>
<string name="run_onboarding_again">เรียกใช้การเริ่มต้นใช้งานอีกครั้ง</string>
<string name="resolved_endpoint">เอนด์พอยต์ที่แก้ไขแล้ว</string>
<string name="gateway_setup">การตั้งค่าเกตเวย์</string>
<string name="connect_to_gateway">เชื่อมต่อกับเกตเวย์ของคุณ</string>
<string name="scan_setup_code">สแกนรหัสตั้งค่า</string>
<string name="use_gateway_qr">ใช้ QR ของเกตเวย์หรือรหัสตั้งค่าของคุณ</string>
<string name="nearby_gateway">เกตเวย์ใกล้เคียง</string>
<string name="enter_gateway_url">ป้อน URL เกตเวย์</string>
<string name="connect_manual_url">เชื่อมต่อโดยใช้ URL ด้วยตนเอง</string>
<string name="permissions">สิทธิ์</string>
<string name="done">เสร็จสิ้น</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Ağ Geçidi Bağlantısı</string>
<string name="connect_gateway">Ağ Geçidine Bağlan</string>
<string name="disconnect">Bağlantıyı Kes</string>
<string name="trust_this_gateway">Bu ağ geçidine güvenilsin mi?</string>
<string name="trust_and_continue">Güven ve devam et</string>
<string name="cancel">İptal</string>
<string name="endpoint">Uç nokta</string>
<string name="status">Durum</string>
<string name="connected_gateway_ready">Ağ geçidiniz etkin ve hazır.</string>
<string name="connect_gateway_get_started">Başlamak için ağ geçidinize bağlanın.</string>
<string name="copy_report_for_claw">Claw için Raporu Kopyala</string>
<string name="advanced_controls">Gelişmiş kontroller</string>
<string name="connection_method">Bağlantı yöntemi</string>
<string name="setup_code">Kurulum Kodu</string>
<string name="manual">Manuel</string>
<string name="paste_setup_code">Kurulum kodunu yapıştır</string>
<string name="host">Ana makine</string>
<string name="use_tls">TLS kullan</string>
<string name="token_optional">Token (isteğe bağlı)</string>
<string name="password">Parola</string>
<string name="run_onboarding_again">Başlangıç sürecini tekrar çalıştır</string>
<string name="resolved_endpoint">Çözümlenen uç nokta</string>
<string name="gateway_setup">Ağ Geçidi Kurulumu</string>
<string name="connect_to_gateway">Ağ Geçidinize Bağlanın</string>
<string name="scan_setup_code">Kurulum kodunu tara</string>
<string name="use_gateway_qr">Gateway QR kodunuzu veya kurulum kodunuzu kullanın</string>
<string name="nearby_gateway">Yakındaki ağ geçidi</string>
<string name="enter_gateway_url">Ağ geçidi URL&apos;sini girin</string>
<string name="connect_manual_url">Manuel URL kullanarak bağlan</string>
<string name="permissions">İzinler</string>
<string name="done">Bitti</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Підключення до шлюзу</string>
<string name="connect_gateway">Підключити шлюз</string>
<string name="disconnect">Відключити</string>
<string name="trust_this_gateway">Довіряти цьому шлюзу?</string>
<string name="trust_and_continue">Довіряти й продовжити</string>
<string name="cancel">Скасувати</string>
<string name="endpoint">Кінцева точка</string>
<string name="status">Стан</string>
<string name="connected_gateway_ready">Ваш шлюз активний і готовий.</string>
<string name="connect_gateway_get_started">Підключіться до свого шлюзу, щоб почати.</string>
<string name="copy_report_for_claw">Скопіювати звіт для Claw</string>
<string name="advanced_controls">Розширені елементи керування</string>
<string name="connection_method">Спосіб підключення</string>
<string name="setup_code">Код налаштування</string>
<string name="manual">Вручну</string>
<string name="paste_setup_code">Вставте код налаштування</string>
<string name="host">Хост</string>
<string name="use_tls">Використовувати TLS</string>
<string name="token_optional">Токен (необов’язково)</string>
<string name="password">Пароль</string>
<string name="run_onboarding_again">Запустити адаптацію знову</string>
<string name="resolved_endpoint">Визначена кінцева точка</string>
<string name="gateway_setup">Налаштування шлюзу</string>
<string name="connect_to_gateway">Підключіться до свого шлюзу</string>
<string name="scan_setup_code">Сканувати код налаштування</string>
<string name="use_gateway_qr">Використайте QR-код свого шлюзу або код налаштування</string>
<string name="nearby_gateway">Шлюз поблизу</string>
<string name="enter_gateway_url">Введіть URL-адресу шлюзу</string>
<string name="connect_manual_url">Підключитися за допомогою URL-адреси вручну</string>
<string name="permissions">Дозволи</string>
<string name="done">Готово</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Kết nối cổng</string>
<string name="connect_gateway">Kết nối cổng</string>
<string name="disconnect">Ngắt kết nối</string>
<string name="trust_this_gateway">Tin cậy cổng này?</string>
<string name="trust_and_continue">Tin cậy và tiếp tục</string>
<string name="cancel">Hủy</string>
<string name="endpoint">Điểm cuối</string>
<string name="status">Trạng thái</string>
<string name="connected_gateway_ready">Cổng của bạn đang hoạt động và sẵn sàng.</string>
<string name="connect_gateway_get_started">Kết nối với cổng của bạn để bắt đầu.</string>
<string name="copy_report_for_claw">Sao chép báo cáo cho Claw</string>
<string name="advanced_controls">Điều khiển nâng cao</string>
<string name="connection_method">Phương thức kết nối</string>
<string name="setup_code">Mã thiết lập</string>
<string name="manual">Thủ công</string>
<string name="paste_setup_code">Dán mã thiết lập</string>
<string name="host">Máy chủ</string>
<string name="use_tls">Sử dụng TLS</string>
<string name="token_optional">Token (tùy chọn)</string>
<string name="password">Mật khẩu</string>
<string name="run_onboarding_again">Chạy hướng dẫn thiết lập lại</string>
<string name="resolved_endpoint">Điểm cuối đã phân giải</string>
<string name="gateway_setup">Thiết lập cổng</string>
<string name="connect_to_gateway">Kết nối với Gateway của bạn</string>
<string name="scan_setup_code">Quét mã thiết lập</string>
<string name="use_gateway_qr">Sử dụng mã QR Gateway hoặc mã thiết lập của bạn</string>
<string name="nearby_gateway">Cổng gần đây</string>
<string name="enter_gateway_url">Nhập URL cổng</string>
<string name="connect_manual_url">Kết nối bằng URL thủ công</string>
<string name="permissions">Quyền</string>
<string name="done">Xong</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">网关连接</string>
<string name="connect_gateway">连接网关</string>
<string name="disconnect">断开连接</string>
<string name="trust_this_gateway">信任此网关?</string>
<string name="trust_and_continue">信任并继续</string>
<string name="cancel">取消</string>
<string name="endpoint">端点</string>
<string name="status">状态</string>
<string name="connected_gateway_ready">你的网关已激活并准备就绪。</string>
<string name="connect_gateway_get_started">连接到你的网关以开始使用。</string>
<string name="copy_report_for_claw">复制 Claw 报告</string>
<string name="advanced_controls">高级控制</string>
<string name="connection_method">连接方式</string>
<string name="setup_code">设置代码</string>
<string name="manual">手动</string>
<string name="paste_setup_code">粘贴设置代码</string>
<string name="host">主机</string>
<string name="use_tls">使用 TLS</string>
<string name="token_optional">令牌(可选)</string>
<string name="password">密码</string>
<string name="run_onboarding_again">再次运行引导流程</string>
<string name="resolved_endpoint">已解析的端点</string>
<string name="gateway_setup">网关设置</string>
<string name="connect_to_gateway">连接到你的网关</string>
<string name="scan_setup_code">扫描设置代码</string>
<string name="use_gateway_qr">使用你的网关 QR 码或设置代码</string>
<string name="nearby_gateway">附近的网关</string>
<string name="enter_gateway_url">输入网关 URL</string>
<string name="connect_manual_url">使用手动 URL 连接</string>
<string name="permissions">权限</string>
<string name="done">完成</string>
</resources>

View File

@@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">閘道連線</string>
<string name="connect_gateway">連接閘道</string>
<string name="disconnect">中斷連線</string>
<string name="trust_this_gateway">信任此閘道?</string>
<string name="trust_and_continue">信任並繼續</string>
<string name="cancel">取消</string>
<string name="endpoint">端點</string>
<string name="status">狀態</string>
<string name="connected_gateway_ready">您的閘道已啟用並準備就緒。</string>
<string name="connect_gateway_get_started">連接到您的閘道以開始使用。</string>
<string name="copy_report_for_claw">複製 Claw 報告</string>
<string name="advanced_controls">進階控制項</string>
<string name="connection_method">連線方式</string>
<string name="setup_code">設定碼</string>
<string name="manual">手動</string>
<string name="paste_setup_code">貼上設定碼</string>
<string name="host">主機</string>
<string name="use_tls">使用 TLS</string>
<string name="token_optional">權杖(選填)</string>
<string name="password">密碼</string>
<string name="run_onboarding_again">再次執行新手導覽</string>
<string name="resolved_endpoint">已解析的端點</string>
<string name="gateway_setup">閘道設定</string>
<string name="connect_to_gateway">連接到您的閘道</string>
<string name="scan_setup_code">掃描設定碼</string>
<string name="use_gateway_qr">使用您的 Gateway QR 或設定碼</string>
<string name="nearby_gateway">附近的閘道</string>
<string name="enter_gateway_url">輸入閘道 URL</string>
<string name="connect_manual_url">使用手動 URL 連接</string>
<string name="permissions">權限</string>
<string name="done">完成</string>
</resources>

View File

@@ -1,3 +1,34 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Gateway Connection</string>
<string name="connect_gateway">Connect Gateway</string>
<string name="disconnect">Disconnect</string>
<string name="trust_this_gateway">Trust this gateway?</string>
<string name="trust_and_continue">Trust and continue</string>
<string name="cancel">Cancel</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Your gateway is active and ready.</string>
<string name="connect_gateway_get_started">Connect to your gateway to get started.</string>
<string name="copy_report_for_claw">Copy Report for Claw</string>
<string name="advanced_controls">Advanced controls</string>
<string name="connection_method">Connection method</string>
<string name="setup_code">Setup Code</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Paste setup code</string>
<string name="host">Host</string>
<string name="use_tls">Use TLS</string>
<string name="token_optional">Token (optional)</string>
<string name="password">Password</string>
<string name="run_onboarding_again">Run onboarding again</string>
<string name="resolved_endpoint">Resolved endpoint</string>
<string name="gateway_setup">Gateway Setup</string>
<string name="connect_to_gateway">Connect to your Gateway</string>
<string name="scan_setup_code">Scan setup code</string>
<string name="use_gateway_qr">Use your Gateway QR or setup code</string>
<string name="nearby_gateway">Nearby gateway</string>
<string name="enter_gateway_url">Enter gateway URL</string>
<string name="connect_manual_url">Connect using a manual URL</string>
<string name="permissions">Permissions</string>
<string name="done">Done</string>
</resources>

View File

@@ -198,6 +198,58 @@ def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def mobile_release_ref_script
File.join(repo_root, "scripts", "mobile-release-ref.ts")
end
def release_git_sha
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
stdout.strip
end
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
args = [
"node",
"--import",
"tsx",
mobile_release_ref_script,
command,
"--platform",
platform,
"--version",
version,
"--root",
repo_root,
]
args.push("--build", build.to_s) if build
args.push("--version-code", version_code.to_s) if version_code
args.push("--sha", sha.to_s) if sha
sh(shell_join(args))
end
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"preflight",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"record",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
@@ -282,6 +334,13 @@ def upload_play_store_metadata!(version_metadata)
end
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
release_sha = release_git_sha
ensure_mobile_release_ref_available!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
@@ -302,6 +361,15 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im
skip_upload_screenshots: !upload_screenshots,
validate_only: play_validate_only?
)
unless play_validate_only?
record_mobile_release_ref!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
end
end
load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env"))

View File

@@ -129,6 +129,37 @@ pnpm ios:version:pin -- --version 2026.4.10
This keeps the TestFlight version stable while review is in flight.
## Release SHA tracking
Successful App Store Connect uploads create a non-tag Git ref that records the
source commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
```
Example:
```text
refs/openclaw/mobile-releases/ios/2026.6.10-8
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm ios:release:upload` checks the ref before archive/upload work and records
it only after `upload_to_testflight` succeeds. Existing refs are immutable: the
same ref at the same SHA is accepted, while the same ref at a different SHA
fails.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8
pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8
```
## New release promotion workflow
When you want the next production iOS release to align with the current gateway release:

View File

@@ -1128,6 +1128,58 @@ def prepare_app_store_release!(version:, build_number:)
release_xcconfig
end
def mobile_release_ref_script
File.join(repo_root, "scripts", "mobile-release-ref.ts")
end
def release_git_sha
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
stdout.strip
end
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
args = [
"node",
"--import",
"tsx",
mobile_release_ref_script,
command,
"--platform",
platform,
"--version",
version,
"--root",
repo_root,
]
args.push("--build", build.to_s) if build
args.push("--version-code", version_code.to_s) if version_code
args.push("--sha", sha.to_s) if sha
sh(shell_join(args))
end
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"preflight",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"record",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def validate_app_store_ipa!(ipa_path)
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
@@ -1309,15 +1361,22 @@ platform :ios do
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_sha = release_git_sha
release_signing_check!
preserve_local_signing do
screenshots
end
context = prepare_app_store_context(require_api_key: true)
ensure_mobile_release_ref_available!(
platform: "ios",
version: context[:short_version],
build: context[:build_number],
sha: release_sha
)
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
@@ -1326,6 +1385,12 @@ platform :ios do
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
record_mobile_release_ref!(
platform: "ios",
version: build[:short_version],
build: build[:build_number],
sha: release_sha
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")

View File

@@ -1,2 +1,2 @@
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl

View File

@@ -11,7 +11,7 @@ Generated locale trees and live translation memory now live in the publish repo:
- English docs are authored in `openclaw/openclaw`.
- The source docs tree lives under `docs/`.
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/hi/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/ru/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
## End-to-end flow
@@ -32,10 +32,10 @@ Generated locale trees and live translation memory now live in the publish repo:
## Locale visibility
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `ar`, `it`, `tr`, `uk`, `id`, `pl`, `th`, `vi`, `nl`, and `fa`.
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `hi`, `ar`, `it`, `vi`, `nl`, `fa`, `ru`, `tr`, `uk`, `id`, `pl`, and `th`.
- Docs translation workflows generate the same non-English locale set in `openclaw/docs`.
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; today that includes Vietnamese (`vi`) and Dutch (`nl`), but not Thai (`th`) or Persian (`fa`).
- Do not treat missing `th` or `fa` entries in generated `docs/docs.json` as a pipeline failure. Verify their generated folders in `openclaw/docs` instead.
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; Russian (`ru`) and Hindi (`hi`) are now included in the publish configuration.
- Do not treat locale visibility in generated `docs/docs.json` as proof that translation artifacts exist. Verify each generated locale folder and its translation memory in `openclaw/docs`.
## Files in this folder

View File

@@ -0,0 +1,82 @@
[
{
"source": "ACP",
"target": "ACP"
},
{
"source": "Active Memory",
"target": "Active Memory"
},
{
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "CLI",
"target": "CLI"
},
{
"source": "Compaction",
"target": "Compaction"
},
{
"source": "Cron",
"target": "Cron"
},
{
"source": "Dreaming",
"target": "Dreaming"
},
{
"source": "Gateway",
"target": "Gateway"
},
{
"source": "Heartbeat",
"target": "Heartbeat"
},
{
"source": "LINE",
"target": "LINE"
},
{
"source": "Mintlify",
"target": "Mintlify"
},
{
"source": "Node",
"target": "Node"
},
{
"source": "OpenClaw",
"target": "OpenClaw"
},
{
"source": "Pi",
"target": "Pi"
},
{
"source": "Plugin",
"target": "Plugin"
},
{
"source": "Skills",
"target": "Skills"
},
{
"source": "Tailscale",
"target": "Tailscale"
},
{
"source": "TaskFlow",
"target": "TaskFlow"
},
{
"source": "TUI",
"target": "TUI"
},
{
"source": "Webhook",
"target": "Webhook"
}
]

View File

@@ -0,0 +1,82 @@
[
{
"source": "ACP",
"target": "ACP"
},
{
"source": "Active Memory",
"target": "Active Memory"
},
{
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "CLI",
"target": "CLI"
},
{
"source": "Compaction",
"target": "Compaction"
},
{
"source": "Cron",
"target": "Cron"
},
{
"source": "Dreaming",
"target": "Dreaming"
},
{
"source": "Gateway",
"target": "Gateway"
},
{
"source": "Heartbeat",
"target": "Heartbeat"
},
{
"source": "LINE",
"target": "LINE"
},
{
"source": "Mintlify",
"target": "Mintlify"
},
{
"source": "Node",
"target": "Node"
},
{
"source": "OpenClaw",
"target": "OpenClaw"
},
{
"source": "Pi",
"target": "Pi"
},
{
"source": "Plugin",
"target": "Plugin"
},
{
"source": "Skills",
"target": "Skills"
},
{
"source": "Tailscale",
"target": "Tailscale"
},
{
"source": "TaskFlow",
"target": "TaskFlow"
},
{
"source": "TUI",
"target": "TUI"
},
{
"source": "Webhook",
"target": "Webhook"
}
]

View File

@@ -90,9 +90,9 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Pull requests on the canonical repository use an additional compact admission plan: the same per-config groups run in isolated subprocesses inside the current 34-job Linux Node plan, so a single PR does not register the full 70-plus-job Node matrix. `main` pushes, manual dispatches, and release gates retain the full matrix. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Once admitted, canonical Linux CI permits up to 12 concurrent Node jobs and 8 for
the smaller fast/check lanes; Windows and Android stay at two because those
runner pools are narrower.
Once admitted, canonical Linux CI permits up to 24 concurrent Node test jobs and
12 for the smaller fast/check lanes; Windows and Android stay at two because
those runner pools are narrower.
The compact PR plan emits 18 Node jobs for the current suite: whole-config
groups are batched in isolated subprocesses with a 120-minute batch timeout,
@@ -145,17 +145,17 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
## Runner registration budget
GitHub caps self-hosted runner registrations at 1,500 runners per 5 minutes per
repository, organization, or enterprise. The limit is shared by all Blacksmith
runner registrations in the `openclaw` organization, so adding another
Blacksmith installation does not add a new bucket.
OpenClaw's current GitHub runner-registration bucket allows 3,000 self-hosted
runner registrations per 5 minutes. The limit is shared by all Blacksmith runner
registrations in the `openclaw` organization, so adding another Blacksmith
installation does not add a new bucket.
Treat Blacksmith labels as the scarce resource for burst control. Jobs that
only route, notify, summarize, select shards, or run short CodeQL scans should
stay on GitHub-hosted runners unless they have measured Blacksmith-specific
needs. Any new Blacksmith matrix, larger `max-parallel`, or high-frequency
workflow must show its worst-case registration count and keep the org-level
target below 1,000 registrations per 5 minutes, leaving headroom for concurrent
target below 2,000 registrations per 5 minutes, leaving headroom for concurrent
repositories and retried jobs.
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.

View File

@@ -24,17 +24,31 @@ OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install @owner/<slug>
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills verify @owner/<slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins update <id-or-npm-spec>
```
Skill installs target the active workspace `skills/` directory by default. Add
`--global` to install into the shared managed skills directory.
OpenClaw checks the selected community ClawHub skill or plugin trust state
before downloading it. Versioned community skill and plugin releases use
exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's
install resolver to enforce scan and force-install policy before it returns a
pinned commit. Malicious or blocked community releases are refused. Risky
community releases require review and `--acknowledge-clawhub-risk` when a
non-interactive command should continue after that review.
Official ClawHub publishers/packages and bundled OpenClaw sources bypass this
release-trust prompt and security-verdict fetch during install and update.
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
instead of npm or another install source.

View File

@@ -54,8 +54,9 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
openclaw plugins init <id>
openclaw plugins init <id> --directory ./my-plugin --name "My Plugin"
openclaw plugins init my-tool --name "My Tool"
openclaw plugins init my-provider --name "My Provider" --type provider
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
openclaw plugins build --entry ./dist/index.js
openclaw plugins build --entry ./dist/index.js --check
openclaw plugins validate --entry ./dist/index.js
@@ -86,12 +87,15 @@ npm run plugin:build
npm run plugin:validate
```
`plugins init` creates a minimal TypeScript tool plugin that uses
`defineToolPlugin`. `plugins build` imports that entry, reads its static tool
metadata, writes `openclaw.plugin.json`, and keeps `package.json`
`openclaw.extensions` aligned. `plugins validate` checks that the generated
manifest, package metadata, and current entry export still agree. See
[Tool Plugins](/plugins/tool-plugins) for the full authoring workflow.
`plugins init` creates a minimal TypeScript tool plugin by default. The first
argument is the plugin id; pass `--name` for the display name. OpenClaw uses the
id for the default output directory and package naming. Tool scaffolds use
`defineToolPlugin`.
`plugins build` imports the built entry, reads its static tool metadata, writes
`openclaw.plugin.json`, and keeps `package.json` `openclaw.extensions` aligned.
`plugins validate` checks that the generated manifest, package metadata, and
current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for
the full tool-authoring workflow.
The scaffold writes TypeScript source but generates metadata from the built
`./dist/index.js` entry so the workflow also works with the published CLI. Use
@@ -99,6 +103,29 @@ The scaffold writes TypeScript source but generates metadata from the built
`plugins build --check` in CI to fail when generated metadata is stale without
rewriting files.
### Provider Scaffold
```bash
openclaw plugins init acme-models --name "Acme Models" --type provider
cd acme-models
npm install
npm run build
npm test
npm run validate
```
Provider scaffolds create a generic text/model provider plugin with OpenAI-compatible
API-key plumbing, a built-in `npm run validate` script for `clawhub package
validate`, ClawHub package metadata, and a manually dispatched GitHub workflow
for future trusted publishing through GitHub Actions OIDC. Provider scaffolds do
not generate skills and do not use `openclaw plugins build` or
`openclaw plugins validate`; those commands are for the tool scaffold's
generated metadata path.
Before publishing, replace the placeholder API base URL, model catalog, docs
route, credential text, and README copy with real provider details. Use the
generated README for first-time ClawHub publishing and trusted publisher setup.
### Install
```bash
@@ -111,6 +138,7 @@ openclaw plugins install git:github.com/<owner>/<repo> # git repo
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
openclaw plugins install <plugin>@<marketplace> # marketplace
@@ -163,6 +191,12 @@ is available, then fall back to `latest`.
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk">
Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing.
Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
<Accordion title="Hook packs and npm specs">
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
@@ -390,6 +424,7 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call
openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
@@ -421,6 +456,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
<Accordion title="--dangerously-force-unsafe-install on update">
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk on update">
Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
</AccordionGroup>
### Inspect

View File

@@ -31,9 +31,11 @@ openclaw skills install git:owner/repo
openclaw skills install git:owner/repo@main
openclaw skills install ./path/to/skill --as custom-name
openclaw skills install @owner/<slug> --force
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills install @owner/<slug> --agent <id>
openclaw skills install @owner/<slug> --global
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
@@ -97,6 +99,14 @@ Notes:
- `install --version <version>` applies only to ClawHub skill refs.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- Community ClawHub skill installs and updates check trust before downloading.
Versioned community archive releases use exact-release trust metadata.
Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce
scan and force-install policy before it returns a pinned commit. Malicious or
blocked community releases are refused. Risky community releases require
review and `--acknowledge-clawhub-risk` when a non-interactive command should
continue after that review. Official ClawHub skill publishers and bundled
OpenClaw skill sources bypass this release-trust prompt.
- `--global` targets the shared managed skills directory and cannot be combined
with `--agent <id>`.
- `--agent <id>` targets one configured agent workspace and overrides current

View File

@@ -28,6 +28,7 @@ openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --yes
openclaw update --acknowledge-clawhub-risk
openclaw update --json
openclaw --update
```
@@ -45,6 +46,11 @@ openclaw --update
when npm plugin artifact drift is detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1800s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow post-update plugin sync to continue without an interactive
prompt. Without this, risky community ClawHub plugin releases are skipped and
left unchanged when OpenClaw cannot prompt. Official ClawHub packages and
bundled OpenClaw plugin sources bypass this release-trust prompt.
`openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview
the planned channel/tag/install/restart actions, `--json` for machine-readable
@@ -88,6 +94,7 @@ converge.
```bash
openclaw update repair
openclaw update repair --channel beta
openclaw update repair --acknowledge-clawhub-risk
openclaw update repair --json
```
@@ -98,6 +105,10 @@ Options:
- `--json`: print machine-readable finalization JSON.
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
- `--yes`: skip confirmation prompts.
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow repair-time plugin convergence to continue without an
interactive prompt. Official ClawHub packages and bundled OpenClaw plugin
sources bypass this release-trust prompt.
- `--no-restart`: accepted for update command parity; repair never restarts the
Gateway.

View File

@@ -21,6 +21,7 @@ import {
readCodexNotificationItem,
readNotificationItemId,
shouldDisarmAssistantCompletionIdleWatch,
updateActiveCompletionBlockerItemIds,
updateActiveTurnItemIds,
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
@@ -92,6 +93,7 @@ export function applyCodexTurnNotificationState(params: {
currentPromptTexts: string[];
turnWatches: CodexAttemptTurnWatchController;
activeTurnItemIds: Set<string>;
activeCompletionBlockerItemIds: Set<string>;
activeAppServerTurnRequests: number;
pendingOpenClawDynamicToolCompletionIds: Set<string>;
turnCrossedToolHandoff: boolean;
@@ -121,6 +123,7 @@ export function applyCodexTurnNotificationState(params: {
});
params.onReportExecutionNotification(notification);
updateActiveTurnItemIds(notification, params.activeTurnItemIds);
updateActiveCompletionBlockerItemIds(notification, params.activeCompletionBlockerItemIds);
if (notification.method === "item/completed" && params.activeTurnItemIds.size === 0) {
params.onScheduleTerminalDynamicToolReleaseCheck();
}

View File

@@ -63,6 +63,45 @@ export function updateActiveTurnItemIds(
activeItemIds.delete(itemId);
}
export function updateActiveCompletionBlockerItemIds(
notification: CodexServerNotification,
activeItemIds: Set<string>,
): void {
if (notification.method !== "item/started" && notification.method !== "item/completed") {
return;
}
const itemId = readNotificationItemId(notification);
if (!itemId) {
return;
}
if (notification.method === "item/completed") {
activeItemIds.delete(itemId);
return;
}
const item = readCodexNotificationItem(notification.params);
if (item && isCompletionBlockingItem(item)) {
activeItemIds.add(itemId);
}
}
function isCompletionBlockingItem(item: CodexThreadItem): boolean {
// Codex emits paired item/started and item/completed notifications for these
// execution items. Completion must not time out while any pair is still open.
switch (item.type) {
case "collabAgentToolCall":
case "commandExecution":
case "dynamicToolCall":
case "fileChange":
case "imageGeneration":
case "imageView":
case "mcpToolCall":
case "webSearch":
return true;
default:
return false;
}
}
function isCompletedAssistantNotification(notification: CodexServerNotification): boolean {
if (!isJsonObject(notification.params)) {
return false;

View File

@@ -1,6 +1,7 @@
// Codex tests cover attempt turn watches plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { updateActiveCompletionBlockerItemIds } from "./attempt-notifications.js";
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
describe("Codex app-server attempt turn watches", () => {
@@ -23,6 +24,7 @@ describe("Codex app-server attempt turn watches", () => {
let terminalQueued = false;
let activeRequests = 0;
let activeItems = 0;
let activeCompletionBlockers = 0;
const interrupts: Array<Record<string, unknown>> = [];
const timeouts: Array<Record<string, unknown>> = [];
const events: Array<{ name: string; fields: Record<string, unknown> }> = [];
@@ -36,6 +38,7 @@ describe("Codex app-server attempt turn watches", () => {
isTerminalTurnNotificationQueued: () => terminalQueued,
getActiveAppServerTurnRequests: () => activeRequests,
getActiveTurnItemCount: () => activeItems,
getActiveCompletionBlockerItemCount: () => activeCompletionBlockers,
turnCompletionIdleTimeoutMs: 10,
turnAssistantCompletionIdleTimeoutMs: 10,
turnAttemptIdleTimeoutMs: 10,
@@ -69,6 +72,9 @@ describe("Codex app-server attempt turn watches", () => {
set activeItems(value: number) {
activeItems = value;
},
set activeCompletionBlockers(value: number) {
activeCompletionBlockers = value;
},
interrupts,
timeouts,
events,
@@ -155,6 +161,32 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.aborted).toBe(false);
});
it("waits for active completion blocker items before firing completion idle timeout", () => {
const harness = createController();
harness.activeCompletionBlockers = 1;
harness.controller.touchActivity("request:mcpServer/elicitation/request:response", {
arm: true,
});
vi.advanceTimersByTime(10);
expect(harness.timeouts).toEqual([]);
expect(harness.abortController.signal.aborted).toBe(false);
harness.activeCompletionBlockers = 0;
harness.controller.touchActivity("notification:item/completed");
vi.advanceTimersByTime(10);
expect(harness.timeouts).toMatchObject([
{
kind: "completion",
idleMs: 10,
timeoutMs: 10,
lastActivityReason: "notification:item/completed",
},
]);
});
it("releases a completed assistant item after the assistant idle guard expires", () => {
const harness = createController();
@@ -214,3 +246,41 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.reason).toBe("turn_progress_idle_timeout");
});
});
describe("Codex completion blocker item tracking", () => {
it.each([
"collabAgentToolCall",
"commandExecution",
"dynamicToolCall",
"fileChange",
"imageGeneration",
"imageView",
"mcpToolCall",
"webSearch",
])("tracks the %s lifecycle", (type) => {
const activeItemIds = new Set<string>();
updateActiveCompletionBlockerItemIds(
{ method: "item/started", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set(["item-1"]));
updateActiveCompletionBlockerItemIds(
{ method: "item/completed", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set());
});
it.each(["agentMessage", "contextCompaction", "plan", "reasoning", "subAgentActivity"])(
"does not track the %s lifecycle",
(type) => {
const activeItemIds = new Set<string>();
updateActiveCompletionBlockerItemIds(
{ method: "item/started", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set());
},
);
});

View File

@@ -36,6 +36,7 @@ export function createCodexAttemptTurnWatchController(params: {
isTerminalTurnNotificationQueued: () => boolean;
getActiveAppServerTurnRequests: () => number;
getActiveTurnItemCount: () => number;
getActiveCompletionBlockerItemCount: () => number;
turnCompletionIdleTimeoutMs: number;
turnAssistantCompletionIdleTimeoutMs: number;
turnAttemptIdleTimeoutMs: number;
@@ -121,7 +122,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isCompleted() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return;
}
@@ -183,7 +185,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return false;
}
@@ -302,7 +305,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return;
}

View File

@@ -1578,6 +1578,7 @@ export async function runCodexAppServerAttempt(
let activeAppServerTurnRequests = 0;
const pendingOpenClawDynamicToolCompletionIds = new Set<string>();
const activeTurnItemIds = new Set<string>();
const activeCompletionBlockerItemIds = new Set<string>();
let turnCrossedToolHandoff = false;
let pendingTerminalDynamicToolRelease:
| {
@@ -1627,6 +1628,7 @@ export async function runCodexAppServerAttempt(
isTerminalTurnNotificationQueued: () => terminalTurnNotificationQueued,
getActiveAppServerTurnRequests: () => activeAppServerTurnRequests,
getActiveTurnItemCount: () => activeTurnItemIds.size,
getActiveCompletionBlockerItemCount: () => activeCompletionBlockerItemIds.size,
turnCompletionIdleTimeoutMs,
turnAssistantCompletionIdleTimeoutMs,
turnAttemptIdleTimeoutMs,
@@ -1899,6 +1901,7 @@ export async function runCodexAppServerAttempt(
currentPromptTexts: [codexTurnPromptText],
turnWatches,
activeTurnItemIds,
activeCompletionBlockerItemIds,
activeAppServerTurnRequests,
pendingOpenClawDynamicToolCompletionIds,
turnCrossedToolHandoff,

View File

@@ -49,9 +49,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -78,6 +76,7 @@ describe("createCodexAttemptTurnWatchController", () => {
isTerminalTurnNotificationQueued: () => false,
getActiveAppServerTurnRequests: () => 0,
getActiveTurnItemCount: () => 0,
getActiveCompletionBlockerItemCount: () => 0,
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 500,
turnAttemptIdleTimeoutMs: 200,
@@ -807,6 +806,93 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(result.promptError).toBeNull();
});
it("keeps an eliciting MCP tool active past the completion timeout", async () => {
const harness = createStartedThreadHarness();
const bridgedResponse = {
action: "accept",
content: null,
_meta: null,
} as const;
vi.spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest").mockResolvedValue(
bridgedResponse,
);
const params = createParams(
path.join(tempDir, "session-mcp-elicitation.jsonl"),
path.join(tempDir, "workspace-mcp-elicitation"),
);
params.timeoutMs = 500;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 15,
turnAssistantCompletionIdleTimeoutMs: 1_000,
turnTerminalIdleTimeoutMs: 1_000,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "mcp-1",
type: "mcpToolCall",
server: "computer-use",
tool: "computer",
status: "inProgress",
arguments: {},
},
},
});
await expect(
harness.handleServerRequest({
id: "request-mcp-elicitation",
method: "mcpServer/elicitation/request",
params: {
threadId: "thread-1",
turnId: "turn-1",
mode: "form",
message: "Approve?",
requestedSchema: { type: "object", properties: {} },
serverName: "computer-use",
_meta: null,
},
}),
).resolves.toEqual(bridgedResponse);
await new Promise((resolve) => {
setTimeout(resolve, 40);
});
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "mcp-1",
type: "mcpToolCall",
server: "computer-use",
tool: "computer",
status: "completed",
arguments: {},
result: { content: [] },
},
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("counts pending user input requests as turn attempt progress", async () => {
const harness = createStartedThreadHarness();
const params = createParams(

View File

@@ -31,15 +31,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
};
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -63,6 +69,13 @@ function requireFirstMockObjectArg(mock: ReturnType<typeof vi.fn>, label: string
return value;
}
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("deepinfra image generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
@@ -86,11 +99,9 @@ describe("deepinfra image generation provider", () => {
const release = vi.fn(async () => {});
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
},
response: jsonResponse({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
release,
});
@@ -168,17 +179,15 @@ describe("deepinfra image generation provider", () => {
it("sends image edits as multipart OpenAI-compatible requests", async () => {
postMultipartRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
},
response: jsonResponse({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
release: vi.fn(async () => {}),
});

View File

@@ -12,6 +12,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
@@ -645,7 +646,9 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "fal image generation failed");
const payload = parseFalImageGenerationResponse(await response.json());
const payload = parseFalImageGenerationResponse(
await readProviderJsonResponse(response, "fal.image-generation"),
);
const images: GeneratedImageAsset[] = [];
let imageIndex = 0;
for (const entry of payload.images) {

View File

@@ -29,21 +29,30 @@ function expectExplicitDefaultAccountSelection(
expect(account.appId).toBe(appId);
}
function withEnvVar(key: string, value: string | undefined, run: () => void) {
function setTestEnvValue(key: string, value: string | undefined): () => void {
const prev = process.env[key];
if (value === undefined) {
delete process.env[key];
Reflect.deleteProperty(process.env, key);
} else {
process.env[key] = value;
Reflect.set(process.env, key, value);
}
return () => restoreTestEnvValue(key, prev);
}
function restoreTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
function withEnvVar(key: string, value: string | undefined, run: () => void): void {
const restore = setTestEnvValue(key, value);
try {
run();
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
}
@@ -214,8 +223,7 @@ describe("resolveFeishuCredentials", () => {
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env ";
const restore = setTestEnvValue(key, " secret_from_env ");
try {
const creds = resolveFeishuCredentials(
@@ -234,18 +242,13 @@ describe("resolveFeishuCredentials", () => {
domain: "feishu",
});
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
});
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env_alias ";
const restore = setTestEnvValue(key, " secret_from_env_alias ");
try {
const creds = resolveFeishuCredentials(
@@ -258,11 +261,7 @@ describe("resolveFeishuCredentials", () => {
expect(creds?.appSecret).toBe("secret_from_env_alias");
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
});

View File

@@ -1,16 +1,21 @@
// Feishu tests cover app registration plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { beginAppRegistration, pollAppRegistration } from "./app-registration.js";
import { beginAppRegistration, pollAppRegistration, printQrCode } from "./app-registration.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
const { fetchWithSsrFGuardMock, renderQrTerminalMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
renderQrTerminalMock: vi.fn(async () => "terminal-qr"),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("./qr-terminal.js", () => ({
renderQrTerminal: renderQrTerminalMock,
}));
function mockFeishuJson(payload: unknown) {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify(payload), { status: 200 }),
@@ -23,6 +28,7 @@ describe("Feishu app registration", () => {
vi.useRealTimers();
vi.restoreAllMocks();
fetchWithSsrFGuardMock.mockReset();
renderQrTerminalMock.mockClear();
});
it("defaults unsafe begin polling lifetimes from provider responses", async () => {
@@ -59,4 +65,16 @@ describe("Feishu app registration", () => {
await vi.runOnlyPendingTimersAsync();
await expect(poll).resolves.toEqual({ status: "timeout" });
});
it("prints scan-to-create QR codes with compact terminal rendering", async () => {
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
await printQrCode("https://accounts.feishu.cn/verify?device_code=long-device-code");
expect(renderQrTerminalMock).toHaveBeenCalledWith(
"https://accounts.feishu.cn/verify?device_code=long-device-code",
{ small: true },
);
expect(writeSpy).toHaveBeenCalledWith("terminal-qr\n");
});
});

View File

@@ -266,7 +266,7 @@ export async function pollAppRegistration(params: {
* otherwise the pattern is corrupted and cannot be scanned.
*/
export async function printQrCode(url: string): Promise<void> {
const output = await renderQrTerminal(url);
const output = await renderQrTerminal(url, { small: true });
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
}

View File

@@ -83,6 +83,14 @@ let FEISHU_USER_AGENT: string;
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
let priorFeishuTimeoutEnv: string | undefined;
function setFeishuTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
vi.mock("./channel.js", () => ({
feishuPlugin: feishuPluginMock,
}));
@@ -213,10 +221,10 @@ beforeAll(async () => {
beforeEach(() => {
priorProxyEnv = {};
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, undefined);
for (const key of proxyEnvKeys) {
priorProxyEnv[key] = process.env[key];
delete process.env[key];
setFeishuTestEnvValue(key, undefined);
}
vi.clearAllMocks();
clearClientCache();
@@ -238,18 +246,9 @@ beforeEach(() => {
afterEach(() => {
for (const key of proxyEnvKeys) {
const value = priorProxyEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (priorFeishuTimeoutEnv === undefined) {
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
} else {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
setFeishuTestEnvValue(key, priorProxyEnv[key]);
}
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv);
setFeishuClientRuntimeForTest();
});
@@ -359,7 +358,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("uses env timeout override when provided and no direct timeout is set", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
createFeishuClient({
appId: "app_8",
@@ -373,7 +372,7 @@ describe("createFeishuClient HTTP timeout", () => {
it("ignores non-decimal env timeout overrides", async () => {
for (const value of ["0x10", "1e3", "10.5"]) {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = value;
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, value);
createFeishuClient({
appId: `app-${value}`,
@@ -387,7 +386,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("prefers direct timeout over env override", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
createFeishuClient({
appId: "app_10",
@@ -401,7 +400,10 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("clamps env timeout override to max bound", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
setFeishuTestEnvValue(
FEISHU_HTTP_TIMEOUT_ENV_VAR,
String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456),
);
createFeishuClient({
appId: "app_9",
@@ -505,7 +507,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
process.env.https_proxy = "http://lower-https:8001";
setFeishuTestEnvValue("https_proxy", "http://lower-https:8001");
await createFeishuWSClient(baseAccount);
@@ -515,7 +517,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
process.env.HTTPS_PROXY = "http://upper-https:8002";
setFeishuTestEnvValue("HTTPS_PROXY", "http://upper-https:8002");
await createFeishuWSClient(baseAccount);
@@ -525,7 +527,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
process.env.HTTP_PROXY = "http://upper-http:8999";
setFeishuTestEnvValue("HTTP_PROXY", "http://upper-http:8999");
await createFeishuWSClient(baseAccount);

View File

@@ -8,6 +8,13 @@ import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provi
let ssrfMock: { mockRestore: () => void } | undefined;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGoogleApiKeyAuth() {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
@@ -24,9 +31,8 @@ function installGoogleFetchMock(params?: {
const mimeType = params?.mimeType ?? "image/png";
const data = params?.data ?? "png-data";
const inlineDataKey = params?.inlineDataKey ?? "inlineData";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -42,7 +48,7 @@ function installGoogleFetchMock(params?: {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
@@ -100,9 +106,8 @@ describe("Google image-generation provider", () => {
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -119,7 +124,7 @@ describe("Google image-generation provider", () => {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -208,10 +213,7 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ candidates: { content: { parts: [] } } }),
}),
vi.fn().mockResolvedValue(jsonResponse({ candidates: { content: { parts: [] } } })),
);
const provider = buildGoogleImageGenerationProvider();
@@ -229,9 +231,8 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -240,7 +241,7 @@ describe("Google image-generation provider", () => {
},
],
}),
}),
),
);
const provider = buildGoogleImageGenerationProvider();
@@ -260,9 +261,8 @@ describe("Google image-generation provider", () => {
source: "profile",
mode: "token",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -278,7 +278,7 @@ describe("Google image-generation provider", () => {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -305,6 +305,74 @@ describe("Google image-generation provider", () => {
});
});
it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => {
mockGoogleApiKeyAuth();
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
const imagePayload = imageBytes.toString("base64");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: Array.from({ length: 3 }, () => ({
inlineData: {
mimeType: "image/png",
data: imagePayload,
},
})),
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
const result = await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
});
expect(result.images).toHaveLength(3);
expect(result.images.map((image) => image.buffer.byteLength)).toEqual([
imageBytes.byteLength,
imageBytes.byteLength,
imageBytes.byteLength,
]);
});
it("still rejects oversized Google image JSON responses", async () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: [{ text: "x".repeat(35 * 1024 * 1024) }],
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
await expect(
provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("google.image-generation: JSON response exceeds");
});
it("sends reference images and explicit resolution for edit flows", async () => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();

View File

@@ -1,15 +1,18 @@
// Google provider module implements model/runtime integration.
import {
generatedImageAssetFromBase64,
resolveInlineImageJsonResponseMaxBytes,
type GeneratedImageAsset,
type ImageGenerationProvider,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -22,6 +25,8 @@ import { normalizeGoogleModelId, resolveGoogleGenerativeAiHttpRequestConfig } fr
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
const DEFAULT_OUTPUT_MIME = "image/png";
const GOOGLE_MAX_IMAGE_RESULTS = 4;
const MB = 1024 * 1024;
const GOOGLE_SUPPORTED_SIZES = [
"1024x1024",
"1024x1536",
@@ -49,6 +54,16 @@ function normalizeGoogleImageModel(model: string | undefined): string {
return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL);
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function mapSizeToImageConfig(
size: string | undefined,
): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined {
@@ -149,14 +164,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}),
capabilities: {
generate: {
maxCount: 4,
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
},
edit: {
enabled: true,
maxCount: 4,
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
maxInputImages: 5,
supportsSize: true,
supportsAspectRatio: true,
@@ -231,7 +246,12 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(res, "Google image generation failed");
const payload = await res.json();
const payload = await readProviderJsonResponse(res, "google.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
GOOGLE_MAX_IMAGE_RESULTS,
resolveGeneratedImageMaxBytes(req),
),
});
let imageIndex = 0;
const images: GeneratedImageAsset[] = [];
for (const part of googleResponseParts(payload)) {

View File

@@ -33,15 +33,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -49,13 +55,18 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGeneratedPngResponse() {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
},
response: jsonResponse({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
}

View File

@@ -23,6 +23,23 @@ describe("matrix thread context", () => {
).toBe("Thread starter body");
});
it("truncates long thread starter bodies on code-point boundaries", () => {
const summary = summarizeMatrixThreadStarterEvent({
event_id: "$root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
// 496 "a" + astral emoji (surrogate pair at units 496-497) + tail.
// A raw slice(0, 497) would cut the pair and leave a lone high surrogate.
body: `${"a".repeat(496)}\u{1F600}bcd`,
},
} as MatrixRawEvent);
expect(summary).toBe(`${"a".repeat(496)}...`);
expect(summary && /[\uD800-\uDFFF]/.test(summary)).toBe(false);
});
it("marks media-only thread starter events instead of returning bare filenames", () => {
expect(
summarizeMatrixThreadStarterEvent({

View File

@@ -1,4 +1,5 @@
// Matrix plugin module implements thread context behavior.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { MatrixClient } from "../sdk.js";
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
import type { MatrixRawEvent } from "./types.js";
@@ -17,7 +18,7 @@ function truncateThreadStarterBody(value: string): string {
if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
return value;
}
return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
return `${sliceUtf16Safe(value, 0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
}
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {

View File

@@ -2342,7 +2342,7 @@ describe("memory cli", () => {
lastRecalledAt: "<now>",
queryHashes: ["<hash>"],
recallDays: ["<today>"],
conceptTags: ["backup", "backups", "glacier"],
conceptTags: ["backup", "backups", "glacier", "s3"],
});
expect(close).toHaveBeenCalled();
});

View File

@@ -35,6 +35,19 @@ const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for(
"openclaw.memoryCore.dreamingNarrative.sessionLocks",
);
const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32";
const originalNarrativeStateDir = process.env.OPENCLAW_STATE_DIR;
function setNarrativeTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreNarrativeTestEnv(): void {
if (originalNarrativeStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalNarrativeStateDir);
}
}
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
@@ -89,7 +102,7 @@ async function expectPathMissing(targetPath: string): Promise<void> {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
restoreNarrativeTestEnv();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
resolveGlobalMap<string, unknown>(NARRATIVE_SESSION_LOCKS_KEY).clear();
});
@@ -1228,7 +1241,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setNarrativeTestEnv(stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
@@ -1297,7 +1310,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setNarrativeTestEnv(stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("A forgotten endpoint hummed in the dark.");

View File

@@ -11,7 +11,7 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
testing,
filterRecallEntriesWithinLookback,
@@ -30,6 +30,8 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const DREAMING_TEST_BASE_TIME = new Date("2026-04-05T10:00:00.000Z");
const DREAMING_TEST_DAY = "2026-04-05";
const originalDreamingTestFast = process.env.OPENCLAW_TEST_FAST;
const originalDreamingStateDir = process.env.OPENCLAW_STATE_DIR;
const EMPTY_SESSION_CONTENT_HASH =
"75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070";
const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
@@ -59,6 +61,28 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
},
};
function setDreamingTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", "1");
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreDreamingTestEnv(): void {
if (originalDreamingTestFast === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_TEST_FAST");
} else {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", originalDreamingTestFast);
}
if (originalDreamingStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalDreamingStateDir);
}
}
afterEach(() => {
restoreDreamingTestEnv();
});
function requireCandidateByKey<T extends { key: string }>(candidates: T[], key: string): T {
const candidate = candidates.find((entry) => entry.key === key);
if (!candidate) {
@@ -947,8 +971,7 @@ describe("memory-core dreaming phases", () => {
it("checkpoints session transcript ingestion and skips unchanged transcripts", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1022,7 +1045,7 @@ describe("memory-core dreaming phases", () => {
([target]) => typeof target === "string" && target === transcriptPath,
).length;
readSpy.mockRestore();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
expect(transcriptReadCount).toBeLessThanOrEqual(1);
@@ -1051,8 +1074,7 @@ describe("memory-core dreaming phases", () => {
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagentWorkspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main");
const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo");
@@ -1122,7 +1144,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const mainCorpus = await fs.readFile(
@@ -1141,8 +1163,7 @@ describe("memory-core dreaming phases", () => {
it("redacts sensitive session content before writing session corpus", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1198,7 +1219,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusPath = path.join(
@@ -1215,8 +1236,7 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming-generated narrative transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1291,7 +1311,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1308,8 +1328,7 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1387,7 +1406,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1404,8 +1423,7 @@ describe("memory-core dreaming phases", () => {
it("skips isolated cron run transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "cron-run.jsonl");
@@ -1480,7 +1498,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1496,8 +1514,7 @@ describe("memory-core dreaming phases", () => {
it("drops generated system wrapper text without suppressing paired assistant replies", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "ordinary-session.jsonl");
@@ -1580,7 +1597,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpus = await fs.readFile(
@@ -1595,8 +1612,7 @@ describe("memory-core dreaming phases", () => {
it("drops archive, cron, and heartbeat chatter from fresh session corpus output", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
@@ -1729,7 +1745,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpus = await fs.readFile(
@@ -1781,8 +1797,7 @@ describe("memory-core dreaming phases", () => {
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1859,14 +1874,13 @@ describe("memory-core dreaming phases", () => {
readFileSpy.mockRestore();
} finally {
vi.restoreAllMocks();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
});
it("dedupes reset/deleted session archives instead of double-ingesting", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1958,7 +1972,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -1989,8 +2003,7 @@ describe("memory-core dreaming phases", () => {
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const archivePath = path.join(
@@ -2048,7 +2061,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -2061,8 +2074,7 @@ describe("memory-core dreaming phases", () => {
it("buckets session snippets by per-message day rather than file mtime", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2127,7 +2139,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
@@ -2142,8 +2154,7 @@ describe("memory-core dreaming phases", () => {
it("drains >80 unseen transcript messages across multiple unchanged sweeps", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2200,7 +2211,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 7);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusPath = path.join(
@@ -2222,8 +2233,7 @@ describe("memory-core dreaming phases", () => {
it("re-ingests rewritten session transcripts after truncate/reset", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2300,7 +2310,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -2317,8 +2327,7 @@ describe("memory-core dreaming phases", () => {
it("ingests sessions when dreaming is enabled even if memorySearch is disabled", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2376,7 +2385,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({

View File

@@ -43,6 +43,7 @@ let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
@@ -58,6 +59,18 @@ function createLocalWorkerExitError(): Error {
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", () => {
const embedText = (text: string) => {
const lower = text.toLowerCase();
@@ -276,7 +289,7 @@ describe("memory index", () => {
closeOpenClawStateDatabaseForTest();
clearRegistry();
managersForCleanup.clear();
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
@@ -298,7 +311,7 @@ describe("memory index", () => {
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-memory-index"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
@@ -488,7 +501,7 @@ describe("memory index", () => {
stateDirName: string;
}): Promise<MemoryIndexManager | null> {
forceNoProvider = true;
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, params.stateDirName));
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
sources: ["memory", "sessions"],
sessionMemory: true,
@@ -573,7 +586,7 @@ describe("memory index", () => {
it("reindexes memory tables in place without deleting unrelated agent rows", async () => {
const stateDir = path.join(workspaceDir, "managed-memory-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setMemoryIndexStateDir(stateDir);
const agentDbPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
const agentDb = openOpenClawAgentDatabase({ agentId: "main" });
agentDb.db
@@ -1117,7 +1130,7 @@ describe("memory index", () => {
it("clears dirty after sessions-only identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-only-reindex"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1167,13 +1180,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-missing-meta"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1223,13 +1236,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("keeps provider cutover vector search paused during targeted session sync", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
@@ -1287,13 +1300,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("preserves memory dirty events raised during session identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-dirty-during-session"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1351,7 +1364,7 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
@@ -2230,7 +2243,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
@@ -2274,7 +2287,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
});

View File

@@ -56,9 +56,32 @@ type MemoryTranscriptUpdateSubscriber = (
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR;
const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
function setStartupStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function setStartupConfigPath(configPath: string): void {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", configPath);
}
function restoreStartupEnv(): void {
if (originalStartupStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalStartupStateDir);
}
if (originalStartupConfigPath === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH");
} else {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", originalStartupConfigPath);
}
}
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
protected readonly cfg = {} as OpenClawConfig;
protected readonly agentId = "main";
@@ -230,13 +253,13 @@ describe("session startup catch-up", () => {
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-startup-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setStartupStateDir(stateDir);
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllEnvs();
restoreStartupEnv();
clearRuntimeConfigSnapshot();
clearConfigCache();
await fs.rm(stateDir, { recursive: true, force: true });
@@ -458,7 +481,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -505,7 +528,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -553,7 +576,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -660,7 +683,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);

View File

@@ -13,6 +13,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { buildSessionEntryMock } = vi.hoisted(() => ({
buildSessionEntryMock: vi.fn(),
}));
const originalSyncYieldStateDir = process.env.OPENCLAW_STATE_DIR;
function setSyncYieldStateDir(): void {
Reflect.set(
process.env,
"OPENCLAW_STATE_DIR",
path.join(os.tmpdir(), "openclaw-session-sync-yield"),
);
}
function restoreSyncYieldStateDir(): void {
if (originalSyncYieldStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSyncYieldStateDir);
}
}
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
@@ -162,7 +179,7 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps {
describe("session sync responsiveness", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(os.tmpdir(), "openclaw-session-sync-yield"));
setSyncYieldStateDir();
buildSessionEntryMock.mockImplementation(async (absPath: string) => {
const name = path.basename(absPath);
return {
@@ -177,7 +194,7 @@ describe("session sync responsiveness", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
restoreSyncYieldStateDir();
vi.clearAllMocks();
});

View File

@@ -18,6 +18,19 @@ const createEmbeddingProviderMock = vi.hoisted(() =>
providerUnavailableReason: "No embeddings provider available.",
})),
);
const originalFtsOnlyStateDir = process.env.OPENCLAW_STATE_DIR;
function setFtsOnlyStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreFtsOnlyStateDir(): void {
if (originalFtsOnlyStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalFtsOnlyStateDir);
}
}
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
@@ -44,7 +57,7 @@ describe("memory manager FTS-only reindex", () => {
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
setFtsOnlyStateDir(path.join(workspaceDir, "state"));
indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
});
@@ -54,7 +67,7 @@ describe("memory manager FTS-only reindex", () => {
manager = null;
}
await closeAllMemorySearchManagers();
vi.unstubAllEnvs();
restoreFtsOnlyStateDir();
});
afterAll(async () => {

View File

@@ -13,6 +13,19 @@ import type { MemoryIndexMeta } from "./manager-reindex-state.js";
type SessionDeltaState = { lastSize: number; pendingBytes: number; pendingMessages: number };
type SyncSessionParams = { needsFullReindex: boolean; targetSessionFiles?: string[] };
const originalReindexStateDir = process.env.OPENCLAW_STATE_DIR;
function setReindexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreReindexStateDir(): void {
if (originalReindexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalReindexStateDir);
}
}
type ReindexHarness = {
sync: (params: { reason?: string; force?: boolean }) => Promise<void>;
@@ -42,11 +55,11 @@ describe("memory manager reindex recovery", () => {
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state"));
setReindexStateDir(path.join(fixtureRoot, "state"));
});
afterEach(async () => {
vi.unstubAllEnvs();
restoreReindexStateDir();
vi.restoreAllMocks();
if (manager) {
await manager.close();

View File

@@ -16,6 +16,19 @@ const createEmbeddingProviderMock = vi.hoisted(() =>
providerUnavailableReason: "No embeddings provider available.",
})),
);
const originalSelfHealStateDir = process.env.OPENCLAW_STATE_DIR;
function setSelfHealStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreSelfHealStateDir(): void {
if (originalSelfHealStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSelfHealStateDir);
}
}
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
@@ -49,7 +62,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
setSelfHealStateDir(path.join(workspaceDir, "state"));
indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
});
@@ -59,7 +72,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
manager = null;
}
await closeAllMemorySearchManagers();
vi.unstubAllEnvs();
restoreSelfHealStateDir();
});
afterAll(async () => {

View File

@@ -17,18 +17,21 @@ describe("memory manager sync failures", () => {
unhandled.push(reason);
};
process.on("unhandledRejection", handler);
const syncSpy = vi
.fn()
.mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request"));
setTimeout(() => {
runDetachedMemorySync(syncSpy, "watch");
}, 1);
try {
const syncSpy = vi
.fn()
.mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request"));
setTimeout(() => {
runDetachedMemorySync(syncSpy, "watch");
}, 1);
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
await syncSpy.mock.results[0]?.value?.catch(() => undefined);
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
await syncSpy.mock.results[0]?.value?.catch(() => undefined);
process.off("unhandledRejection", handler);
expect(unhandled).toHaveLength(0);
expect(unhandled).toHaveLength(0);
} finally {
process.off("unhandledRejection", handler);
}
});
});

View File

@@ -121,6 +121,19 @@ const {
const CHOKIDAR_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
const NATIVE_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
const originalWatcherStateDir = process.env.OPENCLAW_STATE_DIR;
function setWatcherStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreWatcherStateDir(): void {
if (originalWatcherStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalWatcherStateDir);
}
}
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async (importOriginal) => {
const actual =
@@ -194,7 +207,7 @@ describe("memory watcher config", () => {
}
await closeAllMemorySearchManagers();
clearRegistry();
vi.unstubAllEnvs();
restoreWatcherStateDir();
if (workspaceDir) {
await fs.rm(workspaceDir, { recursive: true, force: true });
workspaceDir = "";
@@ -204,7 +217,7 @@ describe("memory watcher config", () => {
async function setupWatcherWorkspace(seedFile: { name: string; contents: string }) {
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
setWatcherStateDir(path.join(workspaceDir, "state"));
extraDir = path.join(workspaceDir, "extra");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.mkdir(extraDir, { recursive: true });

View File

@@ -76,6 +76,19 @@ import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host
import { QmdMemoryManager } from "./qmd-manager.js";
const spawnMock = mockedSpawn as unknown as Mock;
const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR;
function setQmdStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreQmdStateDir(): void {
if (originalQmdStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir);
}
}
describe("QmdMemoryManager slugified path resolution", () => {
let tmpRoot: string;
@@ -172,7 +185,7 @@ describe("QmdMemoryManager slugified path resolution", () => {
workspaceDir = path.join(tmpRoot, "workspace");
stateDir = path.join(tmpRoot, "state");
await fs.mkdir(workspaceDir, { recursive: true });
process.env.OPENCLAW_STATE_DIR = stateDir;
setQmdStateDir(stateDir);
cfg = {
agents: {
@@ -197,7 +210,7 @@ describe("QmdMemoryManager slugified path resolution", () => {
);
openManagers.clear();
await fs.rm(tmpRoot, { recursive: true, force: true });
delete process.env.OPENCLAW_STATE_DIR;
restoreQmdStateDir();
});
it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => {

View File

@@ -199,11 +199,17 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => {
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
type MemorySearchRuntimeDebug,
requireNodeSqlite,
resolveMemoryBackendConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js";
import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js";
@@ -211,6 +217,19 @@ const spawnMock = mockedSpawn as unknown as Mock;
const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
const originalWindowsPath = process.env.Path;
const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR;
function setQmdStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreQmdStateDir(): void {
if (originalQmdStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir);
}
}
describe("QmdMemoryManager", () => {
let fixtureRoot: string;
@@ -257,6 +276,14 @@ describe("QmdMemoryManager", () => {
return mock.mock.calls.map((call: unknown[]) => String(call[0]));
}
function qmdCommandCalls(): string[][] {
return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
}
function countQmdCommand(predicate: (args: string[]) => boolean): number {
return qmdCommandCalls().filter(predicate).length;
}
function expectMockMessageContains(mock: Mock, text: string): void {
expect(mockMessages(mock).join("\n")).toContain(text);
}
@@ -277,6 +304,387 @@ describe("QmdMemoryManager", () => {
);
});
it("reuses persisted collection validation across transient cli managers", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0);
});
it("does not cache incomplete collection validation", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "permission denied", 1);
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.close();
spawnMock.mockClear();
spawnMock.mockImplementation(() => createMockChild());
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("runs collection validation when the runtime cache store is unavailable", async () => {
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
const manager = await createManager({ mode: "cli" });
await manager.manager.close();
} finally {
await configureMemoryCoreDreamingStateForTests();
}
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("reports collection validation debug only once per validation run", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined();
});
it("misses collection validation cache when managed collection config changes", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
const changedCfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
...cfg.memory?.qmd,
paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockClear();
const second = await createManager({ mode: "cli", cfg: changedCfg });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
});
it("bypasses validation cache for missing-collection search repair", async () => {
await configureMemoryCoreDreamingStateForTests();
const { manager } = await createManager();
spawnMock.mockClear();
let searchAttempts = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
searchAttempts += 1;
if (searchAttempts === 1) {
emitAndClose(child, "stderr", "collection workspace-main not found", 1);
} else {
emitAndClose(child, "stdout", "[]");
}
return child;
}
return createMockChild();
});
const debug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
expect(searchAttempts).toBe(2);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force");
});
it("reuses persisted qmd multi-collection support probe across managers", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
});
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const debug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit");
expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2);
});
it("reports multi-collection probe debug only when the probe runs", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined();
});
it("keeps concurrent search debug isolated on a shared qmd manager", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
let firstSearchChild: MockChild | undefined;
let searchCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
searchCalls += 1;
const child = createMockChild({ autoClose: false });
if (searchCalls === 1) {
firstSearchChild = child;
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
}
if (args[0] === "--version") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "qmd 1.0.0");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
const firstSearch = manager.search("memory fact", {
sessionKey: "agent:main:slack:dm:u123",
sources: ["memory"],
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await waitUntil(() => searchCalls === 1);
const secondSearch = manager.search("session fact", {
sessionKey: "agent:main:slack:dm:u123",
sources: ["sessions"],
onDebug: (entry) => {
secondDebug.push(entry);
},
});
await waitUntil(() => searchCalls === 2);
emitAndClose(requireValue(firstSearchChild, "first search child missing"), "stdout", "[]");
await Promise.all([firstSearch, secondSearch]);
expect(firstDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["memory"]);
expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]);
});
it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => {
await configureMemoryCoreDreamingStateForTests();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
{ path: otherWorkspaceDir, pattern: "**/*.md", name: "other" },
],
},
},
} as OpenClawConfig;
const isCombinedSearch = (args: string[]) =>
(args[0] === "search" || args[0] === "query") &&
args.filter((token) => token === "-c").length > 1;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--version") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "qmd 1.0.0");
return child;
}
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (isCombinedSearch(args)) {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "unknown flag: -c", 1);
return child;
}
if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await first.manager.close();
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "write",
supported: false,
});
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const secondDebug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(countQmdCommand(isCombinedSearch)).toBe(0);
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "hit",
supported: false,
});
});
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.lstat(targetPath);
@@ -340,7 +748,7 @@ describe("QmdMemoryManager", () => {
// Only workspace must exist for configured collection paths; state paths are
// created lazily by manager code when needed.
await fs.mkdir(workspaceDir, { recursive: true });
process.env.OPENCLAW_STATE_DIR = stateDir;
setQmdStateDir(stateDir);
// Keep the default Windows path unresolved for most tests so spawn mocks can
// match the logical package command. Tests that verify wrapper resolution
// install explicit shim fixtures inline.
@@ -387,7 +795,7 @@ describe("QmdMemoryManager", () => {
embedStartupJitterSpy?.mockRestore();
embedStartupJitterSpy = null;
vi.useRealTimers();
delete process.env.OPENCLAW_STATE_DIR;
restoreQmdStateDir();
if (originalPath === undefined) {
delete process.env.PATH;
} else {
@@ -406,6 +814,7 @@ describe("QmdMemoryManager", () => {
delete (globalThis as Record<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[QMD_EMBED_QUEUE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[MEMORY_EMBEDDING_PROVIDERS_KEY];
resetMemoryCoreDreamingStateForTests();
});
it("debounces back-to-back sync calls", async () => {
@@ -6450,7 +6859,7 @@ describe("QmdMemoryManager", () => {
// directory instead of the real ~/.cache.
savedXdgCacheHome = process.env.XDG_CACHE_HOME;
const fakeCacheHome = path.join(tmpRoot, "fake-cache");
process.env.XDG_CACHE_HOME = fakeCacheHome;
Reflect.set(process.env, "XDG_CACHE_HOME", fakeCacheHome);
defaultModelsDir = path.join(fakeCacheHome, "qmd", "models");
await fs.mkdir(defaultModelsDir, { recursive: true });
@@ -6461,9 +6870,9 @@ describe("QmdMemoryManager", () => {
afterEach(() => {
if (savedXdgCacheHome === undefined) {
delete process.env.XDG_CACHE_HOME;
Reflect.deleteProperty(process.env, "XDG_CACHE_HOME");
} else {
process.env.XDG_CACHE_HOME = savedXdgCacheHome;
Reflect.set(process.env, "XDG_CACHE_HOME", savedXdgCacheHome);
}
});

View File

@@ -74,6 +74,16 @@ import {
type QmdSessionArtifactMapping,
} from "../qmd-session-artifacts.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
import {
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
} from "./qmd-runtime-cache.js";
import {
countChokidarWatchedEntries,
type MemoryWatchPressureWarningState,
@@ -324,6 +334,19 @@ type ManagedCollection = {
kind: "memory" | "custom" | "sessions";
};
type QmdCollectionValidationDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
>;
type QmdMultiCollectionProbeDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
>;
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
type QmdSearchRuntimeDebugContext = {
collectionValidation?: QmdCollectionValidationDebug;
multiCollectionProbe?: QmdMultiCollectionProbeDebug;
searchPlan?: QmdSearchPlanDebug;
};
type QmdManagerMode = "full" | "status" | "cli";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
@@ -441,6 +464,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private mode: QmdManagerMode = "full";
private readonly closeSignal: Promise<void>;
private resolveCloseSignal!: () => void;
private qmdRuntimeIdentityPromise: Promise<string> | null = null;
private db: SqliteDatabase | null = null;
private lastUpdateAt: number | null = null;
private lastEmbedAt: number | null = null;
@@ -453,6 +477,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
private multiCollectionFilterSupported: boolean | null = null;
private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined;
private constructor(params: {
agentId: string;
@@ -612,11 +637,171 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private async ensureCollections(): Promise<void> {
private qmdRuntimeCacheSources(): string[] {
return [...this.sources].toSorted();
}
private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] {
return this.qmd.collections.map((collection) => ({
name: collection.name,
kind: collection.kind,
path: collection.path,
pattern: collection.pattern,
}));
}
private buildQmdRuntimeEnvironmentHash(): string {
const relevantEnv = Object.fromEntries(
Object.keys(this.env)
.filter(
(key) =>
key === "PATH" ||
key === "HOME" ||
key === "LOCALAPPDATA" ||
key === "XDG_CONFIG_HOME" ||
key === "XDG_CACHE_HOME" ||
key === "QMD_CONFIG_DIR" ||
key.startsWith("QMD_"),
)
.toSorted()
.map((key) => [key, this.env[key] ?? ""]),
);
return crypto.createHash("sha256").update(JSON.stringify(relevantEnv)).digest("hex");
}
private async buildQmdCollectionValidationCacheContext(): Promise<QmdRuntimeCollectionValidationCacheContext> {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdVersion: await this.resolveQmdRuntimeIdentity(),
qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(),
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
collections: this.qmdRuntimeCacheCollections(),
sources: this.qmdRuntimeCacheSources(),
};
}
private async buildQmdMultiCollectionProbeCacheContext(): Promise<QmdRuntimeMultiCollectionProbeCacheContext> {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdVersion: await this.resolveQmdRuntimeIdentity(),
qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(),
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
sources: this.qmdRuntimeCacheSources(),
};
}
private resolveQmdRuntimeIdentity(): Promise<string> {
this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity();
return this.qmdRuntimeIdentityPromise;
}
private async readQmdRuntimeIdentity(): Promise<string> {
const commandIdentity = `command:${this.qmd.command}`;
try {
const result = await this.runQmd(["--version"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 2_000),
});
const versionText = `${result.stdout}\n${result.stderr}`.trim();
return versionText ? `${commandIdentity};version:${versionText}` : commandIdentity;
} catch {
return commandIdentity;
}
}
private recordSearchPlanDebug(params: {
debugContext: QmdSearchRuntimeDebugContext;
command: "query" | "search" | "vsearch";
collectionNames: string[];
collectionGroups: string[][];
}): void {
const sources = uniqueValues(
params.collectionNames
.map((collectionName) => this.collectionRoots.get(collectionName)?.kind)
.filter((source): source is MemorySource => Boolean(source)),
);
params.debugContext.searchPlan = {
command: params.command,
collectionCount: params.collectionNames.length,
groupCount: params.collectionGroups.length,
sources,
};
}
private beginQmdSearchRuntimeDebug(): QmdSearchRuntimeDebugContext {
const debugContext: QmdSearchRuntimeDebugContext = {};
if (this.pendingCollectionValidationDebug) {
debugContext.collectionValidation = this.pendingCollectionValidationDebug;
this.pendingCollectionValidationDebug = undefined;
}
return debugContext;
}
private consumeQmdRuntimeDebug(
debugContext: QmdSearchRuntimeDebugContext,
): MemorySearchRuntimeDebug["qmd"] | undefined {
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
if (debugContext.collectionValidation) {
debug.collectionValidation = debugContext.collectionValidation;
}
if (debugContext.multiCollectionProbe) {
debug.multiCollectionProbe = debugContext.multiCollectionProbe;
}
if (debugContext.searchPlan) {
debug.searchPlan = debugContext.searchPlan;
}
return Object.keys(debug).length > 0 ? debug : undefined;
}
private async ensureCollectionPathsBestEffort(): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.ensureCollectionPath(collection);
} catch (err) {
log.warn(
`qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`,
);
}
}
}
private async ensureCollections(options?: {
force?: boolean;
debugContext?: QmdSearchRuntimeDebugContext;
}): Promise<void> {
const startedAt = Date.now();
const cacheContext = await this.buildQmdCollectionValidationCacheContext();
if (!options?.force) {
const cached = await readQmdCollectionValidationCache(cacheContext);
if (cached.state === "hit") {
await this.ensureCollectionPathsBestEffort();
const debug: QmdCollectionValidationDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: cached.value.validation.collectionCount,
listCalls: 0,
showCalls: 0,
};
if (options?.debugContext) {
options.debugContext.collectionValidation = debug;
} else {
this.pendingCollectionValidationDebug = debug;
}
return;
}
}
const stats = { listCalls: 0, showCalls: 0 };
let validationComplete = true;
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = await this.listCollectionsBestEffort();
const existing = await this.listCollectionsBestEffort(stats);
await this.migrateLegacyUnscopedCollections(existing);
@@ -631,6 +816,7 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
validationComplete = false;
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
}
}
@@ -661,13 +847,36 @@ export class QmdMemoryManager implements MemorySearchManager {
pattern: collection.pattern,
});
} else {
validationComplete = false;
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
}
validationComplete = false;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
const wroteCache = validationComplete
? await writeQmdCollectionValidationCache(cacheContext)
: false;
const debug: QmdCollectionValidationDebug = {
cacheState: validationComplete
? options?.force
? "bypass-force"
: wroteCache
? "write"
: "error"
: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: this.qmd.collections.length,
listCalls: stats.listCalls,
showCalls: stats.showCalls,
};
if (options?.debugContext) {
options.debugContext.collectionValidation = debug;
} else {
this.pendingCollectionValidationDebug = debug;
}
}
private async tryRebindSameNameCollection(params: {
@@ -713,9 +922,15 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
private async listCollectionsBestEffort(stats?: {
listCalls: number;
showCalls: number;
}): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {
if (stats) {
stats.listCalls += 1;
}
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -737,6 +952,9 @@ export class QmdMemoryManager implements MemorySearchManager {
continue;
}
try {
if (stats) {
stats.showCalls += 1;
}
const showResult = await this.runQmd(["collection", "show", collection.name], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -956,14 +1174,17 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async tryRepairMissingCollectionSearch(err: unknown): Promise<boolean> {
private async tryRepairMissingCollectionSearch(
err: unknown,
debugContext: QmdSearchRuntimeDebugContext,
): Promise<boolean> {
if (!this.isMissingCollectionSearchError(err)) {
return false;
}
log.warn(
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
);
await this.ensureCollections();
await this.ensureCollections({ force: true, debugContext });
return true;
}
@@ -1318,6 +1539,7 @@ export class QmdMemoryManager implements MemorySearchManager {
if (searchSignal?.aborted) {
throw asAbortError(searchSignal);
}
const debugContext = this.beginQmdSearchRuntimeDebug();
const trimmed = query.trim();
if (!trimmed) {
return [];
@@ -1344,6 +1566,7 @@ export class QmdMemoryManager implements MemorySearchManager {
const runSearchAttempt = async (
allowMissingCollectionRepair: boolean,
): Promise<QmdQueryResult[]> => {
let attemptedCombinedCollectionFilter = false;
try {
if (mcporterEnabled) {
const minScore = opts?.minScore ?? 0;
@@ -1402,7 +1625,15 @@ export class QmdMemoryManager implements MemorySearchManager {
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
debugContext,
);
this.recordSearchPlanDebug({
debugContext,
command: qmdSearchCommand,
collectionNames,
collectionGroups,
});
attemptedCombinedCollectionFilter = collectionGroups.some((group) => group.length > 1);
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1424,6 +1655,9 @@ export class QmdMemoryManager implements MemorySearchManager {
qmdSearchCommand !== "query" &&
this.isUnsupportedQmdOptionError(err)
) {
if (attemptedCombinedCollectionFilter) {
await this.markQmdMultiCollectionFiltersUnsupported(debugContext);
}
effectiveSearchMode = "query";
searchFallbackReason = "unsupported-search-flags";
log.warn(
@@ -1433,7 +1667,14 @@ export class QmdMemoryManager implements MemorySearchManager {
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
debugContext,
);
this.recordSearchPlanDebug({
debugContext,
command: "query",
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1463,7 +1704,7 @@ export class QmdMemoryManager implements MemorySearchManager {
try {
parsed = await runSearchAttempt(true);
} catch (err) {
if (!(await this.tryRepairMissingCollectionSearch(err))) {
if (!(await this.tryRepairMissingCollectionSearch(err, debugContext))) {
throw err instanceof Error ? err : new Error(String(err));
}
parsed = await runSearchAttempt(false);
@@ -1512,6 +1753,7 @@ export class QmdMemoryManager implements MemorySearchManager {
configuredMode: qmdSearchCommand,
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
qmd: this.consumeQmdRuntimeDebug(debugContext),
});
let ranked = results;
if (opts?.sources?.length) {
@@ -3370,23 +3612,41 @@ export class QmdMemoryManager implements MemorySearchManager {
private async resolveCollectionSearchGroups(
collectionNames: string[],
signal?: AbortSignal,
debugContext?: QmdSearchRuntimeDebugContext,
): Promise<string[][]> {
if (collectionNames.length <= 1) {
return [collectionNames];
}
if (!(await this.supportsQmdMultiCollectionFilters(signal))) {
if (!(await this.supportsQmdMultiCollectionFilters(signal, debugContext))) {
return collectionNames.map((collectionName) => [collectionName]);
}
return this.groupCollectionNamesBySource(collectionNames);
}
private async supportsQmdMultiCollectionFilters(signal?: AbortSignal): Promise<boolean> {
private async supportsQmdMultiCollectionFilters(
signal?: AbortSignal,
debugContext?: QmdSearchRuntimeDebugContext,
): Promise<boolean> {
if (signal?.aborted) {
throw asAbortError(signal);
}
if (this.multiCollectionFilterSupported !== null) {
return this.multiCollectionFilterSupported;
}
const startedAt = Date.now();
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
if (cached.state === "hit") {
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
}
return this.multiCollectionFilterSupported;
}
try {
const result = await this.runQmd(["--help"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
@@ -3395,17 +3655,50 @@ export class QmdMemoryManager implements MemorySearchManager {
const helpText = `${result.stdout}\n${result.stderr}`;
this.multiCollectionFilterSupported =
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
const wroteCache = await writeQmdMultiCollectionProbeCache(
cacheContext,
this.multiCollectionFilterSupported,
);
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
}
} catch (err) {
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
if (signal?.aborted) {
throw asAbortError(signal);
}
this.multiCollectionFilterSupported = false;
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
}
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
}
return this.multiCollectionFilterSupported;
}
private async markQmdMultiCollectionFiltersUnsupported(
debugContext: QmdSearchRuntimeDebugContext,
): Promise<void> {
const startedAt = Date.now();
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
this.multiCollectionFilterSupported = false;
await clearQmdMultiCollectionProbeCache(cacheContext);
const wroteCache = await writeQmdMultiCollectionProbeCache(cacheContext, false);
debugContext.multiCollectionProbe = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
}
private async runQueryAcrossCollectionGroups(
query: string,
limit: number,

View File

@@ -0,0 +1,323 @@
import path from "node:path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
openMemoryCoreStateStore,
memoryCoreWorkspaceEntryKey,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import {
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
buildQmdMultiCollectionProbeCacheContextHash,
clearQmdCollectionValidationCache,
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
} from "./qmd-runtime-cache.js";
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
});
afterAll(async () => {
resetMemoryCoreDreamingStateForTests();
});
async function clearStore(namespace: string): Promise<void> {
try {
await openMemoryCoreStateStore({
namespace,
maxEntries: 1_000,
}).clear();
} catch {
// fail open
}
}
afterEach(async () => {
await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE);
await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE);
});
async function withWorkspace<T>(run: (workspaceDir: string) => Promise<T>): Promise<T> {
return await withTempDir("qmd-runtime-cache-", run);
}
function managedCollections(): QmdRuntimeManagedCollection[] {
return [
{
name: "project-notes",
kind: "memory",
path: "/repo/project-notes",
pattern: "*.md",
},
{
name: "sessions",
kind: "sessions",
path: "/repo/sessions",
pattern: "*",
},
];
}
function collectionValidationContext(
workspaceDir: string,
): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
collections: managedCollections(),
sources: ["memory", "sessions"],
};
}
function multiCollectionProbeContext(
workspaceDir: string,
): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
sources: ["memory", "sessions"],
};
}
describe("qmd-runtime-cache", () => {
it("writes and reads collection validation cache entries", async () => {
await withWorkspace(async (workspaceDir) => {
const context = collectionValidationContext(workspaceDir);
const writeStartedAtMs = 1_000;
const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdCollectionValidationCache(
{ ...context, sources: ["sessions", "memory"] },
writeStartedAtMs + 1,
);
expect(read).toMatchObject({
state: "hit",
value: {
validation: {
ok: true,
collectionCount: context.collections.length,
},
},
});
});
});
it("writes and reads multi-collection probe cache entries", async () => {
await withWorkspace(async (workspaceDir) => {
const context = multiCollectionProbeContext(workspaceDir);
const writeStartedAtMs = 2_000;
const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1);
expect(read).toMatchObject({
state: "hit",
value: {
multiCollectionProbe: {
supported: true,
},
},
});
});
});
it("scopes cache entries by workspace", async () => {
await withWorkspace(async (firstWorkspace) => {
await withWorkspace(async (secondWorkspace) => {
const context = collectionValidationContext(firstWorkspace);
expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true);
const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = {
...context,
workspaceDir: secondWorkspace,
qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"),
};
const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001);
expect(miss).toStrictEqual({ state: "miss" });
});
});
});
it("misses collection validation cache when managed collection paths change", async () => {
await withWorkspace(async (workspaceDir) => {
const context = collectionValidationContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true);
const changedContext: QmdRuntimeCollectionValidationCacheContext = {
...context,
collections: context.collections.map((collection) =>
collection.name === "project-notes"
? {
name: collection.name,
kind: collection.kind,
path: `${collection.path}-moved`,
pattern: collection.pattern,
}
: collection,
),
};
expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({
state: "miss",
});
});
});
it("misses validation and probe caches when qmd runtime environment changes", async () => {
await withWorkspace(async (workspaceDir) => {
const validationContext = {
...collectionValidationContext(workspaceDir),
qmdEnvironmentHash: "env-a",
};
const probeContext = {
...multiCollectionProbeContext(workspaceDir),
qmdEnvironmentHash: "env-a",
};
expect(await writeQmdCollectionValidationCache(validationContext, 3_600)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 3_600)).toBe(true);
expect(
await readQmdCollectionValidationCache(
{ ...validationContext, qmdEnvironmentHash: "env-b" },
3_601,
),
).toStrictEqual({ state: "miss" });
expect(
await readQmdMultiCollectionProbeCache(
{ ...probeContext, qmdEnvironmentHash: "env-b" },
3_601,
),
).toStrictEqual({ state: "miss" });
});
});
it("treats cache misses for malformed values and expired entries", async () => {
await withWorkspace(async (workspaceDir) => {
const context = multiCollectionProbeContext(workspaceDir);
const nowMs = 4_000;
await writeQmdMultiCollectionProbeCache(context, false, nowMs);
const key = memoryCoreWorkspaceEntryKey(
workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`,
);
const store = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
await store.register(key, {
version: 1,
createdAtMs: "bad",
expiresAtMs: 0,
keyHash: "bad",
multiCollectionProbe: { supported: true },
});
const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1);
expect(malformed).toStrictEqual({ state: "miss" });
const expired = await readQmdMultiCollectionProbeCache(
context,
nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1,
);
expect(expired).toStrictEqual({ state: "miss" });
});
});
it("uses separate namespaces for validation and probe entries", async () => {
await withWorkspace(async (workspaceDir) => {
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true);
const validationStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: 1_000,
});
const probeStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
expect((await validationStore.entries()).length).toBeGreaterThan(0);
expect((await probeStore.entries()).length).toBeGreaterThan(0);
});
});
it("fails open when state store is unavailable", async () => {
await withWorkspace(async (workspaceDir) => {
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false);
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({
state: "miss",
});
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false);
} finally {
await configureMemoryCoreDreamingStateForTests();
}
});
});
it("exposes bounded TTL windows", () => {
expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000);
expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000);
});
it("can clear cache keys explicitly", async () => {
await withWorkspace(async (workspaceDir) => {
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true);
await clearQmdCollectionValidationCache(validationContext);
await clearQmdMultiCollectionProbeCache(probeContext);
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({
state: "miss",
});
});
});
});

View File

@@ -0,0 +1,435 @@
// Memory Core QMD runtime cache helpers.
import { createHash } from "node:crypto";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE =
"qmd-runtime-cache.collection-validation";
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
"qmd-runtime-cache.multi-collection-probe";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1;
export type QmdRuntimeManagedCollection = {
name: string;
kind: "memory" | "custom" | "sessions";
path: string;
pattern: string;
};
type QmdRuntimeCacheContextBase = {
workspaceDir: string;
agentId: string;
qmdCommand: string;
qmdVersion?: string;
qmdEnvironmentHash?: string;
qmdIndexPath: string;
searchMode: string;
};
export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & {
collections: readonly QmdRuntimeManagedCollection[];
sources: readonly string[];
};
export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & {
sources: readonly string[];
};
export type QmdRuntimeCacheCollectionValidationEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
validation: {
ok: true;
collectionConfigHash: string;
collectionCount: number;
};
};
export type QmdRuntimeCacheMultiCollectionProbeEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
multiCollectionProbe: {
supported: boolean;
};
};
export type QmdRuntimeCacheResult<T> =
| {
state: "hit";
value: T;
}
| { state: "miss" };
function normalizeText(value: string): string {
return value.trim();
}
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
return {
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
};
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function normalizePathIdentity(value: string): string {
const normalized =
process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value);
return hashText(normalized);
}
function sortedUnique(values: readonly string[]): string[] {
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted();
}
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
const normalized = collections
.map((collection) => ({
...normalizeCollection(collection),
}))
.toSorted(
(left, right) =>
left.name.localeCompare(right.name) ||
left.kind.localeCompare(right.kind) ||
left.pathHash.localeCompare(right.pathHash) ||
left.pattern.localeCompare(right.pattern),
)
.map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`)
.join(";");
return hashText(normalized);
}
function buildCollectionValidationCacheContextInput(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
collectionConfigHash: buildCollectionConfigHash(params.collections),
});
}
function buildMultiCollectionProbeCacheContextInput(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
});
}
function buildCollectionValidationCacheHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return hashText(buildCollectionValidationCacheContextInput(params));
}
function buildMultiCollectionProbeCacheHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return hashText(buildMultiCollectionProbeCacheContextInput(params));
}
export function buildQmdCollectionValidationCacheContextHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return buildCollectionValidationCacheHash(params);
}
export function buildQmdMultiCollectionProbeCacheContextHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return buildMultiCollectionProbeCacheHash(params);
}
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
});
}
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
});
}
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`,
);
}
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`,
);
}
function normalizeCollectionValidationEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheCollectionValidationEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const validation = record.validation;
if (typeof validation !== "object" || validation === null) {
return undefined;
}
const validationRecord = validation as Record<string, unknown>;
if (validationRecord.ok !== true) {
return undefined;
}
if (typeof validationRecord.collectionConfigHash !== "string") {
return undefined;
}
if (typeof validationRecord.collectionCount !== "number") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
validation: {
ok: true,
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
},
};
}
function normalizeMultiCollectionProbeEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheMultiCollectionProbeEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const probe = record.multiCollectionProbe;
if (typeof probe !== "object" || probe === null) {
return undefined;
}
const probeRecord = probe as Record<string, unknown>;
if (typeof probeRecord.supported !== "boolean") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
multiCollectionProbe: {
supported: probeRecord.supported,
},
};
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
try {
const store = collectionValidationStore();
const key = collectionValidationEntryKey(params);
const expectedKeyHash = buildCollectionValidationCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = collectionValidationEntryKey(params);
const keyHash = buildCollectionValidationCacheHash(params);
const collectionConfigHash = buildCollectionConfigHash(params.collections);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
const store = collectionValidationStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
validation: {
ok: true,
collectionConfigHash,
collectionCount: params.collections.length,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
): Promise<void> {
try {
const store = collectionValidationStore();
await store.delete(collectionValidationEntryKey(params));
} catch {
// fail open
}
}
export async function readQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
try {
const store = multiCollectionProbeStore();
const key = multiCollectionProbeEntryKey(params);
const expectedKeyHash = buildMultiCollectionProbeCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
supported: boolean,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = multiCollectionProbeEntryKey(params);
const keyHash = buildMultiCollectionProbeCacheHash(params);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
const store = multiCollectionProbeStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
multiCollectionProbe: {
supported,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): Promise<void> {
try {
const store = multiCollectionProbeStore();
await store.delete(multiCollectionProbeEntryKey(params));
} catch {
// fail open
}
}

View File

@@ -326,6 +326,10 @@ describe("getMemorySearchManager caching", () => {
expect(first.manager).toBe(second.manager);
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
expect(first.debug?.managerCacheState).toBe("cached-full-miss");
expect(second.debug?.managerCacheState).toBe("cached-full-hit");
expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash);
});
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
@@ -806,6 +810,10 @@ describe("getMemorySearchManager caching", () => {
const fullManager = requireManager(full);
const cliManager = requireManager(cli);
expect(cli.debug?.managerCacheState).toBe("transient-cli");
expect(full.debug?.managerCacheState).toBe("cached-full-miss");
expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash);
expect(cliManager).toBe(cliPrimary);
expect(cliManager).not.toBe(fullManager);
const fullCreateParams = qmdCreateParams();

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
// Memory Core plugin module implements search manager behavior.
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -48,6 +49,24 @@ type QmdManagerOpenFailure = {
retryAfterMs: number;
};
type MemorySearchManagerCacheState =
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
export type MemorySearchManagerDebug = {
backend?: "builtin" | "qmd";
purpose?: MemorySearchManagerPurpose;
managerMs?: number;
managerCacheState?: MemorySearchManagerCacheState;
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
type MemorySearchManagerCacheStore = {
qmdManagerCache: Map<string, CachedQmdManagerEntry>;
pendingQmdManagerCreates: Map<string, PendingQmdManagerCreate>;
@@ -109,6 +128,7 @@ function loadQmdManagerModule() {
export type MemorySearchManagerResult = {
manager: Maybe<MemorySearchManager>;
error?: string;
debug?: MemorySearchManagerDebug;
};
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
@@ -149,11 +169,42 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void
}
}
function hashQmdManagerIdentity(identityKey: string): string {
return createHash("sha256").update(identityKey).digest("hex");
}
function applyManagerDebug(
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult {
if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) {
return result;
}
return {
...result,
debug: {
...result.debug,
...debug,
},
};
}
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
const acquireStartedAt = Date.now();
const purpose = params.purpose ?? "default";
const finish = (
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult =>
applyManagerDebug(result, {
purpose,
managerMs: Math.max(0, Date.now() - acquireStartedAt),
...debug,
});
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const qmdResolved = resolved.qmd;
@@ -163,6 +214,7 @@ export async function getMemorySearchManager(params: {
const transient = params.purpose === "status" || params.purpose === "cli";
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
const debugIdentityHash = hashQmdManagerIdentity(identityKey);
const createPrimaryQmdManager = async (
mode: "full" | "status" | "cli",
@@ -254,10 +306,24 @@ export async function getMemorySearchManager(params: {
// Status callers often close the manager they receive. Wrap the live
// full manager with a no-op close so health/status probes do not tear
// down the active QMD manager for the process.
return { manager: new BorrowedMemoryManager(cached.manager) };
return finish(
{ manager: new BorrowedMemoryManager(cached.manager) },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
}
if (params.purpose !== "cli") {
return { manager: cached.manager };
return finish(
{ manager: cached.manager },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
}
}
@@ -266,20 +332,44 @@ export async function getMemorySearchManager(params: {
params.purpose === "cli" ? "cli" : "status",
);
return manager
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
}
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
if (recentFailure) {
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
return finish(
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
{
backend: "qmd",
managerCacheState: "recent-failure-cooldown",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
},
);
}
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
if (pending) {
await pending.promise;
return await getMemorySearchManager(params);
return finish(await getMemorySearchManager(params), {
backend: "qmd",
managerCacheState: "pending-create-wait",
qmdIdentityHash: debugIdentityHash,
});
}
let pendingFailureReason: string | undefined;
@@ -309,11 +399,25 @@ export async function getMemorySearchManager(params: {
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
const manager = await pendingCreate.promise;
return manager
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: "cached-full-miss",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
}
return await getBuiltinMemorySearchManager(params);
return finish(await getBuiltinMemorySearchManager(params), {
backend: "builtin",
});
}
async function getBuiltinMemorySearchManagerAfterQmdFailure(

View File

@@ -0,0 +1,44 @@
// Memory Core provider tests cover plugin runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { describe, expect, it, vi } from "vitest";
const managerDebug = {
backend: "qmd" as const,
purpose: "default" as const,
managerMs: 7,
managerCacheState: "cached-full-hit" as const,
qmdIdentityHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
};
const getMemorySearchManagerMock = vi.hoisted(() =>
vi.fn(async () => ({
manager: null,
debug: managerDebug,
error: undefined,
})),
);
vi.mock("./memory/index.js", () => ({
closeAllMemorySearchManagers: vi.fn(async () => {}),
closeMemorySearchManager: vi.fn(async () => {}),
getMemorySearchManager: getMemorySearchManagerMock,
}));
import { memoryRuntime } from "./runtime-provider.js";
describe("memoryRuntime", () => {
it("preserves manager debug metadata", async () => {
const cfg = {} as OpenClawConfig;
const result = await memoryRuntime.getMemorySearchManager({
cfg,
agentId: "main",
});
expect(result.debug).toEqual(managerDebug);
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
});
});
});

View File

@@ -9,9 +9,10 @@ import {
export const memoryRuntime: MemoryPluginRuntime = {
async getMemorySearchManager(params) {
const { manager, error } = await getMemorySearchManager(params);
const { manager, debug, error } = await getMemorySearchManager(params);
return {
manager,
debug,
error,
};
},

View File

@@ -67,18 +67,28 @@ export async function getMemoryManagerContextWithPurpose(params: {
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const { manager, error } = await getMemorySearchManager({
const startedAt = Date.now();
const { manager, debug, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
return manager
? {
manager,
debug: {
...debug,
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
},
}
: { error };
}
export function createMemoryTool(params: {

View File

@@ -1,4 +1,5 @@
// Memory Core tests cover tools plugin behavior.
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getMemoryCloseMockCalls,
@@ -381,6 +382,95 @@ describe("memory_search unavailable payloads", () => {
expect(searchCalls).toBe(2);
});
it("merges qmd runtime debug across zero-hit retry attempts", async () => {
setMemoryBackend("qmd");
let searchCalls = 0;
setMemorySearchImpl(async (opts) => {
searchCalls += 1;
if (searchCalls === 1) {
opts?.onDebug?.({
backend: "qmd",
configuredMode: "search",
effectiveMode: "search",
qmd: {
collectionValidation: {
cacheState: "hit",
elapsedMs: 2,
collectionCount: 2,
listCalls: 0,
showCalls: 0,
},
multiCollectionProbe: {
cacheState: "hit",
elapsedMs: 1,
supported: true,
},
},
});
return [];
}
opts?.onDebug?.({
backend: "qmd",
configuredMode: "search",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Thread-hidden codename: ORBIT-22.",
source: "memory" as const,
},
];
});
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd", citations: "off" },
},
});
const result = await tool.execute("zero-hit-debug-retry", {
query: "hidden thread codename",
});
const details = result.details as {
debug?: {
effectiveMode?: string;
fallback?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
};
};
expect(searchCalls).toBe(2);
expect(details.debug?.effectiveMode).toBe("query");
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.qmd?.collectionValidation).toMatchObject({
cacheState: "hit",
collectionCount: 2,
});
expect(details.debug?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "hit",
supported: true,
});
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("returns unavailable metadata when the index identity is paused", async () => {
let searchCalls = 0;
setMemorySearchImpl(async () => {
@@ -422,6 +512,14 @@ describe("memory_search unavailable payloads", () => {
configuredMode: opts.qmdSearchModeOverride ?? "query",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
@@ -470,6 +568,18 @@ describe("memory_search unavailable payloads", () => {
fallback?: unknown;
hits?: unknown;
searchMs?: number;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
managerCacheState?: unknown;
qmd?: {
searchPlan?: {
command?: unknown;
collectionCount?: unknown;
groupCount?: unknown;
sources?: unknown;
};
};
};
};
expect(details.mode).toBe("query");
@@ -479,6 +589,94 @@ describe("memory_search unavailable payloads", () => {
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.hits).toBe(1);
expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBeUndefined();
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("includes manager acquisition timing and cache-state debug payload", async () => {
setMemorySearchManagerImpl(
async () =>
({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}) as any,
);
setMemorySearchImpl(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
]);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd" },
},
});
const result = await tool.execute("manager-debug", { query: "favorite food" });
const details = result.details as {
debug?: {
backend?: string;
managerMs?: number;
toolMs?: number;
outsideSearchMs?: number;
managerCacheState?: string;
hits?: number;
searchMs?: number;
};
};
expect(details.debug?.backend).toBe("qmd");
expect(details.debug?.managerMs).toBe(17);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBe("cached-full-hit");
});
});

View File

@@ -44,12 +44,35 @@ type MemorySearchToolResult =
| MemoryCorpusSearchResult;
type MemoryManagerContext = Awaited<ReturnType<typeof getMemoryManagerContextWithPurpose>>;
type ActiveMemoryManagerContext = Extract<MemoryManagerContext, { manager: unknown }>;
type QmdRuntimeDebug = NonNullable<MemorySearchRuntimeDebug["qmd"]>;
const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000;
const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000;
const memorySearchToolCooldowns = new Map<string, { until: number; error: string }>();
function mergeQmdRuntimeDebug(
entries: readonly MemorySearchRuntimeDebug[],
): MemorySearchRuntimeDebug["qmd"] | undefined {
const merged: QmdRuntimeDebug = {};
for (const entry of entries) {
const qmd = entry.qmd;
if (!qmd) {
continue;
}
if (!merged.collectionValidation && qmd.collectionValidation) {
merged.collectionValidation = qmd.collectionValidation;
}
if (qmd.multiCollectionProbe) {
merged.multiCollectionProbe = qmd.multiCollectionProbe;
}
if (qmd.searchPlan) {
merged.searchPlan = qmd.searchPlan;
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
function resolveMemorySearchToolCooldownKey(options: {
agentId?: string;
agentSessionKey?: string;
@@ -415,6 +438,7 @@ export function createMemorySearchTool(options: {
const outcome = await runMemorySearchToolWithDeadline({
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
run: async (deadlineSignal) => {
const toolStartedAt = Date.now();
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
@@ -471,13 +495,20 @@ export function createMemorySearchTool(options: {
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let managerMs: number | undefined;
let managerCacheState: string | undefined;
let searchDebug:
| {
backend: string;
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
searchMs: number;
managerCacheState?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
hits: number;
}
| undefined;
@@ -506,6 +537,8 @@ export function createMemorySearchTool(options: {
},
...(searchSources ? { sources: searchSources } : {}),
};
managerMs = memory.debug?.managerMs;
managerCacheState = memory.debug?.managerCacheState;
try {
rawResults = await activeMemory.manager.search(query, searchOptions);
} catch (error) {
@@ -522,6 +555,8 @@ export function createMemorySearchTool(options: {
if ("error" in refreshed) {
throw error;
}
managerMs = refreshed.debug?.managerMs;
managerCacheState = refreshed.debug?.managerCacheState;
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
@@ -580,7 +615,9 @@ export function createMemorySearchTool(options: {
model = status.model;
fallback = status.fallback;
const latestDebug = runtimeDebug.at(-1);
const qmdDebug = mergeQmdRuntimeDebug(runtimeDebug);
searchMode = latestDebug?.effectiveMode;
const searchMs = Math.max(0, Date.now() - searchStartedAt);
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
@@ -589,7 +626,10 @@ export function createMemorySearchTool(options: {
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
searchMs: Math.max(0, Date.now() - searchStartedAt),
managerMs,
searchMs,
managerCacheState,
qmd: qmdDebug,
hits: rawResults.length,
};
});
@@ -620,6 +660,14 @@ export function createMemorySearchTool(options: {
maxResults: effectiveMax,
balanceCorpora: requestedCorpus === "all",
});
if (searchDebug) {
const finalToolMs = Math.max(0, Date.now() - toolStartedAt);
searchDebug = {
...searchDebug,
toolMs: finalToolMs,
outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs),
};
}
return jsonResult({
results,
provider,

View File

@@ -49,15 +49,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
vi.mock("./runtime.js", () => ({
prepareFoundryRuntimeAuth: prepareFoundryRuntimeAuthMock,
@@ -69,12 +75,16 @@ function buildConfig(
modelName?: string;
baseUrl?: string;
includeModel?: boolean;
mediaMaxMb?: number;
} = {},
): OpenClawConfig {
const baseUrl = params.baseUrl ?? "https://example.services.ai.azure.com/openai/v1";
const modelId = params.modelId ?? "image-deployment";
const modelName = params.modelName ?? "MAI-Image-2.5";
return {
...(params.mediaMaxMb !== undefined
? { agents: { defaults: { mediaMaxMb: params.mediaMaxMb } } }
: {}),
models: {
providers: {
[PROVIDER_ID]: {
@@ -227,6 +237,64 @@ describe("microsoft foundry image generation provider", () => {
expect(result.images[0]?.mimeType).toBe("image/png");
});
it("accepts a valid max-size MAI image JSON response", async () => {
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: imageBytes.toString("base64") }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
const result = await provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig(),
});
expect(result.images).toHaveLength(1);
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
});
it("honors configured generated media caps above the default image limit", async () => {
const imageBytes = Buffer.alloc(7 * 1024 * 1024, 1);
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: imageBytes.toString("base64") }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
const result = await provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig({ mediaMaxMb: 8 }),
});
expect(result.images).toHaveLength(1);
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
});
it("rejects oversized MAI image JSON responses", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: "x".repeat(10 * 1024 * 1024) }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
await expect(
provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig(),
}),
).rejects.toThrow("microsoft-foundry.image-generation: JSON response exceeds");
});
it("uses AZURE_OPENAI_ENDPOINT when env API-key auth has no configured base URL", async () => {
vi.stubEnv("AZURE_OPENAI_ENDPOINT", "https://env.services.ai.azure.com");
postJsonRequestMock.mockResolvedValue(

Some files were not shown because too many files have changed in this diff Show More