Compare commits

..

100 Commits

Author SHA1 Message Date
Dallin Romney
7a2cccbe8b docs: tighten changed lint guidance 2026-06-18 17:41:58 -07:00
Dallin Romney
0c03ce94e8 chore: target changed lint checks 2026-06-18 16:53:39 -07:00
Josh Lehman
49e6f5a524 refactor(auto-reply): add lifecycle storage seams (#93685)
* refactor(auto-reply): add lifecycle storage seams

* fix(auto-reply): remove unused transcript replay shim
2026-06-18 16:40:27 -07:00
Sally O'Malley
95c87e31e2 fix: preserve pending subagent completion announces (#94349)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-18 19:38:11 -04:00
Vincent Koc
0d2102d247 fix(e2e): validate plugin log limits before setup 2026-06-19 01:31:33 +02:00
Vincent Koc
55323103b9 fix(e2e): validate codex media timeout 2026-06-19 01:24:12 +02:00
Vincent Koc
239b4de6af fix(e2e): validate fixture log limits 2026-06-19 01:17:35 +02:00
Vincent Koc
a7b52ecad9 fix(e2e): validate cleanup log limits 2026-06-19 01:08:42 +02:00
Vincent Koc
bb44c5326e fix(e2e): validate docker log limits 2026-06-19 01:02:59 +02:00
Vincent Koc
4764258b3f fix(live): validate docker pids limits 2026-06-19 00:54:57 +02:00
Vincent Koc
6af1b97b1d fix(e2e): validate docker pids limits 2026-06-19 00:46:17 +02:00
Dallin Romney
4ca0e52d0e test: fold channel message flows into qa e2e (#93174)
* test: fold channel flows into qa e2e

* test: keep channel flow skill pointed at qa

* test: move channel flow proof under telegram
2026-06-18 15:45:33 -07:00
Vincent Koc
37eea55afa fix(e2e): validate docker build limits 2026-06-19 00:35:49 +02:00
VACInc
ea76a45917 Prevent Codex thread rotation from losing next-step context (#94093)
Merged via squash.

Prepared head SHA: 1f3ced8f63
Maintainer decision: `checks-node-core-tooling` is an unrelated baseline/tooling failure; PR-relevant CI and real behavior proof passed.

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-18 15:33:48 -07:00
Vincent Koc
84bcdaa983 fix(e2e): validate fixture cleanup interval 2026-06-19 00:29:25 +02:00
Vincent Koc
4ac192deef fix(agents): correct claw-score validation workflow 2026-06-19 00:26:35 +02:00
Vincent Koc
3c9cf2d583 fix(e2e): validate log tail limits 2026-06-19 00:18:39 +02:00
Dallin Romney
c4ae2be947 fix: taxonomy coverage id cleanup (#94304)
* fix: split taxonomy coverage id features

* fix: clean taxonomy feature row names

* docs: clarify taxonomy coverage id semantics

* docs: tighten coverage id guidance

* fix: keep taxonomy features product shaped

* fix: narrow sdk artifact coverage bundle

* fix: name taxonomy coverage ids clearly

* fix: polish taxonomy feature descriptions
2026-06-18 15:16:58 -07:00
Vincent Koc
27310bfa34 fix(e2e): validate docker e2e ports 2026-06-19 00:09:47 +02:00
Xavier Coulon
fbc12e0879 fix(slack): stop leaking bot token into /api/auth.test request body (#94574)
* fix(slack): stop leaking bot token into /api/auth.test request body

The bot token is already passed as an `Authorization` header,
so we don't need to send it in the request body when calling `/api/auth.test`.

See [Slack API documentation](https://api.slack.com/methods/auth.test).

Also, showing with `curl` that the bot token is not needed in the request body when passed as an `Authorization` header when calling `/api/auth.test`:
```
curl -X POST https://slack.com:443/api/auth.test -H "Authorization: Bearer xoxb-..."
{"ok":true,"url":"https://xcoulonworkspace.slack.com/","team":"xcoulon",...}
```

Signed-off-by: Xavier Coulon <xcoulon@redhat.com>

* add test for slack auth.test token handling

verify that the bot token is not passed in the request body when calling `/api/auth.test`.

Signed-off-by: Xavier Coulon <xcoulon@redhat.com>

---------

Signed-off-by: Xavier Coulon <xcoulon@redhat.com>
2026-06-18 18:09:37 -04:00
Vincent Koc
73cdb78a1e fix(sdk): refresh plugin api baseline hash 2026-06-19 00:01:15 +02:00
Vincent Koc
0df60ad306 fix(e2e): validate docker resource limits 2026-06-19 00:00:42 +02:00
Vincent Koc
9928516a78 fix(codex): deliver native subagent idle results 2026-06-18 23:41:34 +02:00
Josh Lehman
7845182410 clawdbot-d02.1.9.1.39: add task session registry maintenance seam (#93734) 2026-06-18 14:36:10 -07:00
Vincent Koc
aba6f7ad21 fix(live): validate model sweep limits 2026-06-18 23:33:44 +02:00
Vincent Koc
5570a10bf4 refactor(auth): remove stale external auth persist wrapper 2026-06-19 05:26:31 +08:00
Vincent Koc
98857235d5 fix(live): validate gateway model limits 2026-06-18 23:26:21 +02:00
Vincent Koc
39e9336d40 test(scripts): match installer e2e env validation 2026-06-18 23:23:24 +02:00
Vincent Koc
392f5b75bf fix(e2e): validate kitchen sink fixture wait attempts 2026-06-18 23:21:28 +02:00
Vincent Koc
a98bfdb2b7 refactor(agents): remove stale wrapper exports 2026-06-19 05:18:25 +08:00
Vincent Koc
34d402f53c fix(e2e): validate plugin fixture stop attempts 2026-06-18 23:17:00 +02:00
Vincent Koc
1faf8175e4 fix(e2e): validate onboard gateway wait attempts 2026-06-18 23:12:41 +02:00
Vincent Koc
fdb042b9ce refactor(providers): remove stale primary model helper 2026-06-19 05:10:15 +08:00
Colin Johnson
d5a27b0b96 test: add QA Lab UX Matrix evidence scenario (#94306)
* test: add qa lab ux matrix script scenario

* fix(qa-lab): annotate UX Matrix producer catch callback as unknown for oxlint

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-18 14:10:06 -07:00
Vincent Koc
9328f4a675 fix(e2e): validate bun smoke timeout env 2026-06-18 23:07:22 +02:00
Vincent Koc
75df29c215 fix(install): validate install e2e agent env 2026-06-18 23:03:34 +02:00
Vincent Koc
bf8ac0d96d refactor(cli): remove stale command group helpers 2026-06-19 05:02:54 +08:00
Vincent Koc
bfb47a03b3 fix(install): validate install smoke timing env 2026-06-18 22:57:17 +02:00
Vincent Koc
a93fc87e2c refactor(cli): keep relay stream helpers test-local 2026-06-19 04:53:59 +08:00
Vincent Koc
cc3d346c15 fix(e2e): validate upgrade survivor budgets 2026-06-18 22:52:19 +02:00
Vincent Koc
a8d60d352e refactor(cli): remove stale adapter helpers 2026-06-19 04:44:51 +08:00
NianJiu
1bfa2787b5 fix(exec): resume agent turn for native chat exec approvals (#93949)
* fix(exec): resume agent turn for native chat exec approvals (issue #93918)

Extend the inline approval-pending path that PR #85239 added for webchat to
every bundled chat channel that ships an `approval-handler.runtime`
adapter (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, Matrix,
Google Chat, QQ Bot, plus webchat). When the originating turn can be
approved in the same chat, the gateway resolves the approval in place and
the agent waits inline for the command output instead of terminating the
run on the "approval-pending" tool result.

Before this fix, native chat approvals landed in the fire-and-forget
`sendExecApprovalFollowup` path. The followup either failed silently
against the agent dispatch and fell through to a direct delivery to the
operator, or never reached the agent at all; either way the model never
saw an "Exec running / Exec finished / Exec denied" event. The operator
had to send a follow-up message to recover the turn, and a new approval
was minted because the original run had already ended.

The change:

- Introduces `NATIVE_APPROVAL_CHANNELS` and `isNativeApprovalChannel`
  in `src/utils/message-channel-constants.ts`, listing the channels that
  ship a native chat approval client. `webchat` is included so the
  single-channel check inside `shouldAwaitGatewayApprovalInline` can
  move from "this one id" to "any native approval client".
- Replaces the `INTERNAL_MESSAGE_CHANNEL` equality check in
  `shouldAwaitGatewayApprovalInline` with `isNativeApprovalChannel`,
  preserving the `approvalFollowupMode` opt-out and the existing
  `unavailableReason === null` gate.
- Adds unit tests asserting inline resolution and inline denial for
  every native approval channel, plus a regression test that
  non-native channels (e.g. feishu) and explicit `approvalFollowupMode`
  settings still take the fire-and-forget path.
- Adds a `NATIVE_APPROVAL_CHANNELS` test in
  `src/utils/message-channel.test.ts` to lock the membership and the
  negative cases.

Refs https://github.com/openclaw/openclaw/issues/93918

* fix(lint): restore InternalMessageChannel type export lost during rebase

Rebase on upstream/main dropped the InternalMessageChannel type alias
from message-channel-constants.ts, breaking the plugin-sdk boundary
.dts check ('has no exported member named InternalMessageChannel').
message-channel.ts was also re-importing the type only to re-export
it, triggering the oxlint no-unused-vars rule.

- Re-add 'export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL'
  in message-channel-constants.ts so the public re-export is valid.
- Drop the redundant 'type InternalMessageChannel' from the local
  import in message-channel.ts; the value-side import is what the
  file body actually needs.

* test(exec): align native approval routing expectations
2026-06-18 16:41:04 -04:00
Vincent Koc
e385f6663a fix(live): validate docker setup timeouts 2026-06-18 22:39:43 +02:00
Vincent Koc
76bdb025d6 refactor(cli): remove stale path policy helpers 2026-06-19 04:37:29 +08:00
Vincent Koc
d2e36a176d fix(e2e): validate live plugin tool limits 2026-06-18 22:30:59 +02:00
Josh Lehman
b637414871 fix: add plugin host session cleanup seam (#93733)
* fix: add plugin host session cleanup seam (clawdbot-d02.1.9.1.38)

* test: update session accessor writer ratchet
2026-06-18 13:29:39 -07:00
Vincent Koc
aa39600793 refactor(agents): remove stale tool helpers 2026-06-19 04:28:18 +08:00
Vincent Koc
61b116d597 fix(e2e): validate plugin update timeout seconds 2026-06-18 22:23:23 +02:00
Vincent Koc
033162f209 refactor(channels): remove stale discovery helpers 2026-06-19 04:19:22 +08:00
Dallin Romney
dfd8a2220b test(scripts): route temp-dir helper importers for store.sqlite and status.scan tests (#94681) 2026-06-18 13:15:40 -07:00
Vincent Koc
74ad4f592a fix(ci): route Windows proof away from Linux Testbox 2026-06-18 22:09:58 +02:00
Vincent Koc
a14b1e05e5 refactor(channels): remove stale helper exports 2026-06-19 04:07:36 +08:00
Vincent Koc
8b63a3d551 fix(e2e): validate openwebui docker timeouts 2026-06-18 22:02:43 +02:00
Vincent Koc
f4e9a6e047 refactor(channels): remove stale bundled channel helpers 2026-06-19 03:52:49 +08:00
Vincent Koc
2ae84f75ef fix(e2e): reject invalid mcp code-mode docker ports 2026-06-18 21:47:18 +02:00
Vincent Koc
dca17477dc refactor(media): remove stale completion wake wrappers 2026-06-19 03:43:39 +08:00
Vincent Koc
7f1fa65399 fix(e2e): reject declared oversized probe bodies 2026-06-18 21:37:07 +02:00
Vincent Koc
b6a06f0e49 refactor(sandbox): remove stale bridge helpers 2026-06-19 03:34:44 +08:00
Vincent Koc
9501d4dec2 fix(e2e): reject invalid openwebui docker ports 2026-06-18 21:31:46 +02:00
Vincent Koc
d9397e5b9b fix(e2e): cancel stalled kitchen sink response streams 2026-06-18 21:27:08 +02:00
Vincent Koc
d9d7766a41 fix(scripts): ignore loose package content length headers 2026-06-18 21:23:41 +02:00
Vincent Koc
0771ac8563 refactor(core): remove unused helper wrappers 2026-06-19 03:22:07 +08:00
Vincent Koc
906174bff1 fix(scripts): ignore loose audit content length headers 2026-06-18 21:20:26 +02:00
Colin Johnson
c677424edb qa-lab: add evidence artifact gallery (#94283)
* qa-lab: add evidence artifact gallery

* qa-lab: harden evidence gallery artifacts

* qa-lab: share evidence gallery view types

* qa-lab: disable evidence preview caching

* refactor(qa-lab): resolve evidence artifacts once and trim gallery render/test duplication

* fix(qa-lab): build evidence model before sending /api/evidence success headers

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-18 12:17:46 -07:00
Vincent Koc
ea4ddb2eb5 fix(e2e): ignore loose rpc content length headers 2026-06-18 21:14:22 +02:00
Vincent Koc
f19c5f6b2f fix(release): bound cross-os loopback port probes 2026-06-18 21:11:46 +02:00
Vincent Koc
1e83f42a64 refactor(agents): remove unused Claude CLI credential writers 2026-06-19 03:10:14 +08:00
Vincent Koc
4c9b4c32ef fix(scripts): ignore loose content length headers 2026-06-18 21:05:16 +02:00
Vincent Koc
4fc5cf4579 fix(scripts): reject unsafe startup memory timeouts 2026-06-18 21:00:03 +02:00
Vincent Koc
8f8162704d refactor(gateway): remove stale restart sentinel wake predicate 2026-06-19 02:58:16 +08:00
Vincent Koc
f381dca15b fix(e2e): reject loose docker stats CPU samples 2026-06-18 20:57:55 +02:00
Vincent Koc
9ab9469d04 fix(e2e): reject unsafe bundled runtime limits 2026-06-18 20:54:56 +02:00
Vincent Koc
a2f5ac82d5 fix(e2e): reject loose credential numeric limits 2026-06-18 20:51:36 +02:00
Vincent Koc
7b7e40cb0e refactor(gateway): remove duplicate artifact collector 2026-06-19 02:50:22 +08:00
Vincent Koc
e1c2926628 fix(e2e): reject loose telegram credential limits 2026-06-18 20:47:52 +02:00
Vincent Koc
cebe5cb94a fix(e2e): reject invalid client gateway ports 2026-06-18 20:46:11 +02:00
Vincent Koc
0b14724c87 refactor(gateway): remove unused connection auth facade 2026-06-19 02:37:30 +08:00
Vincent Koc
e879a67bf7 refactor(gateway): remove unused skills reload predicate 2026-06-19 02:34:48 +08:00
Vincent Koc
53bb55e023 fix(e2e): reject invalid config writer ports 2026-06-18 20:33:31 +02:00
Vincent Koc
317919ec52 fix(e2e): reject invalid mock fixture ports 2026-06-18 20:28:20 +02:00
Vincent Koc
1f71e92297 refactor(gateway): remove deprecated attachment builder 2026-06-19 02:25:02 +08:00
Vincent Koc
d2e847e8cf fix(e2e): reject invalid telegram proof ports 2026-06-18 20:23:01 +02:00
Vincent Koc
720c0ab372 refactor(gateway): remove unused scoped call wrapper 2026-06-19 02:16:40 +08:00
Vincent Koc
70e39da00f fix(e2e): reject invalid parallels smoke ports 2026-06-18 20:15:33 +02:00
Josh Lehman
9eb6e6d326 refactor(clawdbot-d02.1.9.1.37): add session maintenance seam (#93737) 2026-06-18 11:12:49 -07:00
Vincent Koc
9de9562cb7 fix(git-hooks): avoid precommit dependency hydration 2026-06-18 20:09:48 +02:00
Vincent Koc
6b25ccc4b1 refactor(gateway): remove unused checkpoint wrappers 2026-06-19 02:04:21 +08:00
Peter Lee
111018984c fix(openai-embedding): preserve openai/ prefix for non-native base URLs (#92135)
* fix(openai-embedding): preserve openai/ prefix for non-native base URLs

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

* fix(openai-embedding): normalize model before maxInputTokens lookup so qualified models retain token cap

* fix(openai-embedding): use semantic hostname check for native OpenAI URL detection

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:03:41 -04:00
Vincent Koc
7c24de5c87 docs(changelog): refresh 2026.6.9 notes 2026-06-19 02:00:41 +08:00
Vincent Koc
3125cdacb5 fix(e2e): bound bundled runtime smoke ports 2026-06-18 19:54:19 +02:00
Vincent Koc
8151a547c5 chore(plugin-sdk): refresh API baseline hash 2026-06-18 19:54:11 +02:00
Vincent Koc
940feee71b refactor(gateway): remove unused pending work ack path 2026-06-19 01:49:44 +08:00
Vincent Koc
46d359237e fix(qa): reject invalid qa lab ports 2026-06-18 19:44:50 +02:00
Vincent Koc
ae655345c4 refactor(gateway): remove unused local interface wrapper 2026-06-19 01:42:03 +08:00
Vincent Koc
a48e5091cb fix(plugins): make StepFun npm-only 2026-06-19 01:38:11 +08:00
Vincent Koc
e9229ab77e refactor(tui): remove unused OSC8 wrapper 2026-06-19 01:33:13 +08:00
Vincent Koc
65e77b82f5 fix(e2e): reject invalid kitchen sink rpc ports 2026-06-18 19:29:39 +02:00
Vincent Koc
2c7fe6a39c test(sqlite): use shared temp directory helper 2026-06-19 01:27:49 +08:00
Vincent Koc
7ef85bfb1d fix(crabbox): refresh stale changed-gate git metadata 2026-06-18 19:23:59 +02:00
Vincent Koc
361320cd9f refactor(text): remove unused final tag predicate 2026-06-19 01:21:31 +08:00
479 changed files with 14259 additions and 13652 deletions

View File

@@ -1,44 +1,34 @@
---
name: channel-message-flows
description: "Use when previewing local channel message flow fixtures."
description: "Use when running QA Lab channel message flow evidence."
---
# Channel Message Flows
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
Use this from the OpenClaw repo root to run the QA Lab evidence for Telegram
draft/final delivery sequencing. This skill no longer launches a standalone
script; the behavior is owned by the QA scenario and its Vitest-backed e2e test.
## Telegram
## QA Scenario
Native Telegram `sendMessageDraft` tool progress, then a final answer:
Run the scenario through QA Lab:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow working-final \
--duration-ms 20000
pnpm openclaw qa suite --scenario channel-message-flows
```
Thinking preview, then a final answer:
Run the focused e2e test directly in a Codex worktree:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow thinking-final
node scripts/run-vitest.mjs extensions/telegram/src/channel-message-flows.qa.e2e.test.ts
```
## Options
## References
- `--account <accountId>`: Telegram account id when not using the default.
- `--thread-id <id>`: Telegram forum topic/message thread id.
- `--delay-ms <ms>`: Override preview update cadence.
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
- `--final-text <text>`: Override the durable final message.
- `qa/scenarios/channels/channel-message-flows.yaml`
- `extensions/telegram/src/channel-message-flows.qa.e2e.test.ts`
- `extensions/telegram/src/test-support/channel-message-flows.ts`
## Notes
- `--target` is the numeric Telegram chat id.
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
- Only `--channel telegram` is implemented for now.
The scenario covers `channels.streaming` as primary evidence and records
secondary coverage for thread preservation, delivery ordering, and reasoning
preview visibility.

View File

@@ -16,11 +16,8 @@ This skill owns the operational workflow for:
- `taxonomy.yaml`
- `docs/maturity-scores.yaml`
- `docs/maturity-scorecard.md`
- `docs/taxonomy.md`
- `docs/taxonomy-outline.md`
- `scripts/render-maturity-docs.mjs`
- `.github/workflows/maturity-scorecard.yml`
- `docs/concepts/qa-e2e-automation.md`
- `qa/scenarios/index.yaml`
Keep person-specific, maintainer-private, Discord archive, and discrawl facts
out of this repo. If a score needs private evidence, use the redacted
@@ -31,12 +28,18 @@ out of this repo. If a score needs private evidence, use the redacted
- `taxonomy.yaml` is the hand-edited source of truth for surfaces, levels,
QA profiles, categories, feature coverage IDs, docs refs, LTS overrides, and
completeness-instruction paths.
- Feature `coverageIds` are ANDed proof targets, not aliases. A feature may
list multiple IDs when each ID proves part of one capability.
- Keep categories and feature names unique, product-shaped, and broader than raw
coverage IDs. Do not promote generic IDs into standalone feature names.
- Avoid duplicate coverage-ID bundles under different feature names in one
category.
- `docs/maturity-scores.yaml` is the aggregate score source committed in this
repo. It is the only committed score data; do not add generated inventory
directories.
- `docs/maturity-scorecard.md`, `docs/taxonomy.md`, and
`docs/taxonomy-outline.md` are deterministic docs generated from the root
taxonomy and aggregate score source.
- There is no committed maturity-doc renderer or `pnpm maturity:*` script in
this repo. Do not invent generated scorecard files; update the source YAML
and current docs directly.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
enrich generated artifact docs, but they are not committed as inventory.
@@ -44,22 +47,28 @@ out of this repo. If a score needs private evidence, use the redacted
Run from the openclaw repo root.
Render committed docs:
Validate YAML structure after source edits:
```bash
pnpm maturity:render
node <<'NODE'
const fs = require("node:fs");
const YAML = require("yaml");
for (const file of ["taxonomy.yaml", "docs/maturity-scores.yaml", "qa/scenarios/index.yaml"]) {
YAML.parse(fs.readFileSync(file, "utf8"));
}
NODE
```
Check generated docs are current:
Check docs when touching docs prose:
```bash
pnpm maturity:check
pnpm check:docs
```
Render an evidence-enriched docs artifact from downloaded QA artifacts:
Run focused QA/profile checks when changing coverage IDs or profile membership:
```bash
pnpm maturity:render -- --evidence-dir .artifacts/maturity-evidence --output-dir .artifacts/maturity-docs
pnpm openclaw qa coverage --json
```
## Scoring Workflow
@@ -75,13 +84,13 @@ When asked to score or refresh a surface:
discrawl or unredacted private archives.
5. Update `docs/maturity-scores.yaml` only when the score change is backed by
public or redacted artifact evidence.
6. Run `pnpm maturity:render`.
7. Run `pnpm maturity:check`.
6. Run the YAML validation command from this skill.
7. Run `pnpm check:docs` if docs prose changed, and focused QA coverage checks
if coverage IDs or profile membership changed.
For subjective score changes, make the smallest defensible edit and leave the
evidence path in the PR or task summary. The deterministic renderer owns
Markdown structure; manual prose tweaks belong in taxonomy, score source, or
the renderer rather than in generated docs.
evidence path in the PR or task summary. Keep manual prose in current docs and
keep score data in `docs/maturity-scores.yaml`.
## Default Completeness Process
@@ -158,13 +167,9 @@ Bands:
- `Alpha`: 50-70
- `Experimental`: 0-50
## GitHub Action
The `Maturity scorecard` workflow verifies committed generated docs on PRs and
pushes. Manual dispatch can also download QA artifacts from another workflow run
with `source_run_id` and `artifact_pattern`, render evidence-enriched docs into
`.artifacts/maturity-docs`, and upload them as a GitHub artifact.
## Artifacts
Do not add the maintainer repo's `docs/kevinslin/maturity-scorecard/inventory/`
tree to openclaw. Those generated reports are intentionally replaced here by
short-lived artifact docs and the committed aggregate scorecard pages.
tree to openclaw. Evidence-enriched scorecard outputs belong in short-lived
artifacts, not committed generated docs, unless this repo adds an explicit
renderer/check workflow first.

View File

@@ -85,12 +85,22 @@ jobs:
env:
ENABLE_WSL2_FEATURES: ${{ inputs.enable_wsl2_features }}
IMPORT_UBUNTU_WSL2: ${{ inputs.import_ubuntu_wsl2 }}
UBUNTU_WSL_ROOTFS_URL: https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz
run: |
$ErrorActionPreference = "Continue"
$ok = $false
$restartRequired = $false
function Resolve-UbuntuWslRootfsUrl {
$osArch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLowerInvariant()
switch ($osArch) {
"x64" { $wslArch = "amd64" }
"arm64" { $wslArch = "arm64" }
default { throw "Unsupported Windows architecture for Ubuntu WSL rootfs: $osArch" }
}
Write-Host "ubuntu_wsl_rootfs_arch=$wslArch"
"https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-$wslArch-wsl.rootfs.tar.gz"
}
function Invoke-WslText {
param([string[]] $Arguments)
$output = & wsl.exe @Arguments 2>&1
@@ -143,8 +153,9 @@ jobs:
Write-Host "import_ubuntu_wsl2=true"
$wslRoot = "C:\wsl\UbuntuProbe"
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
$rootfsUrl = Resolve-UbuntuWslRootfsUrl
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
Invoke-WebRequest -Uri $rootfsUrl -OutFile $rootfs -UseBasicParsing
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
Write-Host $import.Text
Write-Host "wsl_import_exit=$($import.Code)"

View File

@@ -117,11 +117,11 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
## Validation

View File

@@ -2,6 +2,415 @@
Docs: https://docs.openclaw.ai
## 2026.6.9
### Highlights
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
### Changes
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @vincentkoc, and @JPKay-AI.
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix.
### Fixes
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD history: 373 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
- **PR #92845** docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement. Related #92841. Thanks @liuhao1024 and @jeugregg.
- **PR #82366** fix: use passive periodic sqlite wal checkpoints. Related #81715. Thanks @honor2030 and @KrasimirKralev.
- **PR #92815** fix(google): route Gemini CLI OAuth through the env proxy (#46184). Thanks @yetval and @EvetteYoung.
- **PR #91331** fix(mattermost): merge progress preview lines by identity. Related #89761. Thanks @iloveleon19 and @leonthe8th and @vincentkoc.
- **PR #92909** fix(tui): keep spinner active when toggling tools. Related #49763. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @CrimsonDump.
- **PR #92904** fix(elevenlabs): use current TTS model ids. Thanks @vortexopenclaw and @vincentkoc.
- **PR #92642** fix #86872: Subagent run reports success but fails to write output file. Thanks @zhangguiping-xydt and @vincentkoc and @zapper35.
- **PR #89122** refactor: route command session reads through seam. Thanks @jalehman.
- **PR #90943** fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread. Thanks @sandieman2 and @vincentkoc.
- **PR #92894** fix(skills): keep managed prompt paths readable. Related #92875. Thanks @kesslerio and @sallyom.
- **PR #39617** fix: reload config in slash command routing so dmScope is respected. Related #39605. Thanks @Ciward.
- **PR #92191** fix(agents): retry thinking-only errored turns. Related #91953. Thanks @ai-hpc and @lml2468.
- **PR #92891** fix(memory): clean stale reindex temp files. Related #92874. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @potterdigital.
- **PR #93005** Add OpenRouter Fusion guidance and prompt context. Related #92984. Thanks @sallyom.
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
- **PR #92913** fix(opencode-go): register model catalog to fix context window detection. Related #92912. Thanks @kumaxs.
- **PR #89129** refactor: route bundled plugin session callers through seam. Thanks @jalehman.
- **PR #93084** fix(agents): preserve fresh usage after compaction. Related #50795. Thanks @Hollychou924 and @leno23 and @de1tydev and @425072024 and @vincentkoc and @wuwahe3.
- **PR #92869** fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1. Thanks @zhangguiping-xydt and @vincentkoc and @chriskosys.
- **PR #93011** fix(gateway): accept file-only input on /v1/responses (parity with image-only). Thanks @yetval and @vincentkoc.
- **PR #92915** Convert QA scenarios to YAML files. Thanks @RomneyDa.
- **PR #91767** Fix one-shot Codex app-server teardown. Thanks @aliahnaf2013-max.
- **PR #92625** feat(codex): add auto plugin approvals. Thanks @kevinslin.
- **PR #91587** test(qa): add qa run --qa-profile and unified output summary/evidence. Thanks @RomneyDa.
- **PR #93104** test(reply): seed channel fixtures for dedupe tests. Thanks @RomneyDa.
- **PR #93107** test(reply): preserve telegram dedupe fallback. Thanks @RomneyDa.
- **PR #92954** fix(memory): accept local default model path migration. Thanks @mushuiyu886 and @vincentkoc.
- **PR #90936** fix(agents): do not misclassify client-disconnect abort as run timeout. Related #90764. Thanks @openperf and @reginaldomarcilon.
- **PR #90812** fix(voice-call): preserve live Twilio streams in stale reaper. Related #79121. Thanks @Takhoffman and @sahibzada-allahyar and @donkeykong91.
- **PR #93094** fix(whatsapp): bound socket operations. Thanks @mcaxtr.
- **PR #91629** fix(scripts): add database-first legacy store guard. Related #91628. Thanks @galiniliev.
- **PR #93124** fix(telegram): render progress drafts as rich previews. Thanks @Marvinthebored.
- **PR #93109** test(qa): embed profile scorecard evidence. Thanks @RomneyDa.
- **PR #87298** test: add temp directory helper guidance. Thanks @hxy91819.
- **PR #92318** fix(cron): require explicit message target proof. Thanks @hxy91819.
- **PR #93137** fix(imessage): honor disabled reply actions. Related #92142. Thanks @omarshahine and @dprev.
- **PR #93134** fix(feishu): pass card_msg_content_type to get full card content (fixes #78289). Thanks @liuhao1024 and @vincentkoc and @longdoubled7.
- **PR #93138** fix(agents): preserve literal current session resolution. Thanks @liuhao1024 and @vincentkoc.
- **PR #91225** fix #83830: [Bug]: Dreaming diary repeats "first day" narrative every sweep — same early memories dominate snippets. Thanks @mushuiyu886 and @YinLiuLiu66.
- **PR #93153** simplify QA evidence profile and mappings/coverage shape. Thanks @RomneyDa.
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
- **PR #93119** fix: accept mixed source/dist bundled roots. Related #87730. Thanks @arkyu2077 and @vincentkoc and @jasonftl.
- **PR #93130** fix(telegram): preserve sticker media paths. Related #83748. Thanks @goutamadwant and @vincentkoc and @aaajiao.
- **PR #93073** fix(agents): retry empty post-tool final turns. Thanks @fuller-stack-dev.
- **PR #91784** fix(voice-call): require realtime websocket path boundary. Thanks @jason-allen-oneal.
- **PR #89133** Restore GPT-5.3 Codex Spark OAuth routing. Thanks @VACInc.
- **PR #91996** refactor: prune unused iOS code. Thanks @zats.
- **PR #90231** fix #69443: [Bug] Subagent RPC callback to WeChat session key routed to main session instead. Thanks @zhangguiping-xydt and @sliverp and @chen11221.
- **PR #89920** fix(matrix): replace recovered command progress lines. Thanks @bdjben and @jesse-merhi.
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
- **PR #93201** fix(auto-reply): clear pending-final state before honoring post-send abort (#89115). Thanks @amknight and @danashburn.
- **PR #93228** fix(agents): replace prose terminal classifiers. Thanks @fuller-stack-dev.
- **PR #93231** fix(status): correct pinned model clear hint. Thanks @hxy91819.
- **PR #92428** fix(qqbot): keep markdown table chunks valid. Thanks @sliverp.
- **PR #93220** fix(status): avoid stale session context windows. Thanks @hxy91819.
- **PR #91957** perf(sessions): share one enumeration across archive retention sweeps. Thanks @amknight.
- **PR #93281** fix(telegram): recover pid-reused ingress claims. Thanks @obviyus.
- **PR #93287** fix(codex): preserve terminal outcome ordering.
- **PR #93182** fix(memory): clean rollback-journal reindex temp sidecar on NFS stores. Thanks @Alix-007.
- **PR #93283** Persist ClawHub skill install provenance. Related #92077. Thanks @momothemage and @nmccready-tars.
- **PR #88872** fix: attribute spawned task runs to child agent. Related #66670. Thanks @Alix-007 and @Neomail2.
- **PR #92837** fix(android): show live chat context usage. Thanks @Tosko4.
- **PR #93325** fix(cli): harden official plugin recovery. Thanks @vincentkoc.
- **PR #93286** feat(telegram): send rich messages as rich html. Thanks @obviyus.
- **PR #92910** fix(memory-core): safely refresh qmd index during collection repair.
- **PR #93329** fix(cli): allow zero Discord timeout duration. Related #93327. Thanks @rohitjavvadi.
- **PR #91625** fix(cron): add cron edit --clear-model to clear a job's model override. Thanks @ly-wang19.
- **PR #91691** [AI] fix(memory): prevent empty-string expectedModel in resolveMemory…. Thanks @xydt-tanshanshan.
- **PR #93006** fix(tui): keep stderr visible when local shell stdout fills the output cap. Thanks @Alix-007.
- **PR #93001** fix(daemon): prefer stderr over stale stdout in gateway restart diagnostics. Thanks @Alix-007.
- **PR #91117** refactor: remove dead code and improve string concatenation. Thanks @Pommelle.
- **PR #90893** fix(models): mask paste-token input in CLI auth prompt. Thanks @anurag-bg-neu.
- **PR #90571** fix(configure): mask gateway password input in CLI wizard prompt. Thanks @anurag-bg-neu.
- **PR #91768** fix(ios): respect chat header safe area. Thanks @zats.
- **PR #93245** fix(cron): resolve lastRunStatus in cron list/show human output. Thanks @ly-wang19.
- **PR #78765** fix(tui): avoid inserting spaces into long CJK text. Thanks @hpt.
- **PR #91776** fix(ios): refresh permission rows after grants. Thanks @zats.
- **PR #92817** fix(cron): trust agent output when channel is unresolved without explicit delivery. Related #90664. Thanks @fsdwen and @dertbv.
- **PR #93297** fix(control-ui): respect agents.defaults.timeFormat for timestamps. Related #58147. Thanks @ZengWen-DT and @Zeng-wen and @TommoT2.
- **PR #93364** Fix Telegram rich progress command output. Thanks @obviyus.
- **PR #91952** feat(status): surface plugin health. Thanks @jalehman.
- **PR #75025** fix(heartbeat): refresh stale Current time line on every helper call (#44993). Thanks @MoerAI and @mclee1975.
- **PR #90992** docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0 idle-termination. Thanks @spencer2211.
- **PR #86544** fix(cli): show Gemini CLI runtime auth status. Related #79585. Thanks @giodl73-repo and @fabricefoy.
- **PR #88945** fix(plugins): serialize binding approval saves. Related #64065. Thanks @Alix-007 and @lihaokun.
- **PR #90115** fix(gateway): pass managed inbound PDFs through chat.send. Related #90097. Thanks @harjothkhara and @joeykrug.
- **PR #74613** docs(cli): add agent selector to CLI backend quick start. Related #68940. Thanks @vyctorbrzezowski and @drmarcopapa.
- **PR #89121** refactor: add transcript reader seam. Thanks @jalehman.
- **PR #84434** fix(cli): disable ScheduleWakeup/CronCreate in --print claude runs. Thanks @SkyWolfDreamer.
- **PR #66985** fix(agents): resolve requestedNode to canonical ID before boundNode comparison. Related #87213. Thanks @mujiannan.
- **PR #91488** fix(reply): project preflight compaction gate by next-input size on fresh tokens. Thanks @yetval.
- **PR #93353** fix(plugins): require owner for plugin writes. Thanks @eleqtrizit.
- **PR #91499** fix(cron): preserve scheduled turn tool policy [AI]. Thanks @mmaps.
- **PR #90412** fix(sessions): cache warm transcript reads to avoid per-turn re-parse. Related #83943. Thanks @Alix-007 and @yyds-xxxx.
- **PR #93118** fix(gateway): guard fast-path startup migrations. Related #93032. Thanks @openperf and @Haderach-Ram.
- **PR #93355** fix(ci): verify performance workflow downloads. Thanks @eleqtrizit.
- **PR #93358** fix(outbound): guard cross-context message mutations. Thanks @eleqtrizit.
- **PR #93362** fix(flock): bind allow-always to wrapped command. Thanks @eleqtrizit.
- **PR #92578** refactor(whatsapp): add inbound admission foundation. Thanks @mcaxtr.
- **PR #89547** Control Telegram group history context. Thanks @mmaps.
- **PR #89201** refactor: add transcript runtime identity contract. Thanks @jalehman.
- **PR #93357** fix(plugins): enforce install policy in wrappers. Thanks @eleqtrizit.
- **PR #93156** fix(doctor): import default-agent auth profiles into sqlite. Related #93145. Thanks @Pick-cat and @sallyom and @Tazio7.
- **PR #93179** Add slim evidence mode for QA profile evidence. Thanks @RomneyDa.
- **PR #93349** fix(control-ui): keep workboard card titles visible in overflowing columns (fixes #91717). Thanks @Pick-cat and @NicoBoom13.
- **PR #93324** fix(cli): accept --no-color after subcommands. Thanks @ooiuuii.
- **PR #89621** Return Google Chat thread metadata from message sends. Thanks @franco-viotti.
- **PR #82458** fix(infra): drop duplicated "restart" word in restart-sentinel summary. Thanks @jameswniu.
- **PR #85471** Suppress cron announce control replies. Related #85421. Thanks @TurboTheTurtle and @leatherneck-33.
- **PR #85316** fix(auth): keep alias-compatible auth-profile overrides instead of clearing them. Thanks @SkyWolfDreamer.
- **PR #89260** fix(doctor): separate platform-incompatible skills from missing requirements. Related #89232. Thanks @Alix-007 and @CameronWeller.
- **PR #90846** fix(media): stop pruning media on write; let the configured timer do it. Thanks @lundog.
- **PR #88062** fix(logging): avoid stalled warnings for active model calls. Thanks @litang9.
- **PR #93308** fix(discord): reject malformed realtime consult calls. Thanks @khoek.
- **PR #93334** fix(whatsapp): notify user when trailing media send fails instead of silent drop. Thanks @rushindrasinha.
- **PR #92575** fix(sessions): preserve user behavior overrides across daily/idle rollover (#92562) [AI-assisted]. Thanks @harjothkhara and @civiltox.
- **PR #89124** refactor: route auto-reply sessions through session seam. Thanks @jalehman.
- **PR #93431** fix: stabilize transcript cache and CLI env isolation. Thanks @shakkernerd.
- **PR #93412** fix(discord): suppress tool progress for message-tool replies. Thanks @mgunnin and @vincentkoc.
- **PR #93409** fix(whatsapp): stop markdownToWhatsApp dropping code spans followed by a digit. Thanks @rushindrasinha.
- **PR #93295** fix(memory): swap rollback-journal sidecar during atomic reindex. Thanks @Alix-007.
- **PR #93076** fix(whatsapp): preserve auth on terminal disconnects. Thanks @mcaxtr.
- **PR #93435** fix(agents): bound autoreview scope. Thanks @vincentkoc.
- **PR #93279** fix(telegram): restore readable default text sends. Related #93263. Thanks @NianJiuZst and @SweetSophia.
- **PR #93429** fix(line): cap carousel column text at 60 chars when a title or image is set. Thanks @harjothkhara and @vincentkoc.
- **PR #93428** fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419). Thanks @zenglingbiao and @vincentkoc and @danielgerlag.
- **PR #93427** fix(tui): show activity indicator for system-injected runs. Related #51825. Thanks @ZengWen-DT and @vincentkoc and @Zeng-wen and @AlethiaQuizForge.
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt and @vincentkoc and @0pen7ech.
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
- **PR #93443** fix(gateway): block internal HTTP session overrides. Thanks @RichardCao.
- **PR #93454** fix(sqlite): disable WAL on network filesystems. Thanks @vincentkoc.
- **PR #90275** test: make install-safe-path symlink tests compatible with Windows. Thanks @aniruddhaadak80.
- **PR #93464** fix(qa): suppress empty WhatsApp debug artifacts. Thanks @vincentkoc.
- **PR #90861** fix(cli): preserve sessions_yield over MCP. Related #77426. Thanks @zhangguiping-xydt and @jarvisagimuspicard-hub.
- **PR #90946** fix(infra): preserve inherited gateway PID across reparent during cleanup. Thanks @amittell.
- **PR #92220** fix(media): extract large managed inbound PDFs via media-understanding. Related #90096, #90097. Thanks @amknight and @joeykrug.
- **PR #91208** fix #91047: Plugin session-extension registry not pinned; sessions.pluginPatch fails after agent/subagent plugin-load churn. Thanks @mushuiyu886 and @teamadams.
- **PR #92111** fix(update): restart managed gateway when update handoff fails after stop. Related #92088. Thanks @yetval and @ofan.
- **PR #93238** fix(agents): honor disabled envelope timestamps at model boundary. Thanks @osolmaz.
- **PR #93343** fix(codex): de-duplicate commentary notes across the raw response lane. Related #93296. Thanks @Marvinthebored and @Peetiegonzalez.
- **PR #93361** fix(openshell): pin mirror remote mutations. Thanks @eleqtrizit.
- **PR #93354** fix(discord): block cross-provider guild admin actions. Thanks @eleqtrizit.
- **PR #92178** fix(gateway): normalize malformed paired access lists. Related #90654. Thanks @wangmiao0668000666 and @EmilioNicolas.
- **PR #85254** perf(plugins): thread prepared manifestPlugins through runtime model-id normalize chain. Thanks @zeroaltitude.
- **PR #93489** Add ClawHub content rights docs to sidebar. Thanks @Patrick-Erichsen.
- **PR #93466** [AI] fix(feishu): guard against missing inbound in channelRuntime fallback. Thanks @xydt-tanshanshan.
- **PR #93460** fix(cli): honor --log-level in route-first commands. Related #93457. Thanks @ooiuuii.
- **PR #93495** fix(cron): clear delivery routing fields from cron edit. Thanks @ly-wang19 and @vincentkoc.
- **PR #93494** docs: point PR landing at maintainer workflow. Thanks @fuller-stack-dev and @vincentkoc.
- **PR #93487** fix(ui): add agent selector to skills page. Related #78553. Thanks @goutamadwant and @vincentkoc and @xiaobu1112.
- **PR #93488** fix(discord): apply tool status emojis immediately to avoid override by thinking reactions. Related #92715. Thanks @lzyyzznl and @vincentkoc and @darealgege.
- **PR #93055** fix(ui): restore provider usage pill in desktop chat composer [AI]. Thanks @harjothkhara.
- **PR #83156** fix(matrix): accept bracketed display-name mentions. Related #83142. Thanks @wdx-agent-io and @wdongxv.
- **PR #93333** fix(auto-reply): redact secrets in /debug show and /debug set output. Thanks @Alix-007.
- **PR #88496** fix(auto-reply): redact secrets in config show output. Related #65623. Thanks @jason-allen-oneal and @coygeek.
- **PR #93105** fix(doctor): repair null agents.list[].workspace values. Related #77718. Thanks @xydigit-sj and @slideshow-dingo.
- **PR #73923** fix(ui): preserve gateway token during safe websocket url edits. Related #41545. Thanks @wsyjh8.
- **PR #88970** fix #85871: [Bug]: Heartbeat scheduler silently fails to fire on 5.20 and all 5.x versions (regression from 4.23). Thanks @zhangguiping-xydt and @vincentkoc and @carlbjson.
- **PR #93511** fix(imessage): normalize leading NUL echo-cache prefixes. Thanks @vincentkoc and @drvoss.
- **PR #92594** [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works. Related #92391. Thanks @zhangguiping-xydt and @vincentkoc and @kvzsolt.
- **PR #93512** build(docs): finish PowerShell-safe docs formatting. Related #44293. Thanks @vincentkoc and @yil337 and @aniruddhaadak80.
- **PR #93513** fix(skills): refresh persisted snapshots after restart. Thanks @vincentkoc and @fif911 and @skadauke.
- **PR #93517** fix(skills): quote skill-creator template description. Thanks @vincentkoc and @parubets.
- **PR #73976** fix(memory): use per-keyword FTS search in hybrid mode #39484. Thanks @joshuakeithpa-sudo.
- **PR #93520** fix(workspace): store setup state outside workspace dot-dir. Thanks @vincentkoc and @1qh.
- **PR #93521** fix(onboard): skip Homebrew prompt on unsupported platforms. Related #68893. Thanks @vincentkoc and @yurivict.
- **PR #93522** fix(feishu): send post mentions as native at elements. Thanks @vincentkoc and @gavin-ali and @YizukiAme and @Panniantong.
- **PR #93496** fix(gateway): rotate already-stale generated transcript filename on /reset. Thanks @harjothkhara and @vincentkoc.
- **PR #93471** fix(cron): preserve aborted isolated-run failure. Thanks @BhargavSatya and @vincentkoc.
- **PR #93473** fix(memory): report skipped QMD embedding probe. Related #77645. Thanks @TurboTheTurtle and @vincentkoc and @aderius.
- **PR #93498** fix(ui): preserve CJK IME composition. Related #86035. Thanks @Zhaoqj2016 and @vincentkoc.
- **PR #93088** fix(telegram): bind bot mentions to assistant identity. Thanks @kesslerio and @vincentkoc.
- **PR #93499** fix(nodes): return screen snapshots as media. Related #90126. Thanks @zenglingbiao and @vincentkoc and @JeffSteinbok.
- **PR #93506** fix(skills): trust verified ClawHub source provenance. Thanks @vincentkoc.
- **PR #93525** agents: notify chat exec empty-success completions. Thanks @vincentkoc and @wenkang-xie.
- **PR #93446** feat: add Codex hosted web search. Thanks @fuller-stack-dev.
- **PR #92883** fix(security): audit open dm tool exposure. Related #55612. Thanks @yu-xin-c and @vincentkoc and @cjg20ss.
- **PR #93476** fix(mattermost): preserve Codex progress preview. Related #88766. Thanks @goutamadwant and @vincentkoc and @KelTech-Services.
- **PR #93395** feat(cron): add compact list responses. Related #93366. Thanks @yu-xin-c and @vincentkoc and @centralpc.
- **PR #93527** fix(cron): preserve model overrides for text payloads. Thanks @vincentkoc and @liaoandi.
- **PR #90487** fix: harden ChatGPT Responses missing content-type streams. Thanks @anyech and @vincentkoc.
- **PR #93528** fix(gateway): tolerate transient pre-hello clean closes. Thanks @vincentkoc and @ruanrrn.
- **PR #93529** fix(auto-reply): allow message tool for group attachments. Related #43146. Thanks @vincentkoc and @Robcis.
- **PR #93291** fix(reply): preserve pending thread evidence when reconciling partial send results. Thanks @yetval and @vincentkoc.
- **PR #90572** fix(feishu): drop self-authored receive echoes. Thanks @baskduf.
- **PR #93455** fix(cli): accept --log-level after subcommands. Thanks @ooiuuii and @vincentkoc.
- **PR #93452** fix(bedrock): strip inference profile prefix from model ID in embedding adapter. Related #79212. Thanks @LiuwqGit and @vincentkoc and @aleck31.
- **PR #89799** fix(cli): skip compile cache on early Node 24.x to avoid startup deadlock. Related #86550. Thanks @zhangguiping-xydt and @vincentkoc and @renyuliang000.
- **PR #93469** fix(agents): drop partialJson streaming artifacts from session history repair. Thanks @drvoss and @vincentkoc.
- **PR #93463** fix(codex): log app-server compaction completion. Related #83932. Thanks @goutamadwant and @vincentkoc and @aounakram.
- **PR #93562** fix(tui): refresh after external session reset. Related #38966. Thanks @vincentkoc and @wsyjh8 and @yizhanzjz.
- **PR #93470** fix(plugins): load externally-installed channel plugins at gateway startup. Related #93219. Thanks @sunlit-deng and @vincentkoc and @cxdnicole.
- **PR #88796** fix(discord): resolve guildId from session channel for search actions. Related #88790. Thanks @SebTardif and @vincentkoc and @mugabuga.
- **PR #93194** fix(agents): preserve prompt-released session metadata. Related #93193. Thanks @snowzlm.
- **PR #89483** fix(gateway): project failed agent turns in chat history. Related #89197. Thanks @IWhatsskill and @vincentkoc and @yangiit.
- **PR #93434** fix: avoid parent group allowlist false positive. Related #92684. Thanks @kingrubic and @vincentkoc and @motteman.
- **PR #93449** fix(feishu): dedupe redelivered text by stable retry identity. Related #46778. Thanks @ZengWen-DT and @vincentkoc and @kingcuty.
- **PR #93407** AGT-80 AGT-81 Fix Discord ingress ack ordering. Thanks @mgunnin and @vincentkoc.
- **PR #93439** fix(agents): honor embedded run default model. Related #93419. Thanks @harjothkhara and @vincentkoc and @danielgerlag.
- **PR #93565** fix(cli): summarize cleanup dry-run by label. Related #76826. Thanks @AgentArcLab and @vincentkoc and @renatomaluhy.
- **PR #93509** fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin. Thanks @Alix-007 and @vincentkoc.
- **PR #93274** Clarify plugin channel config additional-property errors. Thanks @zhangguiping-xydt and @vincentkoc.
- **PR #93555** fix(read): route text decoding through shared Windows codepage fallba…. Thanks @zhanxingxin1998 and @vincentkoc.
- **PR #93314** fix(skills): preserve ClawHub origin provenance on readback. Thanks @Alix-007 and @vincentkoc.
- **PR #93573** fix(acp): keep bridge sessions out of stale ACP classification [AI-assisted]. Related #38907. Thanks @eldar702 and @vincentkoc and @ninaopenclaw.
- **PR #93398** fix(cron): emit isolated model usage diagnostics. Related #92338. Thanks @849261680 and @vincentkoc and @niks999.
- **PR #93367** Fix SSH sandbox remote directory args. Related #93344. Thanks @dmorn and @vincentkoc.
- **PR #93574** fix(feishu): suppress log noise for bot_p2p_chat_entered_v1 event [AI-assisted]. Related #42351. Thanks @eldar702 and @vincentkoc and @sunking0223.
- **PR #93269** Fix tokenjuice bash results without details. Thanks @moeedahmed and @vincentkoc.
- **PR #93575** fix(telegram): hydrate group reply-chain media into model context [AI-assisted]. Thanks @eldar702 and @vincentkoc.
- **PR #93261** fix(plugins): resolve provider policy surface for plugin-owned CLI backends. Related #93259. Thanks @BitmapAsset and @vincentkoc.
- **PR #93303** fix(whatsapp): bound stalled read-receipt socket operations. Thanks @Alix-007 and @vincentkoc.
- **PR #93242** fix(mattermost): keep bare @mention with empty body instead of dropping it. Related #93205. Thanks @iloveleon19 and @vincentkoc.
- **PR #93606** fix(ui): clear stale Talk error when session transitions to non-error state (fixes #88176). Thanks @liuhao1024 and @vincentkoc and @BrianClaw1955.
- **PR #93607** perf(tasks): memoize reconcileInspectableTasks for same-tick calls (fixes #73531). Thanks @liuhao1024 and @vincentkoc and @slideshow-dingo.
- **PR #93612** fix(gateway): compute sessions.usage aggregate totals from all sessions, not just the limited page (fixes #76496). Thanks @liuhao1024 and @vincentkoc and @bobsahur-robot.
- **PR #93615** fix(telegram): recover lone active spooled handler on timeout (#84158). Thanks @0xghost42 and @vincentkoc and @crash2kx.
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
- **PR #93298** fix #93044: control-ui webchat double-renders agent replies when dmScope=main. Thanks @zhangguiping-xydt and @vincentkoc and @cfmilam.
- **PR #93618** fix(feishu): filter temporary card-action-c-\* IDs from reply target to prevent Invalid open_message_id errors (fixes #56818). Thanks @liuhao1024 and @vincentkoc and @SwordImmortal.
- **PR #93387** feat(ios): add watch action surface. Thanks @Solvely-Colin and @joshavant.
- **PR #93648** fix(doctor): archive superseded plugin install index conflicts. Related #90418. Thanks @vincentkoc and @ramitrkar-hash.
- **PR #93649** fix(qwen): place DashScope image prompts in user content. Related #92688. Thanks @vincentkoc and @Yachiyo404.
- **PR #93650** fix(update): avoid per-Node npm prefixes during self-update. Related #80387. Thanks @vincentkoc and @yaanfpv.
- **PR #93653** fix(skill-workshop): skip helper sessions during auto-capture. Thanks @vincentkoc and @zhangguiping-xydt.
- **PR #93654** fix(codex): expose remote node exec as a Codex dynamic tool. Related #92141. Thanks @vincentkoc and @JPKay-AI.
- **PR #93662** fix(discord): protect mention aliases in code fences. Thanks @vincentkoc and @rohitjavvadi.
- **PR #93663** fix(clawdock): open dashboard on published port without starting deps. Related #77344. Thanks @vincentkoc and @dhoman.
- **PR #93670** fix(browser): recover stale managed Chrome CDP listener. Related #41750. Thanks @vincentkoc and @rohitjavvadi and @kissman911.
- **PR #93672** fix(commands): preserve multiline slash skill args. Related #79155. Thanks @vincentkoc and @web3blind.
- **PR #93674** fix(browser): accept top-level act fields with nested requests. Related #38762. Thanks @vincentkoc and @angelusbr and @Lumos-789.
- **PR #93678** fix(plugins): allow Dreaming sidecar through restrictive memory allowlists. Related #92536. Thanks @vincentkoc and @pradeep7127 and @resYuto.
- **PR #93306** fix(status): ignore stale context after model switch. Thanks @hxy91819.
- **PR #93666** fix(control-ui): copy code blocks over plain HTTP via clipboard fallback. Related #93628. Thanks @Pick-cat and @pjq2926.
- **PR #93629** fix(reply): preserve unsent text-only finals after block pipeline streamed partial content (fixes #81078). Thanks @liuhao1024 and @Jackten.
- **PR #93690** fix(telegram): dispatch MEDIA directives as attachments. Related #77702. Thanks @vincentkoc and @butttersbot.
- **PR #93693** fix(gateway): ignore stale sudo scope for root user services. Related #81410. Thanks @vincentkoc and @Ericksza.
- **PR #93646** fix(agents): return string assistant content in getLastAssistantText. Thanks @Alix-007 and @vincentkoc.
- **PR #93687** fix(i18n): retain Codex error tails in logs. Thanks @hxy91819.
- **PR #93630** fix(heartbeat): bootstrap plugin session targets. Thanks @ZengWen-DT and @vincentkoc.
- **PR #93658** fix(wizard): preserve existing default model during setup auth choice [AI-assisted]. Related #64129. Thanks @ml12580 and @vegapunk9527.
- **PR #93671** fix(respawn): rewrite pnpm versioned entry paths to stable wrapper (fixes #52313). Thanks @liuhao1024 and @vincentkoc and @RichardCao.
- **PR #93698** Fix Telegram rich progress detail updates. Thanks @obviyus.
- **PR #93656** fix(gateway): send approval route notices with write scope. Related #93563. Thanks @mushuiyu886 and @vincentkoc and @clawbot247-commits.
- **PR #93665** fix(gateway): surface codex app-server returned failures. Thanks @litang9 and @vincentkoc.
- **PR #93727** fix(context-engine): avoid turn-maintenance lane livelock. Related #77340. Thanks @vincentkoc and @baghvn and @Veda-openclaw.
- **PR #93681** fix(llm): handle string assistant content on the OpenAI-compatible completion path. Thanks @Alix-007.
- **PR #93722** chore(release): update appcast for 2026.6.8. Thanks @vincentkoc.
- **PR #93677** fix(google-meet): declare realtime provider secret inputs. Related #81891. Thanks @goutamadwant and @vincentkoc and @chachi-max.
- **PR #92947** fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root. Related #92816. Thanks @ZengWen-DT and @Zeng-wen and @lewiswu1209.
- **PR #93679** fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099). Thanks @liuhao1024 and @vincentkoc and @bugkill3r.
- **PR #93688** fix(minimax): check base_resp envelope errors in TTS provider. Related #76904. Thanks @dwc1997 and @najef1979-code.
- **PR #93714** fix: isolate async model resolution mock from sync mock in flaky test. Related #92117. Thanks @lsr911 and @wangwllu.
- **PR #93705** test(macos): cover root command dispatch. Related #83879. Thanks @markoub and @vincentkoc and @davinci282828.
- **PR #93711** Keep command text in progress drafts. Thanks @keshavbotagent and @vincentkoc.
- **PR #93712** fix: scope assistant avatar override to agent ID. Related #90890. Thanks @lsr911 and @vincentkoc and @najef1979-code.
- **PR #93725** fix(usage): prune stale usage cache temp files. Related #78939. Thanks @markoub and @Tramsrepus.
- **PR #93726** fix(typing): start typing on reasoning deltas in thinking mode before visible text. Related #79681. Thanks @xialonglee and @novaflash82.
- **PR #93716** fix(discord): propagate timeout through channel capabilities diagnostics. Related #77040. Thanks @xialonglee and @vincentkoc and @unicebondoc.
- **PR #93729** fix(ollama): preserve configured API during discovery. Related #93710. Thanks @zhangguiping-xydt and @vincentkoc and @obnoxious2011-cmd.
- **PR #93719** fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting. Related #90814. Thanks @lsr911 and @vincentkoc and @k-l-lambda.
- **PR #93732** fix(agents): preserve re-sent user prompt during compaction transcript rotation. Thanks @yetval.
- **PR #93738** fix: break plugin registry type import cycle. Thanks @giodl73-repo.
- **PR #93740** fix(sessions): release retained locks after takeover. Thanks @TurboTheTurtle.
- **PR #93745** fix(usage): reject invalid explicit dates in usage RPC date parsing. Thanks @harjothkhara and @vincentkoc.
- **PR #93746** fix(ui): populate realtime talk provider and transport options from talk.catalog. Thanks @shushushv and @vincentkoc.
- **PR #93751** fix(ios): fix quick setup sheet layout design. Thanks @zats.
- **PR #93749** fix(compaction): ignore stale persisted totalTokens in preflight gate. Thanks @yetval.
- **PR #93753** fix: correct tautological uppercase check in tool description summarizer. Thanks @GautamKumarOffical.
- **PR #89123** refactor: route transcript writers through session seam. Thanks @jalehman.
- **PR #93758** feat(memory): apply outputDimensionality truncation to local GGUF embeddings (fixes #58765). Thanks @liuhao1024 and @vincentkoc and @losz5000.
- **PR #93754** feat(inbound-meta): expose per-turn source modality. Related #50482. Thanks @liuhao1024 and @vincentkoc and @JTOrca.
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1 and @vincentkoc.
- **PR #93772** fix(feishu): recover CJK filenames from JSON file_name field (fixes #81103). Thanks @liuhao1024 and @vincentkoc and @pjuneye.
- **PR #93773** fix(ui): scope Skill Workshop proposals to selected agent. Related #93760. Thanks @TurboTheTurtle and @vincentkoc and @hannesrudolph.
- **PR #88750** feat(context-engine): pass runtime settings into lifecycle. Thanks @ragesaq and @jalehman.
- **PR #93763** fix(agents): use neutral billing copy for subscription auth. Related #80877. Thanks @eldar702 and @vincentkoc and @22kyasue.
- **PR #93818** List all ClawHub docs in sidebar. Thanks @Patrick-Erichsen.
- **PR #93779** fix(webchat): skip textarea resize during IME composition to eliminate typing lag. Related #90800. Thanks @joelnishanth and @vincentkoc and @w10497-create.
- **PR #93786** fix(plugins): treat refreshable catalogs as requiring runtime discovery (fixes #93775). Thanks @liuhao1024 and @St0rmz1.
- **PR #93791** fix(memory): await search-sync before returning results to prevent stale index (fixes #52115). Thanks @liuhao1024 and @vincentkoc and @FicheallADa.
- **PR #93780** fix(google): keep parallel Gemini tool responses in the turn after the model. Thanks @yetval and @vincentkoc.
- **PR #93789** fix(agents): make lane suspension consistent across cooldown-precheck and embedded-runner paths. Related #93036. Thanks @joelnishanth and @vincentkoc and @kumaxs.
- **PR #93798** fix(status): show 0 (not ?) for fresh-session context tokens. Related #93771. Thanks @Alix-007 and @vincentkoc and @anarchia-99.
- **PR #93810** fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass. Thanks @yetval.
- **PR #93811** Strip UTF-8 BOM when reading SKILL.md in quick_validate. Thanks @HrachShah.
- **PR #93803** fix(ui): preserve WebChat visible messages across session switches. Related #80855. Thanks @LiuwqGit and @vincentkoc and @viagarsuker.
- **PR #93792** fix(android): wait for node capability approval before onboarding. Thanks @Solvely-Colin and @vincentkoc.
- **PR #93796** fix(feishu): paginate wiki node and space listing (#37626). Thanks @ZengWen-DT and @vincentkoc and @ritou11.
- **PR #93797** fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable (fixes #63343). Thanks @liuhao1024 and @vincentkoc and @OpenCodeEngineer.
- **PR #93806** fix(reasoning-tags): strip MiniMax mm: tags on silent-reply and streaming paths missed by #93767. Thanks @Alix-007 and @vincentkoc.
- **PR #93691** refactor: add gateway sessions.create lifecycle seam. Thanks @jalehman.
- **PR #88748** fix(gemini): bridge OAuth profiles into CLI runtime. Related #88742. Thanks @jason-allen-oneal.
- **PR #93857** fix(deps): remediate Dependabot alerts. Thanks @vincentkoc.
- **PR #93874** fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview. Thanks @Alix-007.
- **PR #93832** feat(providers): add ClawRouter managed proxy. Thanks @vincentkoc.
- **PR #93880** fix(macos): preserve approvals migration data. Thanks @vincentkoc.
- **PR #93903** fix(cron): reject invalid absolute timestamps. Thanks @Alix-007 and @vincentkoc.
- **PR #93879** fix(update): use configured npm registry for update metadata. Related #79140. Thanks @vincentkoc and @sixerLiu.
- **PR #93924** revert(providers): remove ClawRouter provider. Thanks @vincentkoc.
- **PR #93955** fix(telegram): surface rich-message disabled state. Thanks @obviyus.
- **PR #93881** fix(agents): route BTW through canonical Codex runtime. Related #88902. Thanks @vincentkoc and @TurboTheTurtle and @khalil-omer.
- **PR #90192** fix(feishu): fetch quoted content before empty-message guard. Related #90177. Thanks @bladin and @sliverp and @lkxlaz.
- **PR #93237** Fix Mattermost open DM validation. Thanks @amknight.
- **PR #93945** feat(diagnostics): add SIEM security events. Thanks @vincentkoc.
- **PR #87487** fix(cli): clarify mcp list registry scope. Related #65209. Thanks @Alix-007 and @slideshow-dingo.
- **PR #24661** feat(cohere): add provider plugin. Thanks @vincentkoc.
- **PR #93532** Expose verified ClawHub source in skill verify output. Thanks @momothemage.
- **PR #93538** feat(codex): support app-server network proxy profiles. Thanks @vincentkoc.
- **PR #93938** fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers. Related #93921. Thanks @Nas01010101 and @vincentkoc.
- **PR #94104** feat(agents): trace compaction summarization model calls. Thanks @amknight.
- **PR #94108** Fix package Telegram temp root. Thanks @obviyus.
- **PR #94113** Fix Telegram package output mount. Thanks @obviyus.
- **PR #89062** feat(docker): support offline setup reruns. Related #70443. Thanks @Alix-007 and @safrano9999.
- **PR #93929** fix(secrets): explicitly pass BWS_SERVER_URL to resolver for self-hosted instances. Related #93851. Thanks @Pandah97 and @vincentkoc and @AdoShan.
- **PR #90057** Polish Workboard operations view. Thanks @fuller-stack-dev.
- **PR #89396** fix(doctor): drop inert legacy cron notify when cron.webhook is unset. Related #44460. Thanks @Alix-007.
- **PR #94138** fix(session): prevent stale finalizer from recreating deleted session rows. Related #40840. Thanks @xialonglee and @vincentkoc and @AL-knows.
- **PR #93739** refactor: add session patch projection seam. Thanks @jalehman.
- **PR #94178** fix(workspace): skip optional bootstrap files when workspace setup is already completed. Related #83593. Thanks @dwc1997 and @jsompis.
- **PR #93363** fix(feishu): enforce account tool family gates. Thanks @eleqtrizit.
- **PR #93813** fix(codex): keep message registered for internal turns. Related #93750. Thanks @jalehman and @hannesrudolph.
- **PR #93659** refactor: add session reset delete lifecycle seam. Thanks @jalehman.
- **PR #93852** ci(release): harden release controls. Thanks @vincentkoc.
- **PR #94203** feat(codex): support remote app-server plugins. Thanks @kevinslin.
- **PR #94263** chore: migrate claw-score skill. Thanks @RomneyDa and @kevinslin.
- **PR #93695** refactor: add compact trim lifecycle seam. Thanks @jalehman.
- **PR #93114** test: fold lifecycle and package proof into QA Lab. Thanks @RomneyDa.
- **PR #93181** test: fold otel smoke into qa e2e. Thanks @RomneyDa.
- **PR #93178** test: fold gateway smoke into qa e2e. Thanks @RomneyDa.
- **PR #94276** qa-lab: support script-backed evidence scenarios. Thanks @Solvely-Colin and @RomneyDa.
- **PR #94282** Support owner-qualified ClawHub skill installs. Thanks @Patrick-Erichsen.
- **PR #93704** refactor: add session cleanup lifecycle seam. Thanks @jalehman.
- **PR #94296** fix: require all taxonomy coverage ids for a feature - AND not OR. Thanks @RomneyDa.
- **PR #92016** fix(plugins): compose live hook registry view for tool-call hooks. Related #91918. Thanks @amknight and @vokaplok.
- **PR #89596** fix(policy): recognize declared tool allowlists. Thanks @giodl73-repo.
- **PR #93713** fix: route deleted-agent session purge through lifecycle seam. Thanks @jalehman.
- **PR #84172** fix(exec): rebuild command authorization on the Tree-sitter command planner. Thanks @jesse-merhi.
- **PR #94332** docs: add ClawHub namespace claims to sidebar. Thanks @Patrick-Erichsen.
- **PR #86360** fix(codex): honor bound agent exec host policy. Thanks @jesse-merhi.
- **PR #73162** fix(slack): remove socket reconnect attempt cap so gateway stays connected indefinitely. Related #72808. Thanks @suboss87 and @tleyden.
- **PR #94156** fix: expose OpenAI image quality and moderation CLI options. Thanks @lastguru-net and @fuller-stack-dev.
- **PR #94350** feat: externalize GMI provider plugin. Thanks @Patrick-Erichsen and @vincentkoc.
- **PR #94543** fix(gateway): bound config.get middleware results. Related #94265. Thanks @vincentkoc and @v-s-gusev.
- **PR #91409** fix(update): run plugin convergence after RPC git updates. Thanks @masatohoshino.
- **PR #94556** chore(extensions): bump tokenjuice to 0.8.1. Thanks @vincentkoc.
- **PR #94580** fix(ci): stabilize update run gates.
- **PR #94394** fix(infra): probe 127.0.0.1 in ensurePortAvailable to detect IPv4-only occupants. Related #94379. Thanks @Pandah97 and @wangwllu.
- **PR #94421** fix(agents): preserve active compaction retries. Related #94391. Thanks @dexiosmb.
- **PR #94428** fix(feishu): preserve replies before error finals. Related #94360. Thanks @xunx33.
- **PR #93735** refactor: add restart recovery lifecycle seam. Thanks @jalehman.
- **PR #94591** docs(release): backfill complete contribution records. Thanks @vincentkoc.
- **PR #94588** fix(cron): retry isolated setup timeouts. Thanks @aaroneden.
- **PR #94082** fix(cron): prevent lane timeout during long tool execution. Related #94033. Thanks @ajwan8998 and @JingWang-Star996.
- **PR #94551** feat(firecrawl): add keyless scrape support. Thanks @vincentkoc and @developersdigest.
- **PR #94619** test(ci): stabilize timeout-sensitive shards. Thanks @vincentkoc.
- **PR #94048** fix(telegram): set richMessages default to false explicitly in schema. Related #93770, #93794. Thanks @Monkey-wusky and @obviyus and @Nardoa375 and @laurenceputra.
- **PR #94118** [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.
- **PR #94646** refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
## 2026.6.8
### Highlights
@@ -234,6 +643,7 @@ This audited record covers the complete v2026.6.6..v2026.6.8 history: 192 merged
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
## 2026.6.7
### Highlights
@@ -320,6 +730,7 @@ This audited record covers the complete v2026.6.6..v2026.6.7-beta.1 history: 59
- **PR #92605** fix(docs): pin Windows Hub download links to v2026.6.5. Related #92470. Thanks @lzyyzznl and @arjkul.
- **PR #92593** #92589: fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters. Thanks @zhangqueping and @jovi2014-cyber.
- **PR #92606** Run Vitest and Playwright scenarios from qa suite. Thanks @RomneyDa.
## 2026.6.6
### Highlights
@@ -557,6 +968,7 @@ This audited record covers the complete v2026.6.5..v2026.6.6 history: 198 merged
- **PR #92150** fix(release): gate beta publish on plugin verification. Thanks @vincentkoc.
- **PR #92158** fix(cli): validate gateway RPC timeout inputs. Thanks @ruanrrn and @comeran.
- **PR #91911** fix(agents): retry same model across short rate-limit windows. Thanks @lanzhi-lee.
## 2026.6.5
### Highlights
@@ -741,6 +1153,7 @@ This audited record covers the complete v2026.6.2-beta.1..v2026.6.5 history: 142
- **PR #89659** fix(feishu): retry on send rate-limit errors (230020/230006). Related #70879. Thanks @ladygege and @marshallm-create and @sliverp and @AxelHu.
- **PR #91547** Fix Docker store seed target packages. Related #91035. Thanks @sallyom and @laurenceputra.
- **PR #91423** feat(qqbot): add /bot-group-allways command to toggle mention requirement. Thanks @cxyhhhhh.
## 2026.6.2
### Highlights
@@ -833,6 +1246,7 @@ This audited record covers the complete v2026.6.1..v2026.6.2-beta.1 history: 57
- **PR #89176** fix(browser): honor tab timeout for Chrome MCP. Related #88213. Thanks @MonkeyLeeT and @lamkan0210.
- **PR #90043** fix: restore Skill Workshop current chat toggle. Thanks @shakkernerd.
- **PR #81422** fix(update): surface plugin channel fallbacks. Thanks @BKF-Gitty.
## 2026.6.1
### Highlights
@@ -1047,6 +1461,7 @@ This audited record covers the complete v2026.5.31-beta.4..v2026.6.1 history: 11
- **PR #88288** fix(config): skip state-dir dotenv values that are unresolved shell references. Related #88274. Thanks @Alix-007 and @mathias15010.
- **PR #88305** fix(browser): isolate Chrome MCP pending attach aborts. Related #88304. Thanks @rohitjavvadi.
- **PR #74089** fix(openai/tts): handle [[tts:speed]] directive in OpenAI speech provider (#12163). Thanks @stainlu and @useramuser.
## 2026.5.31
### Highlights
@@ -1177,7 +1592,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
- **PR #88346** refactor: extract web content core package.
- **PR #71280** test(gateway): avoid brittle shutdown timer assertion. Thanks @hansolo949.
- **PR #80686** fix(agents): extend session-write-lock payload-less orphan grace from 5s to 30s. Thanks @wAngByg.
- **PR #88067** fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019). Thanks @BSG2000.
- **PR #88067** fix(responses): drop orphaned assistant msg\_\* id when reasoning is dropped (#88019). Thanks @BSG2000.
- **PR #88417** [codex] Route denied exec approval followups to sessions. Related #88167. Thanks @brokemac79 and @jhartman00.
- **PR #85996** fix #85782: surface terminal TUI lifecycle errors. Thanks @zhangguiping-xydt and @vincentkoc and @shakkernerd.
- **PR #88445** refactor: source model catalog types from core.
@@ -1476,6 +1891,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
- **PR #88978** perf(ui): skip closed slash menu rerenders. Thanks @vincentkoc.
- **PR #88982** fix(test): wait for telegram timer flushes. Thanks @vincentkoc.
- **PR #88989** perf(ui): guard chat transcript rerenders. Thanks @vincentkoc.
## 2026.5.28
### Highlights

View File

@@ -1,2 +1,2 @@
7b0d7f0a21c91718fd05151778bb8ff1f16b622599c4dd0a868d72459ad08559 plugin-sdk-api-baseline.json
65e710ce7c379b49abf1f5d1b4ef7b4cbabf2820be87f7f300f2988f05f63ec5 plugin-sdk-api-baseline.jsonl
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl

View File

@@ -131,7 +131,7 @@ Dreaming is the background memory consolidation system with three cooperative
phases: **light** (sort/stage short-term material), **deep** (promote durable
facts into `MEMORY.md`), and **REM** (reflect and surface themes).
- Enable with `memory.extensions.memory-core.dreaming.enabled: true`.
- Enable with `plugins.entries.memory-core.config.dreaming.enabled: true`.
- Toggle from chat with `/dreaming on|off` (or inspect with `/dreaming status`).
- Dreaming runs on one managed sweep schedule (`dreaming.frequency`) and executes phases in order: light, REM, deep.
- Only the deep phase writes durable memory to `MEMORY.md`.
@@ -167,7 +167,7 @@ Example:
Notes:
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
- `memory status` includes any extra paths configured via `memory.search.extraPaths`.
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
- Tune scheduled sweep cadence with `dreaming.frequency`. Deep promotion policy is otherwise internal except for `dreaming.phases.deep.maxPromotedSnippetTokens`, which bounds promoted snippet length while keeping provenance visible. Use CLI flags on `memory promote` when you need one-off manual threshold overrides.

View File

@@ -398,12 +398,12 @@ allowlist such as `["all"]`.
#### Data Handling
| Policy field | Observed state | Use when |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
| `dataHandling.memory.denySessionTranscriptIndexing` | `agents.*.memory.qmd.sessions.enabled` and `agents.*.memory.search.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
| Policy field | Observed state | Use when |
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
#### Secrets

View File

@@ -19,7 +19,7 @@ Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Plain `openclaw status` stays on the fast read-only path and marks memory as `not checked` instead of unavailable when it skips memory inspection. Heavy security audit, plugin compatibility, and memory-vector probes are left to `openclaw status --all`, `openclaw status --deep`, `openclaw security audit`, and `openclaw memory status --deep`.
- `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave built-in `memory.search.enabled` disabled and still report their own files, chunks, vector, and FTS state.
- `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave built-in `agents.defaults.memorySearch.enabled` disabled and still report their own files, chunks, vector, and FTS state.
- `--usage` prints normalized provider usage windows as `X% left`.
- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. See [Agent runtimes](/concepts/agent-runtimes) for the provider/model/runtime distinction.
- MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the window label from timestamps when needed, and include the model name in the plan label.

View File

@@ -32,7 +32,6 @@ Use `openclaw wiki` when you want a compiled knowledge vault with:
```bash
openclaw wiki status
openclaw wiki --agent research status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
@@ -267,16 +266,15 @@ These require the official `obsidian` CLI on `PATH` when
## Configuration tie-ins
`openclaw wiki` resolves config for the selected `--agent` (or the configured
default agent) from:
`openclaw wiki` behavior is shaped by:
- `memory.extensions.memory-wiki.vaultMode`
- `memory.extensions.memory-wiki.search.backend`
- `memory.extensions.memory-wiki.search.corpus`
- `memory.extensions.memory-wiki.bridge.*`
- `memory.extensions.memory-wiki.obsidian.*`
- `memory.extensions.memory-wiki.render.*`
- `memory.extensions.memory-wiki.context.includeCompiledDigestPrompt`
- `plugins.entries.memory-wiki.config.vaultMode`
- `plugins.entries.memory-wiki.config.search.backend`
- `plugins.entries.memory-wiki.config.search.corpus`
- `plugins.entries.memory-wiki.config.bridge.*`
- `plugins.entries.memory-wiki.config.obsidian.*`
- `plugins.entries.memory-wiki.config.render.*`
- `plugins.entries.memory-wiki.config.context.includeCompiledDigestPrompt`
See [Memory Wiki plugin](/plugins/memory-wiki) for the full config model.

View File

@@ -819,14 +819,14 @@ confirm `config.toolsAllow` names the tools that plugin actually registers.
<AccordionGroup>
<Accordion title="Embedding provider switched or stopped working">
If `memory.search.provider` is unset, OpenClaw uses OpenAI embeddings. Set
`memory.search.provider` explicitly for local, Ollama, Gemini, Voyage,
If `memorySearch.provider` is unset, OpenClaw uses OpenAI embeddings. Set
`memorySearch.provider` explicitly for local, Ollama, Gemini, Voyage,
Mistral, DeepInfra, Bedrock, GitHub Copilot, or OpenAI-compatible
embeddings. If the configured provider cannot run, `memory_search` may
degrade to lexical-only retrieval; runtime failures after a provider is
already selected do not fall back automatically.
Set an optional `memory.search.fallback` only when you want a deliberate
Set an optional `memorySearch.fallback` only when you want a deliberate
single fallback. See [Memory Search](/concepts/memory-search) for the full
list of providers and examples.

View File

@@ -18,10 +18,8 @@ Dreaming is **opt-in** and disabled by default.
Dreaming keeps two kinds of output:
- **Agent-private state and artifacts** under
`memory/.dreams/agents/<agent-id>/` (recall journals, phase output, reports,
and the Dream Diary). Normal memory search does not index this directory.
- **Shared durable memory** in `MEMORY.md`.
- **Machine state** in `memory/.dreams/` (recall store, phase signals, ingestion checkpoints, locks).
- **Human-readable output** in `DREAMS.md` (or existing `dreams.md`) and optional phase report files under `memory/dreaming/<phase>/YYYY-MM-DD.md`.
Long-term promotion still writes only to `MEMORY.md`.
@@ -54,7 +52,7 @@ These phases are internal implementation details, not separate user-configured "
- Requires `minScore`, `minRecallCount`, and `minUniqueQueries` to pass.
- Rehydrates snippets from live daily files before writing, so stale/deleted snippets are skipped.
- Appends promoted entries to `MEMORY.md`.
- Writes a `## Deep Sleep` summary into the agent's `DREAMS.md` and optionally writes an agent-private report.
- Writes a `## Deep Sleep` summary into `DREAMS.md` and optionally writes `memory/dreaming/deep/YYYY-MM-DD.md`.
</Accordion>
<Accordion title="REM phase">
@@ -74,12 +72,7 @@ Dreaming can ingest redacted session transcripts into the dreaming corpus. When
## Dream Diary
Dreaming also keeps a narrative **Dream Diary** in
`memory/.dreams/agents/<agent-id>/DREAMS.md`. After each phase has enough
material, `memory-core` runs a best-effort background subagent turn and appends
a short diary entry. It uses the default runtime model unless `dreaming.model`
is configured. If the configured model is unavailable, Dream Diary retries once
with the session default model.
Dreaming also keeps a narrative **Dream Diary** in `DREAMS.md`. After each phase has enough material, `memory-core` runs a best-effort background subagent turn and appends a short diary entry. It uses the default runtime model unless `dreaming.model` is configured. If the configured model is unavailable, Dream Diary retries once with the session default model.
<Note>
This diary is for human reading in the Dreams UI, not a promotion source. Dreaming-generated diary/report artifacts are excluded from short-term promotion. Only grounded memory snippets are eligible to promote into `MEMORY.md`.
@@ -112,8 +105,7 @@ Deep ranking uses six weighted base signals plus phase reinforcement:
| Consolidation | 0.10 | Multi-day recurrence strength |
| Conceptual richness | 0.06 | Concept-tag density from snippet/path |
Light and REM phase hits add a small recency-decayed boost from agent-scoped
dreaming state.
Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/phase-signals.json`.
Shadow-trial results can be layered on top of that base score as a review
signal before any durable write. A helpful trial gives the candidate a small
@@ -144,18 +136,16 @@ harmful verdicts map to `reject`; none of those recommendations writes to
## Scheduling
When enabled, `memory-core` auto-manages one cron job per enabled agent. Each
sweep runs phases in order: light → REM → deep.
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.
Each cron job runs only that agent's workspace and memory state. Agents that set
`agents.*.memory.extensions.memory-core.dreaming.enabled: false` receive no job.
The sweep includes the primary runtime workspace and any configured agent workspaces, deduped by path, so subagent workspace fan-out does not exclude the main agent's `DREAMS.md` and memory state.
Default cadence behavior:
| Setting | Default |
| -------------------------------------------------- | ------------- |
| `memory.extensions.memory-core.dreaming.frequency` | `0 3 * * *` |
| `memory.extensions.memory-core.dreaming.model` | default model |
| Setting | Default |
| -------------------- | ------------- |
| `dreaming.frequency` | `0 3 * * *` |
| `dreaming.model` | default model |
## Quick start
@@ -163,14 +153,12 @@ Default cadence behavior:
<Tab title="Enable dreaming">
```json
{
"agents": {
"defaults": {
"memory": {
"extensions": {
"memory-core": {
"dreaming": {
"enabled": true
}
"plugins": {
"entries": {
"memory-core": {
"config": {
"dreaming": {
"enabled": true
}
}
}
@@ -182,16 +170,14 @@ Default cadence behavior:
<Tab title="Custom sweep cadence">
```json
{
"agents": {
"defaults": {
"memory": {
"extensions": {
"memory-core": {
"dreaming": {
"enabled": true,
"timezone": "America/Los_Angeles",
"frequency": "0 */6 * * *"
}
"plugins": {
"entries": {
"memory-core": {
"config": {
"dreaming": {
"enabled": true,
"timezone": "America/Los_Angeles",
"frequency": "0 */6 * * *"
}
}
}
@@ -247,7 +233,7 @@ Default cadence behavior:
## Key defaults
All settings live under `memory.extensions.memory-core.dreaming`.
All settings live under `plugins.entries.memory-core.config.dreaming`.
<ParamField path="enabled" type="boolean" default="false">
Enable or disable the dreaming sweep.
@@ -263,7 +249,7 @@ All settings live under `memory.extensions.memory-core.dreaming`.
</ParamField>
<Warning>
`memory.extensions.memory-core.dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`. Trust or allowlist failures stay visible instead of falling back silently; the retry only covers model-unavailable errors.
`dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`. Trust or allowlist failures stay visible instead of falling back silently; the retry only covers model-unavailable errors.
</Warning>
<Note>

View File

@@ -24,7 +24,7 @@ Treat them differently from normal config:
| Surface | Key | Use it when | More |
| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Memory search | `memory.search.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) |
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |

View File

@@ -27,9 +27,11 @@ To set a provider explicitly:
```json5
{
memory: {
search: {
provider: "openai",
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
}
@@ -46,12 +48,14 @@ openclaw plugins install @openclaw/llama-cpp-provider
```json5
{
memory: {
search: {
provider: "local",
fallback: "none",
local: {
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
agents: {
defaults: {
memorySearch: {
provider: "local",
fallback: "none",
local: {
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
},
},
},
},
@@ -73,7 +77,7 @@ openclaw plugins install @openclaw/llama-cpp-provider
| OpenAI-compatible | `openai-compatible` | Generic `/v1/embeddings` endpoint |
| Voyage | `voyage` | |
Set `memory.search.provider` to switch away from OpenAI.
Set `memorySearch.provider` to switch away from OpenAI.
## How indexing works
@@ -91,7 +95,7 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
<Info>
You can also index Markdown files outside the workspace with
`memory.search.extraPaths`. See the
`memorySearch.extraPaths`. See the
[configuration reference](/reference/memory-config#additional-memory-paths).
</Info>
@@ -123,7 +127,7 @@ openclaw memory index --force --agent main
```
Both standalone CLI commands and the Gateway use the same `local` provider id.
Set `memory.search.provider: "local"` when you want local embeddings.
Set `memorySearch.provider: "local"` when you want local embeddings.
**Stale results?** Run `openclaw memory index --force` to rebuild. The watcher
may miss changes in rare edge cases.

View File

@@ -18,9 +18,11 @@ backend, set a provider explicitly:
```json5
{
memory: {
search: {
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
agents: {
defaults: {
memorySearch: {
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
},
},
},
}
@@ -37,8 +39,8 @@ may still require native build approval: `pnpm approve-builds` then
Some OpenAI-compatible embedding endpoints require asymmetric labels such as
`input_type: "query"` for searches and `input_type: "document"` or `"passage"`
for indexed chunks. Configure those with `memory.search.queryInputType` and
`memory.search.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
for indexed chunks. Configure those with `memorySearch.queryInputType` and
`memorySearch.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
## Supported providers
@@ -80,7 +82,7 @@ If only one path is available, the other runs alone. Intentional FTS-only mode
lexical ranking when embeddings are unavailable.
Explicit non-local embedding providers are different. If you set
`memory.search.provider` to a concrete remote-backed provider and that provider
`memorySearch.provider` to a concrete remote-backed provider and that provider
is unavailable at runtime, `memory_search` reports memory as unavailable instead
of silently using FTS-only results. This keeps a broken configured semantic
provider visible. Set `provider: "none"` for deliberate FTS-only recall, or fix
@@ -115,12 +117,14 @@ different daily notes.
```json5
{
memory: {
search: {
query: {
hybrid: {
mmr: { enabled: true },
temporalDecay: { enabled: true },
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
mmr: { enabled: true },
temporalDecay: { enabled: true },
},
},
},
},
@@ -139,7 +143,7 @@ setup.
You can optionally index session transcripts so `memory_search` can recall
earlier conversations. This is opt-in via
`memory.search.experimental.sessionMemory`. See the
`memorySearch.experimental.sessionMemory`. See the
[configuration reference](/reference/memory-config) for details.
## Troubleshooting
@@ -152,7 +156,7 @@ earlier conversations. This is opt-in via
**Local embeddings time out?** `ollama`, `lmstudio`, and `local` use a longer
inline batch timeout by default. If the host is simply slow, set
`memory.search.sync.embeddingBatchTimeoutSeconds` and rerun
`agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds` and rerun
`openclaw memory index --force`.
**CJK text not found?** Rebuild the FTS index with

View File

@@ -145,7 +145,7 @@ an API key for any supported provider.
<Info>
OpenClaw uses OpenAI embeddings by default. Set
`memory.search.provider` explicitly to use Gemini, Voyage,
`agents.defaults.memorySearch.provider` explicitly to use Gemini, Voyage,
Mistral, local, Ollama, Bedrock, GitHub Copilot, or OpenAI-compatible
embeddings.
</Info>
@@ -238,9 +238,9 @@ For phase behavior, scoring signals, and Dream Diary details, see
The dreaming system now has two closely related review lanes:
- **Live dreaming** works from an agent-scoped short-term dreaming store under
`memory/.dreams/agents/<agent-id>/` and is what the normal deep phase uses
when deciding what can graduate into shared `MEMORY.md`.
- **Live dreaming** works from the short-term dreaming store under
`memory/.dreams/` and is what the normal deep phase uses when deciding what
can graduate into `MEMORY.md`.
- **Grounded backfill** reads historical `memory/YYYY-MM-DD.md` notes as
standalone day files and writes structured review output into `DREAMS.md`.

View File

@@ -130,38 +130,36 @@ This lets **multiple people** share one Gateway server while keeping their AI "b
## Cross-agent QMD memory search
If one agent should search another agent's QMD session transcripts, add extra collections under `agents.list[].memory.search.qmd.extraCollections`. Use `memory.search.qmd.extraCollections` only when every agent should inherit the same shared transcript collections.
If one agent should search another agent's QMD session transcripts, add extra collections under `agents.list[].memorySearch.qmd.extraCollections`. Use `agents.defaults.memorySearch.qmd.extraCollections` only when every agent should inherit the same shared transcript collections.
```json5
{
memory: {
backend: "qmd",
qmd: { includeDefaultMemory: false },
search: {
qmd: {
extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }],
},
},
},
agents: {
defaults: {
workspace: "~/workspaces/main",
memorySearch: {
qmd: {
extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }],
},
},
},
list: [
{
id: "main",
workspace: "~/workspaces/main",
memory: {
search: {
qmd: {
extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main"
},
memorySearch: {
qmd: {
extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main"
},
},
},
{ id: "family", workspace: "~/workspaces/family" },
],
},
memory: {
backend: "qmd",
qmd: { includeDefaultMemory: false },
},
}
```

View File

@@ -57,6 +57,8 @@ the resolved scenarios through `qa suite`. `--surface` and
The resulting `qa-evidence.json` includes a profile scorecard summary with
selected-category counts and missing coverage IDs; the individual evidence
entries remain the source of truth for the tests, coverage roles, and results.
Taxonomy feature coverage IDs are exact proof targets, not aliases. Primary
scenario coverage fulfills matching IDs; secondary coverage stays advisory.
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:

View File

@@ -281,6 +281,14 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
prompt: "HEARTBEAT",
ackMaxChars: 300,
},
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001",
remote: {
apiKey: "${GEMINI_API_KEY}",
},
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
sandbox: {
mode: "non-main",
scope: "session", // preferred over legacy perSession: true
@@ -324,17 +332,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
],
},
memory: {
search: {
provider: "gemini",
model: "gemini-embedding-001",
remote: {
apiKey: "${GEMINI_API_KEY}",
},
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
},
tools: {
allow: ["exec", "process", "read", "write", "edit", "apply_patch"],
deny: ["browser", "canvas"],

View File

@@ -23,7 +23,7 @@ for the broader field map, defaults, and links to subsystem references.
Dedicated deep references:
- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `memory.extensions.memory-core.dreaming`
- [Memory configuration reference](/reference/memory-config) for `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`
- [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog
- owning channel/plugin pages for channel-specific command surfaces
@@ -348,17 +348,17 @@ restart after changing native plugin config.
- `plugins.entries.xai.config.xSearch`: xAI X Search (Grok web search) settings.
- `enabled`: enable the X Search provider.
- `model`: Grok model to use for search (e.g. `"grok-4-1-fast"`).
- `memory.extensions.memory-core.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
- `plugins.entries.memory-core.config.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
- `enabled`: master dreaming switch (default `false`).
- `frequency`: cron cadence for each full dreaming sweep (`"0 3 * * *"` by default).
- `model`: optional Dream Diary subagent model override. Requires `plugins.entries.memory-core.subagent.allowModelOverride: true`; pair with `allowedModels` to restrict targets. Model-unavailable errors retry once with the session default model; trust or allowlist failures do not fall back silently.
- phase policy and thresholds are implementation details (not user-facing config keys).
- Full memory config lives in [Memory configuration reference](/reference/memory-config):
- `memory.search.*`
- `agents.defaults.memorySearch.*`
- `memory.backend`
- `memory.citations`
- `memory.qmd.*`
- `memory.extensions.memory-core.dreaming`
- `plugins.entries.memory-core.config.dreaming`
- Enabled Claude bundle plugins can also contribute embedded OpenClaw defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.

View File

@@ -519,7 +519,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- **QMD backend**: probes whether the `qmd` binary is available and startable. If not, prints fix guidance including the npm package and a manual binary path option.
- **Explicit local provider**: checks for a local model file or a recognized remote/downloadable model URL. If missing, suggests switching to a remote provider.
- **Explicit remote provider** (`openai`, `voyage`, etc.): verifies an API key is present in the environment or auth store. Prints actionable fix hints if missing.
- **Legacy auto provider**: treats `memory.search.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
- **Legacy auto provider**: treats `memorySearch.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
When a cached gateway probe result is available (gateway was healthy at the time of the check), doctor cross-references its result with the CLI-visible config and notes any discrepancy. Doctor does not start a fresh embedding ping on the default path; use the deep memory status command when you want a live provider check.

View File

@@ -549,14 +549,14 @@ lives on the [First-run FAQ](/help/faq-first-run).
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
If you don't set a provider explicitly, OpenClaw uses OpenAI embeddings. Legacy
configs that still say `memory.search.provider = "auto"` resolve to OpenAI too.
configs that still say `memorySearch.provider = "auto"` resolve to OpenAI too.
If no OpenAI API key is available, semantic memory search stays unavailable
until you configure a key or choose another provider explicitly.
If you'd rather stay local, set `memory.search.provider = "local"` (and optionally
`memory.search.fallback = "none"`). If you want Gemini embeddings, set
`memory.search.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memory.search.remote.apiKey`). We support **OpenAI, OpenAI-compatible, Gemini,
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memorySearch.remote.apiKey`). We support **OpenAI, OpenAI-compatible, Gemini,
Voyage, Mistral, Bedrock, Ollama, LM Studio, GitHub Copilot, DeepInfra, or local**
embedding models - see [Memory](/concepts/memory) for the setup details.

View File

@@ -2,7 +2,7 @@
summary: "Install the official llama.cpp provider for local GGUF memory embeddings"
read_when:
- You want memory search embeddings from a local GGUF model
- You are configuring memory.search.provider = "local"
- You are configuring memorySearch.provider = "local"
- You need the OpenClaw plugin that owns the node-llama-cpp runtime
title: "llama.cpp Provider"
sidebarTitle: "llama.cpp Provider"
@@ -10,7 +10,7 @@ sidebarTitle: "llama.cpp Provider"
`llama-cpp` is the official external provider plugin for local GGUF embeddings.
It owns the `node-llama-cpp` runtime dependency used by
`memory.search.provider: "local"`.
`memorySearch.provider: "local"`.
Install it before using local memory embeddings:
@@ -28,11 +28,13 @@ Set the memory search provider to `local`:
```json5
{
memory: {
search: {
provider: "local",
local: {
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
agents: {
defaults: {
memorySearch: {
provider: "local",
local: {
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
},
},
},
},

View File

@@ -397,78 +397,54 @@ engines or legacy prompt assembly that explicitly consume memory supplements.
## Configuration
Put config under `memory.extensions.memory-wiki`. Agent entries
can override the same object at `agents.list[].memory.extensions.memory-wiki`.
Enable the plugin once under `plugins.entries`; runtime state remains agent-scoped.
Put config under `plugins.entries.memory-wiki.config`:
```json5
{
plugins: {
entries: {
"memory-wiki": { enabled: true },
},
},
memory: {
extensions: {
"memory-wiki": {
vaultMode: "isolated",
vault: {
renderMode: "obsidian",
},
obsidian: {
enabled: true,
useOfficialCli: true,
vaultName: "OpenClaw Wiki",
openAfterWrites: false,
},
bridge: {
enabled: false,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
ingest: {
autoCompile: true,
maxConcurrentJobs: 1,
allowUrlIngest: true,
},
search: {
backend: "shared",
corpus: "wiki",
},
context: {
includeCompiledDigestPrompt: false,
},
render: {
preserveHumanBlocks: true,
createBacklinks: true,
createDashboards: true,
},
},
},
},
}
```
Agent entries can override the same `memory.extensions.memory-wiki` object:
```json5
{
agents: {
list: [
{
id: "research",
memory: {
extensions: {
"memory-wiki": {
vaultMode: "isolated",
},
enabled: true,
config: {
vaultMode: "isolated",
vault: {
path: "~/.openclaw/wiki/main",
renderMode: "obsidian",
},
obsidian: {
enabled: true,
useOfficialCli: true,
vaultName: "OpenClaw Wiki",
openAfterWrites: false,
},
bridge: {
enabled: false,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
ingest: {
autoCompile: true,
maxConcurrentJobs: 1,
allowUrlIngest: true,
},
search: {
backend: "shared",
corpus: "wiki",
},
context: {
includeCompiledDigestPrompt: false,
},
render: {
preserveHumanBlocks: true,
createBacklinks: true,
createDashboards: true,
},
},
},
],
},
},
}
```
@@ -492,30 +468,30 @@ knowledge layer:
```json5
{
plugins: {
entries: {
"memory-wiki": { enabled: true },
},
},
memory: {
backend: "qmd",
extensions: {
},
plugins: {
entries: {
"memory-wiki": {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
search: {
backend: "shared",
corpus: "all",
},
context: {
includeCompiledDigestPrompt: false,
enabled: true,
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
search: {
backend: "shared",
corpus: "all",
},
context: {
includeCompiledDigestPrompt: false,
},
},
},
},

View File

@@ -291,7 +291,7 @@ Each entry lists the package, distribution route, and description.
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.

View File

@@ -12,7 +12,7 @@ Adds StepFun, StepFun Plan model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/stepfun-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/stepfun-provider`
- Install route: npm
## Surface

View File

@@ -371,14 +371,16 @@ openclaw models list
<Accordion title="Embeddings for memory search">
Bedrock can also serve as the embedding provider for
[memory search](/concepts/memory-search). This is configured separately from the
inference provider -- set `memory.search.provider` to `"bedrock"`:
inference provider -- set `agents.defaults.memorySearch.provider` to `"bedrock"`:
```json5
{
memory: {
search: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0", // default
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0", // default
},
},
},
}
@@ -386,7 +388,7 @@ openclaw models list
Bedrock embeddings use the same AWS SDK credential chain as inference (instance
roles, SSO, access keys, shared config, and web identity). No API key is
needed. Set `memory.search.provider: "bedrock"` explicitly to use Bedrock
needed. Set `memorySearch.provider: "bedrock"` explicitly to use Bedrock
embeddings.
Supported embedding models include Amazon Titan Embed (v1, v2), Amazon Nova

View File

@@ -65,7 +65,7 @@ static defaults below.
| Speech-to-text | `openai/whisper-large-v3-turbo` | inbound audio transcription |
| Text-to-speech | `hexgrad/Kokoro-82M` | `messages.tts.provider: "deepinfra"` |
| Video generation | first `video-gen`-tagged entry from live catalog (static fallback `Pixverse/Pixverse-T2V`) | `video_generate`, `agents.defaults.videoGenerationModel` |
| Memory embeddings | `BAAI/bge-m3` | `memory.search.provider: "deepinfra"` |
| Memory embeddings | `BAAI/bge-m3` | `agents.defaults.memorySearch.provider: "deepinfra"` |
DeepInfra also exposes reranking, classification, object-detection, and other
native model types. OpenClaw does not currently have first-class provider

View File

@@ -216,17 +216,19 @@ have logged in, OpenClaw can use it for embeddings without a separate API key.
### Config
Set `memory.search.provider` explicitly to use GitHub Copilot embeddings. If a
Set `memorySearch.provider` explicitly to use GitHub Copilot embeddings. If a
GitHub token is available, OpenClaw discovers available embedding models from
the Copilot API and picks the best one automatically.
```json5
{
memory: {
search: {
provider: "github-copilot",
// Optional: override the auto-discovered model
model: "text-embedding-3-small",
agents: {
defaults: {
memorySearch: {
provider: "github-copilot",
// Optional: override the auto-discovered model
model: "text-embedding-3-small",
},
},
},
}

View File

@@ -208,9 +208,7 @@ matching `sampleRate` only if your upstream stream is already raw PCM.
```json5
{
memory: {
search: { provider: "mistral" },
},
memorySearch: { provider: "mistral" },
}
```

View File

@@ -31,7 +31,7 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept
Remote public hosts and Ollama Cloud (`https://ollama.com`) require a real credential through `OLLAMA_API_KEY`, an auth profile, or the provider's `apiKey`. For direct hosted use, prefer provider `ollama-cloud`.
</Accordion>
<Accordion title="Custom provider ids">
Custom provider ids that set `api: "ollama"` follow the same rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential. Memory search can also set `memory.search.provider` to that custom provider id so embeddings use the matching Ollama endpoint.
Custom provider ids that set `api: "ollama"` follow the same rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential. Memory search can also set `agents.defaults.memorySearch.provider` to that custom provider id so embeddings use the matching Ollama endpoint.
</Accordion>
<Accordion title="Auth profiles">
`auth-profiles.json` stores the credential for a provider id. Put endpoint settings (`baseUrl`, `api`, model ids, headers, timeouts) in `models.providers.<id>`. Older flat auth-profile files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` are not a runtime format; run `openclaw doctor --fix` to rewrite them to the canonical `ollama-windows:default` API-key profile with a backup. `baseUrl` in that file is compatibility noise and should be moved to provider config.
@@ -40,7 +40,7 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept
When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared:
- A provider-level key is sent only to that provider's Ollama host.
- `agents.*.memory.search.remote.apiKey` is sent only to its remote embedding host.
- `agents.*.memorySearch.remote.apiKey` is sent only to its remote embedding host.
- A pure `OLLAMA_API_KEY` env value is treated as the Ollama Cloud convention, not sent to local or self-hosted hosts by default.
</Accordion>
@@ -972,12 +972,14 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
```json5
{
memory: {
search: {
provider: "ollama",
remote: {
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
nonBatchConcurrency: 1,
agents: {
defaults: {
memorySearch: {
provider: "ollama",
remote: {
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
nonBatchConcurrency: 1,
},
},
},
},
@@ -988,14 +990,16 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
```json5
{
memory: {
search: {
provider: "ollama",
model: "nomic-embed-text",
remote: {
baseUrl: "http://gpu-box.local:11434",
apiKey: "ollama-local",
nonBatchConcurrency: 2,
agents: {
defaults: {
memorySearch: {
provider: "ollama",
model: "nomic-embed-text",
remote: {
baseUrl: "http://gpu-box.local:11434",
apiKey: "ollama-local",
nonBatchConcurrency: 2,
},
},
},
},

View File

@@ -123,17 +123,19 @@ OpenClaw can use OpenAI, or an OpenAI-compatible embedding endpoint, for
```json5
{
memory: {
search: {
provider: "openai",
model: "text-embedding-3-small",
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
},
},
},
}
```
For OpenAI-compatible endpoints that require asymmetric embedding labels, set
`queryInputType` and `documentInputType` under `memory.search`. OpenClaw forwards
`queryInputType` and `documentInputType` under `memorySearch`. OpenClaw forwards
those as provider-specific `input_type` request fields: query embeddings use
`queryInputType`; indexed memory chunks and batch indexing use
`documentInputType`. See the [Memory configuration reference](/reference/memory-config#provider-specific-config) for the full example.

View File

@@ -89,7 +89,7 @@ This migration has one canonical runtime shape:
indexing helpers live on `memory-core-host-engine-session-transcripts`; any
QMD re-export is compatibility only and must not be used by runtime code.
- Built-in memory indexes live in the owning agent database. Runtime config and
resolved runtime contracts must not expose `memory.search.store.path`; doctor
resolved runtime contracts must not expose `memorySearch.store.path`; doctor
deletes that legacy config key and current code passes the agent
`databasePath` internally.
@@ -1557,7 +1557,7 @@ Move these into the global database:
`plugin-state/state.sqlite` sidecar importer is deleted.
- Builtin memory search no longer defaults to `memory/<agentId>.sqlite`; its
index tables live in the owning agent database, and the explicit
`memory.search.store.path` sidecar opt-in has been retired to doctor config
`memorySearch.store.path` sidecar opt-in has been retired to doctor config
migration.
- Builtin memory reindex resets only memory-owned tables in the agent database.
It must not replace the whole SQLite file, because the same database owns
@@ -1890,7 +1890,7 @@ verified extracted payload.
- Move Task Flow tables into the global database. Done for runtime writes;
the unshipped legacy sidecar importer is deleted.
- Move builtin memory-search tables into each agent database. Done; explicit
custom `memory.search.store.path` is now removed by doctor config migration.
custom `memorySearch.store.path` is now removed by doctor config migration.
Full reindex runs in place against memory tables only; the old whole-file
swap path and sidecar index swap helper are deleted.
- Delete duplicate database openers, WAL setup, permission helpers, and

View File

@@ -66,7 +66,7 @@ OpenClaw can pick up credentials from:
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
- **Config** (`models.providers.*.apiKey`, `plugins.entries.*.config.webSearch.apiKey`,
`plugins.entries.firecrawl.config.webFetch.apiKey`, `memory.search.*`,
`plugins.entries.firecrawl.config.webFetch.apiKey`, `memorySearch.*`,
`talk.providers.*.apiKey`).
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
@@ -113,16 +113,16 @@ and [Models](/concepts/models).
Semantic memory search uses **embedding APIs** when configured for remote providers:
- `memory.search.provider = "openai"` → OpenAI embeddings
- `memory.search.provider = "gemini"` → Gemini embeddings
- `memory.search.provider = "voyage"` → Voyage embeddings
- `memory.search.provider = "mistral"` → Mistral embeddings
- `memory.search.provider = "deepinfra"` → DeepInfra embeddings
- `memory.search.provider = "lmstudio"` → LM Studio embeddings (local/self-hosted)
- `memory.search.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
- `memorySearch.provider = "openai"` → OpenAI embeddings
- `memorySearch.provider = "gemini"` → Gemini embeddings
- `memorySearch.provider = "voyage"` → Voyage embeddings
- `memorySearch.provider = "mistral"` → Mistral embeddings
- `memorySearch.provider = "deepinfra"` → DeepInfra embeddings
- `memorySearch.provider = "lmstudio"` → LM Studio embeddings (local/self-hosted)
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
- Optional fallback to a remote provider if local embeddings fail
You can keep it local with `memory.search.provider = "local"` (no API usage).
You can keep it local with `memorySearch.provider = "local"` (no API usage).
See [Memory](/concepts/memory).

View File

@@ -29,45 +29,10 @@ This page lists every configuration knob for OpenClaw memory search. For concept
</Card>
</CardGroup>
Memory settings live under `memory` in `openclaw.json`. It is the global
baseline. Agent entries can override the same shape at `agents.list[].memory`.
When OpenClaw resolves memory for an agent, it deep-merges the global baseline
with that agent's override. Scalar values and ordinary arrays in the agent
override replace the global value. `memory.qmd.paths`,
`memory.search.extraPaths`, and `memory.search.qmd.extraCollections` append and
deduplicate so an agent can add sources without repeating the global list.
Global configuration is not shared memory state. Each agent still owns its own
SQLite database, workspace roots, QMD home, dreaming artifacts, and wiki state
unless you explicitly configure a shared path.
```json5
{
memory: {
backend: "qmd",
search: {
provider: "openai",
extraPaths: ["~/team-notes"],
},
},
agents: {
list: [
{
id: "research",
memory: {
search: {
extraPaths: ["~/research-notes"],
},
},
},
],
},
}
```
All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` unless noted otherwise.
<Note>
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memory.search`.
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memorySearch`.
Active memory uses a two-gate model:
@@ -106,8 +71,7 @@ When `provider` is unset, legacy `provider: "auto"` is present, or
`provider: "none"` intentionally selects FTS-only mode, memory recall can still
use lexical FTS ranking when embeddings are unavailable.
Explicit non-local providers fail closed. If you set
`memory.search.provider` to
Explicit non-local providers fail closed. If you set `memorySearch.provider` to
a concrete remote-backed provider such as OpenAI, Gemini, Voyage, Mistral,
Bedrock, GitHub Copilot, DeepInfra, Ollama, LM Studio, or an OpenAI-compatible
custom provider, and that provider is unavailable at runtime, `memory_search`
@@ -117,13 +81,7 @@ provider/auth configuration, switch to a reachable provider, or set
### Custom provider ids
`memory.search.provider` can point at a custom
`models.providers.<id>` entry for memory-specific provider adapters such as
`ollama`, or for OpenAI-compatible model APIs such as `openai-responses` /
`openai-completions`. OpenClaw resolves that provider's `api` owner for the
embedding adapter while preserving the custom provider id for endpoint, auth,
and model-prefix handling. This lets multi-GPU or multi-host setups dedicate
memory embeddings to a specific local endpoint:
`memorySearch.provider` can point at a custom `models.providers.<id>` entry for memory-specific provider adapters such as `ollama`, or for OpenAI-compatible model APIs such as `openai-responses` / `openai-completions`. OpenClaw resolves that provider's `api` owner for the embedding adapter while preserving the custom provider id for endpoint, auth, and model-prefix handling. This lets multi-GPU or multi-host setups dedicate memory embeddings to a specific local endpoint:
```json5
{
@@ -137,10 +95,12 @@ memory embeddings to a specific local endpoint:
},
},
},
memory: {
search: {
provider: "ollama-5080",
model: "qwen3-embedding:0.6b",
agents: {
defaults: {
memorySearch: {
provider: "ollama-5080",
model: "qwen3-embedding:0.6b",
},
},
},
}
@@ -184,13 +144,15 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
memory: {
search: {
provider: "openai-compatible",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_KEY",
agents: {
defaults: {
memorySearch: {
provider: "openai-compatible",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_KEY",
},
},
},
},
@@ -225,16 +187,18 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
memory: {
search: {
provider: "openai-compatible",
remote: {
baseUrl: "https://embeddings.example/v1",
apiKey: "${EMBEDDINGS_API_KEY}",
agents: {
defaults: {
memorySearch: {
provider: "openai-compatible",
remote: {
baseUrl: "https://embeddings.example/v1",
apiKey: "${EMBEDDINGS_API_KEY}",
},
model: "asymmetric-embedder",
queryInputType: "query",
documentInputType: "passage",
},
model: "asymmetric-embedder",
queryInputType: "query",
documentInputType: "passage",
},
},
}
@@ -250,10 +214,12 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
memory: {
search: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
},
},
},
}
@@ -342,7 +308,7 @@ Unset uses the provider default: 600 seconds for local/self-hosted providers suc
## Hybrid search config
All under `memory.search.query.hybrid`:
All under `memorySearch.query.hybrid`:
| Key | Type | Default | Description |
| --------------------- | --------- | ------- | ---------------------------------- |
@@ -373,14 +339,16 @@ All under `memory.search.query.hybrid`:
```json5
{
memory: {
search: {
query: {
hybrid: {
vectorWeight: 0.7,
textWeight: 0.3,
mmr: { enabled: true, lambda: 0.7 },
temporalDecay: { enabled: true, halfLifeDays: 30 },
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
vectorWeight: 0.7,
textWeight: 0.3,
mmr: { enabled: true, lambda: 0.7 },
temporalDecay: { enabled: true, halfLifeDays: 30 },
},
},
},
},
@@ -398,9 +366,11 @@ All under `memory.search.query.hybrid`:
```json5
{
memory: {
search: {
extraPaths: ["../team-docs", "/srv/shared-notes"],
agents: {
defaults: {
memorySearch: {
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
},
},
}
@@ -408,14 +378,7 @@ All under `memory.search.query.hybrid`:
Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine ignores symlinks, while QMD follows the underlying QMD scanner behavior.
For agent-scoped cross-agent transcript search, use
`agents.list[].memory.search.qmd.extraCollections` instead of
`memory.qmd.paths`. Those extra collections follow the same
`{ path, name, pattern? }` shape, but they are merged per agent and can preserve
explicit shared names when the path points outside the current workspace. If the
same resolved path appears in both `memory.qmd.paths` and
`memory.search.qmd.extraCollections`, QMD keeps the first entry
and skips the duplicate.
For agent-scoped cross-agent transcript search, use `agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate.
---
@@ -614,10 +577,9 @@ When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for e
## Dreaming
Dreaming is configured under `memory.extensions.memory-core.dreaming`, not under `memory.search`.
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`.
Each enabled agent gets its own scheduled dreaming sweep. The sweep uses
internal light/deep/REM phases as an implementation detail.
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail.
For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
@@ -630,50 +592,10 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
| `model` | `string` | default model | Optional Dream Diary subagent model override |
| `phases.deep.maxPromotedSnippetTokens` | `number` | `160` | Maximum estimated tokens kept from each short-term recall snippet promoted into `MEMORY.md`; provenance metadata remains visible |
### Per-agent dreaming control
Dreaming is resolved per agent. An agent can opt out with
`agents.list[].memory.extensions.memory-core.dreaming.enabled = false`:
```json5
{
memory: {
extensions: {
"memory-core": {
dreaming: {
enabled: true,
},
},
},
},
agents: {
list: [
{ id: "main", memory: { extensions: { "memory-core": { dreaming: { enabled: false } } } } },
{ id: "oracle", memory: { extensions: { "memory-core": { dreaming: { enabled: false } } } } },
{ id: "librarian" },
],
},
}
```
In this example, `main` and `oracle` will not get cron jobs, while `librarian`
inherits the global enabled setting.
### Example
```json5
{
memory: {
extensions: {
"memory-core": {
dreaming: {
enabled: true,
frequency: "0 3 * * *",
model: "anthropic/claude-sonnet-4-6",
},
},
},
},
plugins: {
entries: {
"memory-core": {
@@ -681,6 +603,13 @@ inherits the global enabled setting.
allowModelOverride: true,
allowedModels: ["anthropic/claude-sonnet-4-6"],
},
config: {
dreaming: {
enabled: true,
frequency: "0 3 * * *",
model: "anthropic/claude-sonnet-4-6",
},
},
},
},
},
@@ -688,11 +617,8 @@ inherits the global enabled setting.
```
<Note>
- Dreaming writes agent-private state and artifacts to
`memory/.dreams/agents/<agent-id>/`; normal memory search does not index
this directory.
- Dreaming writes each agent's human-readable narrative output to
`memory/.dreams/agents/<agent-id>/DREAMS.md`.
- Dreaming writes machine state to `memory/.dreams/`.
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
- `dreaming.model` uses the existing plugin subagent trust gate; set `plugins.entries.memory-core.subagent.allowModelOverride: true` before enabling it.
- Dream Diary retries once with the session default model when the configured model is unavailable. Trust or allowlist failures are logged and are not silently retried.
- The light/deep/REM phase policy and thresholds are internal behavior, not user-facing config.

View File

@@ -34,9 +34,9 @@ Scope intent:
- `models.providers.*.request.tls.key`
- `models.providers.*.request.tls.passphrase`
- `skills.entries.*.apiKey`
- `memory.search.remote.apiKey`
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].tts.providers.*.apiKey`
- `agents.list[].memory.search.remote.apiKey`
- `agents.list[].memorySearch.remote.apiKey`
- `talk.providers.*.apiKey`
- `talk.realtime.providers.*.apiKey`
- `messages.tts.providers.*.apiKey`

View File

@@ -16,16 +16,16 @@
],
"entries": [
{
"id": "memory.search.remote.apiKey",
"id": "agents.defaults.memorySearch.remote.apiKey",
"configFile": "openclaw.json",
"path": "memory.search.remote.apiKey",
"path": "agents.defaults.memorySearch.remote.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].memory.search.remote.apiKey",
"id": "agents.list[].memorySearch.remote.apiKey",
"configFile": "openclaw.json",
"path": "agents.list[].memory.search.remote.apiKey",
"path": "agents.list[].memorySearch.remote.apiKey",
"secretShape": "secret_input",
"optIn": true
},

View File

@@ -1398,12 +1398,6 @@ describe("active-memory plugin", () => {
it("lets active memory inherit the main QMD search mode when configured", async () => {
api.config = {
memory: {
backend: "qmd",
qmd: {
searchMode: "query",
},
},
agents: {
defaults: {
model: {
@@ -1411,6 +1405,12 @@ describe("active-memory plugin", () => {
},
},
},
memory: {
backend: "qmd",
qmd: {
searchMode: "query",
},
},
};
api.pluginConfig = {
agents: ["main"],
@@ -1434,8 +1434,7 @@ describe("active-memory plugin", () => {
);
const config = embeddedRunConfig();
const agents = requireRecord(config.agents, "expected agents config");
expect(requireRecord(agents.defaults, "expected agent defaults").memory).toEqual({
expect(config.memory).toEqual({
backend: "qmd",
qmd: {
searchMode: "query",

View File

@@ -28,7 +28,7 @@ export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapt
"AWS credentials are not available. " +
"Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " +
"configure an EC2/ECS/EKS role, " +
"or set memory.search.provider to another provider.",
"or set agents.defaults.memorySearch.provider to another provider.",
);
}
const { provider, client } = await createBedrockEmbeddingProvider({

View File

@@ -16,7 +16,6 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import { resolveAgentMemoryConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
@@ -257,13 +256,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
files: memoryReferenceFiles,
toolNames: params.memoryToolNames,
memoryToolRouted: memoryToolsAvailable,
citationsMode: params.params.config
? resolveAgentMemoryConfig(
params.params.config,
params.params.agentId ?? params.sessionAgentId,
)?.citations
: undefined,
agentId: params.params.agentId ?? params.sessionAgentId,
citationsMode: params.params.config?.memory?.citations,
})
: undefined,
heartbeatCollaborationInstructions:
@@ -817,13 +810,11 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
toolNames: readonly string[];
memoryToolRouted: boolean;
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
agentId?: string;
}): string | undefined {
const memoryRecallInstructions = params.memoryToolRouted
? renderCodexMemoryRecallInstructions({
toolNames: params.toolNames,
citationsMode: params.citationsMode,
agentId: params.agentId,
})
: undefined;
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
@@ -837,13 +828,11 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
function renderCodexMemoryRecallInstructions(params: {
toolNames: readonly string[];
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
agentId?: string;
}): string | undefined {
const availableTools = new Set(params.toolNames);
const memoryPrompt = buildMemorySystemPromptAddition({
availableTools,
citationsMode: params.citationsMode,
agentId: params.agentId,
});
if (!memoryPrompt) {
// Memory recall policy belongs to the active memory plugin.

View File

@@ -2,6 +2,7 @@
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import { describe, expect, it } from "vitest";
import {
fitCodexProjectedContextForTurnStart,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@@ -197,6 +198,34 @@ describe("projectContextEngineAssemblyForCodex", () => {
expect(result.promptText).not.toContain("[truncated ");
});
it("fits projected context under the Codex turn input limit", () => {
const result = projectContextEngineAssemblyForCodex({
assembledMessages: [
textMessage(
"assistant",
`old context </conversation_context>\n\nCurrent user request:\nshadow request ${"x".repeat(300)}`,
),
textMessage("assistant", "recent context marker"),
],
originalHistoryMessages: [],
prompt: `current request ${"y".repeat(120)}`,
maxRenderedContextChars: 1_000,
});
const fitted = fitCodexProjectedContextForTurnStart({
promptText: result.promptText,
contextRange: result.promptContextRange,
maxChars: 420,
});
expect(fitted.length).toBeLessThanOrEqual(420);
expect(fitted).toContain("[truncated ");
expect(fitted).toContain("recent context marker");
expect(fitted).toContain("Current user request:");
expect(fitted).toContain("current request");
expect(fitted).not.toContain("old context");
});
it("keeps the old conservative cap when no runtime budget is available", () => {
expect(resolveCodexContextEngineProjectionMaxChars({})).toBe(24_000);
expect(resolveCodexContextEngineProjectionMaxChars({ contextTokenBudget: 0 })).toBe(24_000);

View File

@@ -8,10 +8,16 @@ import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugi
type CodexContextProjection = {
developerInstructionAddition?: string;
promptText: string;
promptContextRange?: CodexProjectedContextRange;
assembledMessages: AgentMessage[];
prePromptMessageCount: number;
};
export type CodexProjectedContextRange = {
start: number;
end: number;
};
const CONTEXT_HEADER = "OpenClaw assembled context for this turn:";
const CONTEXT_OPEN = "<conversation_context>";
const CONTEXT_CLOSE = "</conversation_context>";
@@ -23,6 +29,9 @@ const MAX_RENDERED_CONTEXT_CHARS = 1_000_000;
const DEFAULT_TEXT_PART_CHARS = 6_000;
const MAX_TEXT_PART_CHARS = 128_000;
const APPROX_RENDERED_CHARS_PER_TOKEN = 4;
// Codex app-server validates the summed v2 turn/start text input against
// codex-rs/protocol/src/user_input.rs::MAX_USER_INPUT_TEXT_CHARS.
export const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
/** Default token reserve kept out of rendered context-engine prompt text. */
export const DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS = 20_000;
const MIN_PROMPT_BUDGET_RATIO = 0.5;
@@ -44,25 +53,25 @@ export function projectContextEngineAssemblyForCodex(params: {
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
toolPayloadMode: params.toolPayloadMode ?? "elide",
});
const promptText = renderedContext
? [
CONTEXT_HEADER,
CONTEXT_SAFETY_NOTE,
"",
CONTEXT_OPEN,
truncateOlderContext(renderedContext, maxRenderedContextChars),
CONTEXT_CLOSE,
"",
REQUEST_HEADER,
prompt,
].join("\n")
: prompt;
const boundedContext = renderedContext
? truncateOlderContext(renderedContext, maxRenderedContextChars)
: undefined;
const promptPrefix = boundedContext
? [CONTEXT_HEADER, CONTEXT_SAFETY_NOTE, "", CONTEXT_OPEN].join("\n") + "\n"
: undefined;
const promptSuffix = boundedContext ? `\n${CONTEXT_CLOSE}\n\n${REQUEST_HEADER}\n${prompt}` : "";
const promptText = boundedContext ? `${promptPrefix}${boundedContext}${promptSuffix}` : prompt;
const promptContextRange =
promptPrefix && boundedContext
? { start: promptPrefix.length, end: promptPrefix.length + boundedContext.length }
: undefined;
return {
...(params.systemPromptAddition?.trim()
? { developerInstructionAddition: params.systemPromptAddition.trim() }
: {}),
promptText,
...(promptContextRange ? { promptContextRange } : {}),
assembledMessages: params.assembledMessages,
prePromptMessageCount: params.originalHistoryMessages.length,
};
@@ -108,6 +117,50 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
return undefined;
}
/** Fits projected context prompts under Codex app-server turn/start text limits. */
export function fitCodexProjectedContextForTurnStart(params: {
promptText: string;
contextRange?: CodexProjectedContextRange;
maxChars?: number;
}): string {
const maxChars =
typeof params.maxChars === "number" && Number.isFinite(params.maxChars)
? Math.max(0, Math.floor(params.maxChars))
: CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
if (params.promptText.length <= maxChars) {
return params.promptText;
}
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
if (!range) {
return params.promptText;
}
const beforeContext = params.promptText.slice(0, range.start);
const context = params.promptText.slice(range.start, range.end);
const afterContext = params.promptText.slice(range.end);
const contextBudget = maxChars - beforeContext.length - afterContext.length;
const fittedContext = truncateOlderContext(context, contextBudget);
return `${beforeContext}${fittedContext}${afterContext}`;
}
function normalizeProjectedContextRange(
range: CodexProjectedContextRange | undefined,
textLength: number,
): CodexProjectedContextRange | undefined {
if (!range) {
return undefined;
}
const start = Math.floor(range.start);
const end = Math.floor(range.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
return undefined;
}
if (end > textLength) {
return undefined;
}
return { start, end };
}
function resolveProjectionPromptBudgetTokens(params: {
contextTokenBudget: number;
reserveTokens?: number;

View File

@@ -314,6 +314,102 @@ describe("CodexNativeSubagentMonitor", () => {
);
});
it("delivers child agent-message completion when a native subagent becomes idle", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome: "/tmp/codex-home",
});
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify({
method: "item/completed",
params: {
threadId: "child-thread",
item: {
type: "agentMessage",
id: "msg-child-final",
phase: "final_answer",
text: "child final result",
},
},
});
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
await client.notify({
method: "thread/status/changed",
params: {
threadId: "child-thread",
status: { type: "idle" },
},
});
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
status: "succeeded",
terminalSummary: "child final result",
}),
);
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
status: "succeeded",
statusLabel: "agent_message",
result: "child final result",
}),
);
client.close();
});
it("does not deliver commentary-only child messages as native subagent completion", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome: "/tmp/codex-home",
});
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify({
method: "item/completed",
params: {
threadId: "child-thread",
item: {
type: "agentMessage",
id: "msg-child-commentary",
phase: "commentary",
text: "checking now",
},
},
});
await client.notify({
method: "thread/status/changed",
params: {
threadId: "child-thread",
status: { type: "idle" },
},
});
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
client.close();
});
it("keeps late idle lifecycle updates from overwriting native completion results", async () => {
const client = createClient();
const runtime = createRuntime();

View File

@@ -55,6 +55,10 @@ type ChildState = {
transcriptPollAttempt: number;
transcriptPollTimer?: ReturnType<typeof setTimeout>;
transcriptTerminal: boolean;
idle: boolean;
lastAgentMessage?: string;
lastAgentMessageAt?: number;
agentMessageCompletionDelivered: boolean;
pendingCompletion?: CodexNativeSubagentCompletion;
pendingCompletionEventAt?: number;
completionDeliveryAttempt: number;
@@ -211,7 +215,10 @@ export class CodexNativeSubagentMonitor {
});
}
}
const childThreadId = this.recordChildAgentMessage(notification);
const idleChildThreadId = this.recordChildIdle(notification);
await this.handleCompletionNotification(notification);
await this.processChildAgentMessageCompletion(childThreadId ?? idleChildThreadId);
}
private ensureParentTaskRuntime(state: ParentState): void {
@@ -552,6 +559,8 @@ export class CodexNativeSubagentMonitor {
parentThreadId: normalizedParentThreadId,
transcriptPollAttempt: 0,
transcriptTerminal: false,
idle: false,
agentMessageCompletionDelivered: false,
completionDeliveryAttempt: 0,
};
this.childStates.set(normalizedChildThreadId, childState);
@@ -561,6 +570,83 @@ export class CodexNativeSubagentMonitor {
}
}
private recordChildAgentMessage(notification: CodexServerNotification): string | undefined {
if (notification.method !== "item/completed") {
return undefined;
}
const params = isJsonObject(notification.params) ? notification.params : undefined;
const item = isJsonObject(params?.item) ? params.item : undefined;
if (!params || !item || readString(item, "type") !== "agentMessage") {
return undefined;
}
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.transcriptTerminal) {
return undefined;
}
// Codex app-server can report the child final answer as the child thread's
// own agentMessage without also emitting a parent subagent notification.
// Pair it with idle below so commentary does not become a false terminal.
const phase = readString(item, "phase");
if (phase === "commentary") {
return undefined;
}
const text = readString(item, "text")?.trim();
if (!text) {
return undefined;
}
childState.lastAgentMessage = text;
childState.lastAgentMessageAt = Date.now();
return childState.childThreadId;
}
private recordChildIdle(notification: CodexServerNotification): string | undefined {
if (notification.method !== "thread/status/changed") {
return undefined;
}
const params = isJsonObject(notification.params) ? notification.params : undefined;
if (!params || !isJsonObject(params.status) || readString(params.status, "type") !== "idle") {
return undefined;
}
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.transcriptTerminal) {
return undefined;
}
childState.idle = true;
return childState.childThreadId;
}
private async processChildAgentMessageCompletion(
childThreadId: string | undefined,
): Promise<void> {
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (
!childState ||
!childState.idle ||
childState.transcriptTerminal ||
childState.agentMessageCompletionDelivered ||
!childState.lastAgentMessage
) {
return;
}
const state = this.parentStates.get(childState.parentThreadId);
if (!state) {
return;
}
childState.agentMessageCompletionDelivered = true;
await this.processCompletion(
state,
{
childThreadId: childState.childThreadId,
status: "succeeded",
statusLabel: "agent_message",
result: childState.lastAgentMessage,
},
childState.lastAgentMessageAt,
);
}
private ensureChildState(parentThreadId: string, childThreadId: string): ChildState {
this.registerChildThread(parentThreadId, childThreadId);
return this.childStates.get(childThreadId.trim())!;

View File

@@ -71,7 +71,9 @@ 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,
@@ -366,9 +368,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
params.requestedModelId = "gpt-5.4-codex-primary";
params.fallbackReason = "provider_unavailable";
params.degradedReason = "context_overflow";
params.config = {
memory: { citations: "on" },
} as EmbeddedRunAttemptParams["config"];
params.config = { memory: { citations: "on" } } as EmbeddedRunAttemptParams["config"];
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");

View File

@@ -30,6 +30,7 @@ import {
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
import { CodexAppServerRpcError } from "./client.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
createCodexDynamicToolBridge,
@@ -2165,8 +2166,22 @@ describe("runCodexAppServerAttempt", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage(
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
Date.now(),
),
);
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
for (let index = 0; index < 8; index += 1) {
sessionManager.appendMessage(
assistantMessage(
`continuity filler ${index}: ${"x".repeat(4_000)}`,
Date.now() + 2 + index,
),
);
}
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "make the default webpage openclaw";
@@ -2185,12 +2200,57 @@ describe("runCodexAppServerAttempt", () => {
"";
expect(inputText).toContain("OpenClaw assembled context for this turn:");
expect(inputText).toContain("older next-step anchor: keep the handoff checklist");
expect(inputText).toContain("we are fixing the Opik default project");
expect(inputText).toContain("Opik default project context");
expect(inputText).toContain("Current user request:");
expect(inputText).toContain("make the default webpage openclaw");
});
it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage(
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
Date.now(),
),
);
for (let index = 0; index < 12; index += 1) {
sessionManager.appendMessage(
assistantMessage(
`continuity block ${index}: ${"x".repeat(128_000)}`,
Date.now() + 1 + index,
),
);
}
sessionManager.appendMessage(
assistantMessage("recent continuity anchor: resume the database migration", Date.now() + 20),
);
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.contextTokenBudget = 300_000;
params.prompt = `current prompt survives ${"p".repeat(80_000)}`;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const turnStart = harness.requests.find((request) => request.method === "turn/start");
const inputText =
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
"";
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("OpenClaw assembled context for this turn:");
expect(inputText).toContain("recent continuity anchor: resume the database migration");
expect(inputText).toContain("Current user request:");
expect(inputText).toContain("current prompt survives");
expect(inputText).not.toContain("older next-step anchor: keep the handoff checklist");
});
it("keeps thread-start developer instructions stable when adding fresh-thread continuity", async () => {
let hookCalls = 0;
const beforePromptBuild = vi.fn(async () => {
@@ -4787,11 +4847,28 @@ describe("runCodexAppServerAttempt", () => {
}
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage("post-binding user context", bindingUpdatedAt + 1_000),
userMessage(
"pre-binding native-owned context: keep the original plan",
bindingUpdatedAt - 2_000,
),
);
sessionManager.appendMessage(
userMessage(
"post-binding user context: resume the release checklist",
bindingUpdatedAt + 1_000,
),
);
sessionManager.appendMessage(
assistantMessage("post-binding assistant context", bindingUpdatedAt + 2_000),
);
for (let index = 0; index < 8; index += 1) {
sessionManager.appendMessage(
assistantMessage(
`post-binding continuity filler ${index}: ${"x".repeat(4_000)}`,
bindingUpdatedAt + 3_000 + index,
),
);
}
await fs.writeFile(
path.join(path.dirname(sessionFile), "sessions.json"),
JSON.stringify({
@@ -4835,7 +4912,8 @@ describe("runCodexAppServerAttempt", () => {
const inputText =
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
"";
expect(inputText).toContain("post-binding user context");
expect(inputText).toContain("pre-binding native-owned context: keep the original plan");
expect(inputText).toContain("post-binding user context: resume the release checklist");
expect(inputText).toContain("post-binding assistant context");
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-1");

View File

@@ -44,7 +44,6 @@ import {
resolveDiagnosticModelContentCapturePolicy,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { resolveAgentMemoryConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
resolveCodexAppServerForModelProvider,
@@ -140,6 +139,8 @@ import {
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
import {
type CodexProjectedContextRange,
fitCodexProjectedContextForTurnStart,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@@ -897,8 +898,15 @@ export async function runCodexAppServerAttempt(
skillsPrompt: params.skillsSnapshot?.prompt,
});
let promptText = params.prompt;
let promptContextRange: CodexProjectedContextRange | undefined;
let developerInstructions = baseDeveloperInstructions;
let prePromptMessageCount = historyMessages.length;
const codexContextProjectionMaxChars = resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
});
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
let precomputedStaleBindingContinuityProjectionApplied = false;
let staleBindingContinuityForcedFreshStart = false;
@@ -909,8 +917,10 @@ export async function runCodexAppServerAttempt(
assembledMessages: historyMessages,
originalHistoryMessages: historyMessages,
prompt: params.prompt,
maxRenderedContextChars: codexContextProjectionMaxChars,
});
promptText = projection.promptText;
promptContextRange = projection.promptContextRange;
prePromptMessageCount = projection.prePromptMessageCount;
};
const applyActiveContextEngineProjection = async (
@@ -930,10 +940,7 @@ export async function runCodexAppServerAttempt(
.map((tool) => tool.name)
.filter(isNonEmptyString),
),
citationsMode: params.config
? resolveAgentMemoryConfig(params.config, sessionAgentId)?.citations
: undefined,
agentId: sessionAgentId,
citationsMode: params.config?.memory?.citations,
modelId: params.modelId,
contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
providerId: params.provider,
@@ -953,12 +960,7 @@ export async function runCodexAppServerAttempt(
originalHistoryMessages: historyMessages,
prompt: params.prompt,
systemPromptAddition: assembled.systemPromptAddition,
maxRenderedContextChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
}),
maxRenderedContextChars: codexContextProjectionMaxChars,
toolPayloadMode: contextEngineProjection ? "preserve" : "elide",
});
const projectionDecision = contextEngineProjection
@@ -967,7 +969,6 @@ export async function runCodexAppServerAttempt(
expectedBinding: buildContextEngineBinding(
buildActiveRunAttemptParams(),
contextEngineProjection,
{ agentId: sessionAgentId },
),
projection: contextEngineProjection,
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
@@ -991,6 +992,7 @@ export async function runCodexAppServerAttempt(
developerInstructionAdditionChars: projection.developerInstructionAddition?.length ?? 0,
});
promptText = projectionDecision.project ? projection.promptText : params.prompt;
promptContextRange = projectionDecision.project ? projection.promptContextRange : undefined;
developerInstructions = joinPresentSections(
baseDeveloperInstructions,
projection.developerInstructionAddition,
@@ -1019,12 +1021,31 @@ export async function runCodexAppServerAttempt(
messages: codexModelInputHistoryMessages,
ctx: hookContext,
});
const resolveShiftedPromptContextRange = (
prompt: string,
turnPromptText: string,
): CodexProjectedContextRange | undefined => {
if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) {
return undefined;
}
const promptTextOffset = prompt.length - promptText.length;
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
return {
start: turnPromptOffset + promptContextRange.start,
end: turnPromptOffset + promptContextRange.end,
};
};
let promptBuild = await buildPromptFromCurrentInputs();
const decorateCodexTurnPromptText = (prompt: string) =>
prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
const decorateCodexTurnPromptText = (prompt: string) => {
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
preservePromptWithoutContext:
params.bootstrapContextMode === "lightweight" && params.bootstrapContextRunKind === "cron",
});
return fitCodexProjectedContextForTurnStart({
promptText: turnPromptText,
contextRange: resolveShiftedPromptContextRange(prompt, turnPromptText),
});
};
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
const buildCodexTurnCollaborationDeveloperInstructions = () =>
buildTurnCollaborationMode(params, {
@@ -1093,8 +1114,10 @@ export async function runCodexAppServerAttempt(
assembledMessages: newerVisibleMessages,
originalHistoryMessages: historyMessages,
prompt: params.prompt,
maxRenderedContextChars: codexContextProjectionMaxChars,
});
promptText = projection.promptText;
promptContextRange = projection.promptContextRange;
prePromptMessageCount = projection.prePromptMessageCount;
return true;
};
@@ -1171,6 +1194,11 @@ export async function runCodexAppServerAttempt(
staleBindingContinuityForcedFreshStart =
precomputedStaleBindingContinuityProjectionApplied &&
!inactiveThreadBootstrapBindingForcedFreshStart;
if (staleBindingContinuityForcedFreshStart) {
// Once the native thread id is discarded, Codex no longer owns the
// pre-binding history; rebuild from the mirrored transcript.
applyFreshThreadContinuityProjection();
}
if (activeContextEngine) {
contextEngineProjection = undefined;
try {

View File

@@ -1,7 +1,6 @@
// Codex tests cover thread lifecycle.binding plugin behavior.
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
import {
createParams as createRunAttemptParams,
@@ -13,6 +12,7 @@ import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import {
shouldRotateCodexAppServerBindingForRuntime,
startOrResumeThread,
@@ -1296,8 +1296,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.config = { memory: { citations: "on" },
} as never;
params.config = { memory: { citations: "inline" } } as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
@@ -1326,44 +1325,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
'"turnMaintenanceMode":"foreground"',
);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"on"');
});
it("binds context-engine citations to the lifecycle agent when attempt params omit it", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.agentId = undefined;
params.sessionKey = "agent:writer:session-1";
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.config = {
memory: { citations: "on" },
agents: {
list: [{ id: "writer", memory: { citations: "off" } }],
},
} as never;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-writer");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
agentId: "writer",
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"off"');
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
});
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {

View File

@@ -8,10 +8,6 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
import {
resolveAgentMemoryConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { listRegisteredPluginAgentPromptGuidance } from "openclaw/plugin-sdk/plugin-runtime";
import { CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY } from "../../prompt-overlay.js";
import { isModernCodexModel } from "../../provider.js";
@@ -345,9 +341,7 @@ export async function startOrResumeThread(params: {
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection, {
agentId: params.agentId,
}),
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
const userMcpServersConfigPatch =
params.userMcpServersEnabled === false
@@ -970,7 +964,6 @@ function isTransientWebSearchRestriction(
export function buildContextEngineBinding(
params: EmbeddedRunAttemptParams,
projection?: CodexContextEngineThreadBootstrapProjection,
options?: { agentId?: string },
): CodexAppServerContextEngineBinding | undefined {
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
@@ -988,10 +981,7 @@ export function buildContextEngineBinding(
engineVersion: contextEngine.info.version,
ownsCompaction: contextEngine.info.ownsCompaction === true,
turnMaintenanceMode: contextEngine.info.turnMaintenanceMode,
citationsMode: resolveContextEngineCitationsMode(
params.config,
options?.agentId ?? params.agentId,
),
citationsMode: resolveContextEngineCitationsMode(params.config),
contextTokenBudget: params.contextTokenBudget,
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
@@ -1042,14 +1032,10 @@ function areContextEngineProjectionBindingsCompatible(
);
}
function resolveContextEngineCitationsMode(
config: unknown,
agentId?: string,
): JsonValue | undefined {
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
const rootConfig = isUnknownRecord(config) ? config : undefined;
const citations = rootConfig
? resolveAgentMemoryConfig(rootConfig as OpenClawConfig, agentId ?? "main")?.citations
: undefined;
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
const citations = memoryConfig?.citations;
return isJsonConfigValue(citations) ? citations : undefined;
}

View File

@@ -286,7 +286,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
config: options.config,
env: process.env,
value: options.remote?.apiKey,
path: "agents.*.memory.search.remote.apiKey",
path: "agents.*.memorySearch.remote.apiKey",
});
const { githubToken: profileGithubToken } = await resolveFirstGithubToken({
agentDir: options.agentDir,

View File

@@ -210,7 +210,7 @@ export function resolveGeminiOutputDimensionality(
function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined {
const trimmed = resolveMemorySecretInputString({
value: remoteApiKey,
path: "agents.*.memory.search.remote.apiKey",
path: "agents.*.memorySearch.remote.apiKey",
});
if (!trimmed) {
return undefined;

View File

@@ -15,9 +15,9 @@ native installs and updates.
## Configure
Set `memory.search.provider` to `local`. By default, the plugin
Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin
downloads and uses the EmbeddingGemma GGUF model. Configure
`memory.search.local.modelPath` to use another local path, Hugging
`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging
Face model URI, or HTTPS model URL.
## Package

View File

@@ -147,7 +147,7 @@ export function formatLlamaCppSetupError(err: unknown): string {
"1) Install the official provider plugin: openclaw plugins install @openclaw/llama-cpp-provider",
"2) Use Node 24 for native installs/updates.",
"3) If you use pnpm from source: pnpm approve-builds, then pnpm rebuild node-llama-cpp.",
'Or set memory.search.provider to a remote embedding provider such as "openai", "ollama", "lmstudio", or "voyage".',
'Or set agents.defaults.memorySearch.provider to a remote embedding provider such as "openai", "ollama", "lmstudio", or "voyage".',
]
.filter(Boolean)
.join("\n");

View File

@@ -76,7 +76,7 @@ export async function createLmstudioEmbeddingProvider(
const remoteApiKey = !isFallbackActivation
? resolveMemorySecretInputString({
value: options.remote?.apiKey,
path: "agents.*.memory.search.remote.apiKey",
path: "agents.*.memorySearch.remote.apiKey",
})
: undefined;
// memorySearch.remote is shared across primary + fallback providers.

View File

@@ -16,11 +16,7 @@ import { stateMigrations } from "./doctor-contract-api.js";
import { testing as dreamingTesting } from "./src/dreaming-phases.js";
import {
configureMemoryCoreDreamingState,
DREAMING_DAILY_INGESTION_NAMESPACE,
memoryCoreWorkspaceStateKey,
resetMemoryCoreDreamingStateForTests,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
} from "./src/dreaming-state.js";
import { testing as shortTermTesting } from "./src/short-term-promotion.js";
@@ -73,14 +69,6 @@ describe("memory-core doctor dreaming migration", () => {
};
}
function migrationById(id: string) {
const migration = stateMigrations.find((entry) => entry.id === id);
if (!migration) {
throw new Error(`Missing migration ${id}`);
}
return migration;
}
it("imports persistent legacy dreaming state and ignores transient locks", async () => {
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
const dailyPath = path.join(dreamsDir, "daily-ingestion.json");
@@ -167,7 +155,7 @@ describe("memory-core doctor dreaming migration", () => {
);
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf8");
const migration = migrationById("memory-core-dreams-json-to-sqlite");
const migration = stateMigrations[0];
const preview = await migration.detectLegacyState(migrationParams());
expect(preview?.preview).toEqual([
expect.stringContaining("Memory Core daily ingestion"),
@@ -197,21 +185,16 @@ describe("memory-core doctor dreaming migration", () => {
await expect(fs.access(`${phasePath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(lockPath)).resolves.toBeUndefined();
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir, "main");
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir);
expect(daily.files["memory/2026-04-05.md"]?.mtimeMs).toBe(1);
const session = await dreamingTesting.readSessionIngestionState(workspaceDir, "main");
const session = await dreamingTesting.readSessionIngestionState(workspaceDir);
expect(session.files["main/session.jsonl"]?.contentHash).toBe("session-hash");
expect(session.seenMessages["main/session.jsonl"]).toEqual(["seen-a", "seen-b"]);
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
"2026-04-05T12:00:00.000Z",
"main",
);
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
const phase = await shortTermTesting.readPhaseSignalStore(
workspaceDir,
"2026-04-05T13:00:00.000Z",
"main",
);
expect(phase.entries["memory:memory/2026-04-05.md:1:1"]?.remHits).toBe(2);
});
@@ -220,9 +203,7 @@ describe("memory-core doctor dreaming migration", () => {
const recallPath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.writeFile(recallPath, "{", "utf8");
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
migrationParams(),
);
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
@@ -231,11 +212,7 @@ describe("memory-core doctor dreaming migration", () => {
await expect(fs.access(recallPath)).resolves.toBeUndefined();
await expect(fs.access(`${recallPath}.migrated`)).rejects.toThrow();
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
const recall = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
expect(recall.entries).toEqual({});
});
@@ -268,14 +245,10 @@ describe("memory-core doctor dreaming migration", () => {
);
const config = { agents: { list: [{ id: "main", default: true }] } };
const preview = await migrationById("memory-core-dreams-json-to-sqlite").detectLegacyState(
migrationParams(config),
);
const preview = await stateMigrations[0].detectLegacyState(migrationParams(config));
expect(preview?.preview).toEqual([expect.stringContaining("Memory Core short-term recall")]);
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
migrationParams(config),
);
const result = await stateMigrations[0].migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
@@ -283,493 +256,7 @@ describe("memory-core doctor dreaming migration", () => {
expect.stringContaining("Archived Memory Core short-term recall legacy source"),
]);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
"2026-04-05T12:00:00.000Z",
"main",
);
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
});
it("moves unscoped SQLite state and the legacy diary to the default agent", async () => {
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
entries: [
{
key: "memory/2026-04-05.md",
value: {
size: 42,
mtimeMs: 1,
contentHash: "daily-hash",
ingestedAt: "2026-04-05T10:00:00.000Z",
},
},
],
});
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(legacyDiaryPath, "# Dream Diary\n\nA remembered dream.\n", "utf8");
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
const preview = await migration.detectLegacyState(migrationParams());
expect(preview?.preview).toEqual([
expect.stringContaining("Memory Core daily ingestion"),
expect.stringContaining("Memory Core dream diary"),
]);
const result = await migration.migrateLegacyState(migrationParams());
expect(result.warnings).toEqual([]);
expect(result.changes).toContain(
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
);
expect(result.changes).toContain(
"Migrated Memory Core dream diary -> agent-scoped path (main)",
);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
}),
).toEqual([]);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "main",
}),
).toHaveLength(1);
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
await expect(
fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf8",
),
).resolves.toContain("A remembered dream.");
});
it("preserves a legacy diary when its destination contains only a substring", async () => {
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
const agentDiaryPath = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"DREAMS.md",
);
await fs.writeFile(legacyDiaryPath, "A remembered dream\n", "utf8");
await fs.mkdir(path.dirname(agentDiaryPath), { recursive: true });
await fs.writeFile(agentDiaryPath, "A remembered dream, continued.\n", "utf8");
const result = await migrationById(
"memory-core-workspace-state-to-agent-scope",
).migrateLegacyState(migrationParams());
expect(result.warnings).toEqual([]);
expect(result.changes).toContain(
"Migrated Memory Core dream diary -> agent-scoped path (main)",
);
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.readFile(agentDiaryPath, "utf8")).resolves.toBe(
"A remembered dream\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->\n\nA remembered dream, continued.\n",
);
});
it("canonicalizes agent ids at the SQLite state boundary", async () => {
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
expect(memoryCoreWorkspaceStateKey(workspaceDir, "Team Ops")).toBe(
memoryCoreWorkspaceStateKey(workspaceDir, "team-ops"),
);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "Team Ops",
entries: [
{
key: "memory/2026-04-06.md",
value: {
size: 18,
mtimeMs: 2,
contentHash: "team-daily-hash",
ingestedAt: "2026-04-06T10:00:00.000Z",
},
},
],
});
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "team-ops",
}),
).resolves.toHaveLength(1);
});
it("migrates a legacy workspace to that workspace's configured agent", async () => {
const researchWorkspaceDir = path.join(rootDir, "research");
const researchDreamsDir = path.join(researchWorkspaceDir, "memory", ".dreams");
await fs.mkdir(researchDreamsDir, { recursive: true });
const dailyPath = path.join(researchDreamsDir, "daily-ingestion.json");
await fs.writeFile(
dailyPath,
JSON.stringify({
version: 1,
files: {
"memory/2026-04-06.md": {
size: 18,
mtimeMs: 2,
contentHash: "research-daily-hash",
ingestedAt: "2026-04-06T10:00:00.000Z",
},
},
}),
"utf8",
);
const config: OpenClawConfig = {
agents: {
list: [
{ id: "main", default: true, workspace: workspaceDir },
{ id: "research", workspace: researchWorkspaceDir },
],
},
};
const migration = migrationById("memory-core-dreams-json-to-sqlite");
const result = await migration.migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toContain(
"Migrated Memory Core daily ingestion -> SQLite plugin state (1 row(s))",
);
expect({
main: await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: researchWorkspaceDir,
agentId: "main",
}),
research: await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: researchWorkspaceDir,
agentId: "research",
}),
unscoped: await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: researchWorkspaceDir,
}),
}).toEqual({
main: [],
research: [
expect.objectContaining({
key: "memory/2026-04-06.md",
value: expect.objectContaining({
size: 18,
mtimeMs: 2,
}),
}),
],
unscoped: [],
});
});
it("imports shared legacy JSON into the configured default agent", async () => {
const dailyPath = path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json");
await fs.writeFile(
dailyPath,
JSON.stringify({
version: 1,
files: {
"memory/2026-04-07.md": {
size: 22,
mtimeMs: 3,
contentHash: "shared-daily-hash",
ingestedAt: "2026-04-07T10:00:00.000Z",
},
},
}),
"utf8",
);
const config: OpenClawConfig = {
agents: {
list: [
{ id: "main", default: true, workspace: workspaceDir },
{ id: "research", workspace: workspaceDir },
],
},
};
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
migrationParams(config),
);
expect(result.warnings).toEqual([]);
expect(result.changes).toContain(
"Migrated Memory Core daily ingestion -> SQLite plugin state (1 row(s))",
);
await expect(fs.access(`${dailyPath}.migrated`)).resolves.toBeUndefined();
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "main",
}),
).resolves.toHaveLength(1);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "research",
}),
).resolves.toEqual([]);
});
it("moves shared legacy state and diaries to the configured default agent", async () => {
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
entries: [
{
key: "memory/2026-04-07.md",
value: {
size: 22,
mtimeMs: 3,
contentHash: "shared-daily-hash",
ingestedAt: "2026-04-07T10:00:00.000Z",
},
},
],
});
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(legacyDiaryPath, "# Shared dream diary\n", "utf8");
const config: OpenClawConfig = {
agents: {
list: [
{ id: "main", default: true, workspace: workspaceDir },
{ id: "research", workspace: workspaceDir },
],
},
};
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
const preview = await migration.detectLegacyState(migrationParams(config));
expect(preview?.preview.join("\n")).toContain("-> agent main");
const result = await migration.migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toContain(
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
);
expect(result.changes).toContain(
"Migrated Memory Core dream diary -> agent-scoped path (main)",
);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
}),
).toEqual([]);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "main",
}),
).toHaveLength(1);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "research",
}),
).toEqual([]);
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
await expect(
fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf8",
),
).resolves.toContain("Shared dream diary");
});
it("leaves shared legacy state in place when the default agent has another workspace", async () => {
const defaultWorkspaceDir = path.join(rootDir, "default-workspace");
await fs.mkdir(defaultWorkspaceDir);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
entries: [
{
key: "memory/2026-04-07.md",
value: {
size: 22,
mtimeMs: 3,
contentHash: "shared-daily-hash",
ingestedAt: "2026-04-07T10:00:00.000Z",
},
},
],
});
const config: OpenClawConfig = {
agents: {
list: [
{ id: "main", default: true, workspace: defaultWorkspaceDir },
{ id: "research", workspace: workspaceDir },
{ id: "writer", workspace: workspaceDir },
],
},
};
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
const preview = await migration.detectLegacyState(migrationParams(config));
expect(preview?.preview.join("\n")).toContain("resolved default agent does not share");
const result = await migration.migrateLegacyState(migrationParams(config));
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
expect.stringContaining("resolved default agent does not share"),
]);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
}),
).resolves.toHaveLength(1);
});
it("moves symlinked shared workspace aliases to the configured default agent", async () => {
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
await fs.symlink(workspaceDir, workspaceAliasDir);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: workspaceAliasDir,
entries: [
{
key: "memory/2026-04-07.md",
value: {
size: 22,
mtimeMs: 3,
contentHash: "shared-daily-hash",
ingestedAt: "2026-04-07T10:00:00.000Z",
},
},
],
});
const config: OpenClawConfig = {
agents: {
list: [
{ id: "research", workspace: workspaceAliasDir },
{ id: "main", default: true, workspace: workspaceDir },
],
},
};
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
const preview = await migration.detectLegacyState(migrationParams(config));
expect(preview?.preview.join("\n")).toContain("-> agent main");
const result = await migration.migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
]);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
}),
).toEqual([]);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: workspaceAliasDir,
}),
).toEqual([]);
expect(
await readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId: "main",
}),
).toHaveLength(1);
});
it("moves legacy alias state into the configured workspace scope", async () => {
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
const workspaceCanonicalDir = await fs.realpath(workspaceDir);
await fs.symlink(workspaceDir, workspaceAliasDir);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: workspaceCanonicalDir,
entries: [
{
key: "memory/2026-04-08.md",
value: {
size: 23,
mtimeMs: 4,
contentHash: "alias-daily-hash",
ingestedAt: "2026-04-08T10:00:00.000Z",
},
},
],
});
const config: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceAliasDir }],
},
};
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
const result = await migration.migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
]);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: workspaceCanonicalDir,
}),
).resolves.toEqual([]);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: workspaceAliasDir,
agentId: "main",
}),
).resolves.toHaveLength(1);
});
it("does not migrate legacy sources through symlinked dream directories", async () => {
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
const outsideDreamsDir = path.join(rootDir, "outside-dreams");
const outsideLegacyPath = path.join(outsideDreamsDir, "short-term-recall.json");
await fs.rm(dreamsDir, { recursive: true, force: true });
await fs.mkdir(outsideDreamsDir, { recursive: true });
await fs.writeFile(
outsideLegacyPath,
JSON.stringify({
version: 1,
updatedAt: "2026-04-07T10:00:00.000Z",
entries: {},
}),
"utf8",
);
await fs.symlink(outsideDreamsDir, dreamsDir);
const migration = migrationById("memory-core-dreams-json-to-sqlite");
expect(await migration.detectLegacyState(migrationParams())).toBeNull();
const result = await migration.migrateLegacyState(migrationParams());
expect(result).toEqual({ changes: [], warnings: [] });
await expect(fs.access(outsideLegacyPath)).resolves.toBeUndefined();
await expect(fs.access(`${outsideLegacyPath}.migrated`)).rejects.toThrow();
});
});

View File

@@ -1,14 +1,8 @@
// Memory Core doctor contract migrates shipped workspace dreaming state.
import fs from "node:fs/promises";
import path from "node:path";
import {
listAgentIds,
resolveDefaultAgentId,
resolveAgentWorkspaceDir,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import { updateDreamsFile } from "./src/dreaming-dreams-file.js";
import {
DAILY_INGESTION_STATE_RELATIVE_PATH,
SESSION_INGESTION_STATE_RELATIVE_PATH,
@@ -24,7 +18,6 @@ import {
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
SHORT_TERM_RECALL_NAMESPACE,
configureMemoryCoreDreamingState,
migrateMemoryCoreWorkspaceNamespaceToAgent,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntry,
@@ -38,223 +31,49 @@ import {
type LegacySource = {
workspaceDir: string;
stateWorkspaceDir: string;
agentId: string;
label: string;
relativePath: string;
filePath: string;
};
type WorkspaceTarget = {
workspaceDir: string;
stateWorkspaceDir: string;
stateWorkspaceDirs: string[];
agentIds: string[];
agentWorkspaceDirs: Record<string, string>;
agentId?: string;
};
const LEGACY_JSON_CANDIDATES = [
{ label: "daily ingestion", relativePath: DAILY_INGESTION_STATE_RELATIVE_PATH },
{ label: "session ingestion", relativePath: SESSION_INGESTION_STATE_RELATIVE_PATH },
{ label: "short-term recall", relativePath: SHORT_TERM_STORE_RELATIVE_PATH },
{ label: "phase signals", relativePath: SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH },
] as const;
const SCOPED_STATE_NAMESPACES = [
{ namespace: DREAMING_DAILY_INGESTION_NAMESPACE, label: "daily ingestion" },
{ namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE, label: "session ingestion files" },
{ namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE, label: "session ingestion seen state" },
{ namespace: SHORT_TERM_RECALL_NAMESPACE, label: "short-term recall" },
{ namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE, label: "phase signals" },
{ namespace: SHORT_TERM_META_NAMESPACE, label: "short-term metadata" },
] as const;
async function resolveConfiguredWorkspaces(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<WorkspaceTarget[]> {
const cfg = config as Parameters<typeof listAgentIds>[0];
const defaultAgentId = resolveDefaultAgentId(cfg);
const targets = new Map<string, WorkspaceTarget>();
for (const configuredAgentId of listAgentIds(cfg)) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, configuredAgentId, env);
const workspaceRoot = await fs.realpath(workspaceDir).catch(() => path.resolve(workspaceDir));
const existing = targets.get(workspaceRoot);
if (existing) {
existing.agentIds.push(configuredAgentId);
existing.agentWorkspaceDirs[configuredAgentId] = workspaceDir;
if (!existing.stateWorkspaceDirs.includes(workspaceDir)) {
existing.stateWorkspaceDirs.push(workspaceDir);
}
} else {
targets.set(workspaceRoot, {
workspaceDir: workspaceRoot,
stateWorkspaceDir: workspaceDir,
stateWorkspaceDirs: [...new Set([workspaceDir, workspaceRoot])],
agentIds: [configuredAgentId],
agentWorkspaceDirs: { [configuredAgentId]: workspaceDir },
});
}
}
return [...targets.values()].map((target) => {
const agentId =
target.agentIds.length === 1
? target.agentIds[0]
: target.agentIds.includes(defaultAgentId)
? defaultAgentId
: undefined;
const resolved: WorkspaceTarget = {
workspaceDir: target.workspaceDir,
stateWorkspaceDir: agentId
? (target.agentWorkspaceDirs[agentId] ?? target.stateWorkspaceDir)
: target.stateWorkspaceDir,
stateWorkspaceDirs: target.stateWorkspaceDirs,
agentIds: target.agentIds,
agentWorkspaceDirs: target.agentWorkspaceDirs,
};
if (agentId) {
// Legacy workspace-scoped dreaming state was shared. Preserve it under
// the resolved default agent rather than copying private state to peers.
resolved.agentId = agentId;
}
return resolved;
});
function resolveConfiguredWorkspaces(config: unknown, env: NodeJS.ProcessEnv): string[] {
return resolveMemoryDreamingWorkspaces(
config as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
{ env },
).map((entry) => entry.workspaceDir);
}
function isAgentScopedWorkspaceTarget(
target: WorkspaceTarget,
): target is WorkspaceTarget & { agentId: string } {
return typeof target.agentId === "string";
}
async function resolveAgentScopedWorkspaces(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<Array<WorkspaceTarget & { agentId: string }>> {
return (await resolveConfiguredWorkspaces(config, env)).filter(isAgentScopedWorkspaceTarget);
}
function formatSharedWorkspaceMigrationWarning(target: WorkspaceTarget): string {
return `Skipped automatic Memory Core dreaming migration for shared workspace ${target.workspaceDir}; its resolved default agent does not share the workspace with ${target.agentIds.join(", ")}`;
}
async function collectSharedJsonMigrationWarnings(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<string[]> {
const warnings: string[] = [];
for (const target of await resolveConfiguredWorkspaces(config, env)) {
if (isAgentScopedWorkspaceTarget(target)) {
continue;
}
for (const candidate of LEGACY_JSON_CANDIDATES) {
if (await workspaceFileExists(target.workspaceDir, candidate.relativePath)) {
warnings.push(formatSharedWorkspaceMigrationWarning(target));
break;
}
}
}
return warnings;
}
async function collectSharedAgentScopeMigrationWarnings(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<string[]> {
const warnings: string[] = [];
for (const target of await resolveConfiguredWorkspaces(config, env)) {
if (isAgentScopedWorkspaceTarget(target)) {
continue;
}
const hasUnscopedState = (
await Promise.all(
SCOPED_STATE_NAMESPACES.map(async (candidate) => {
return (
await Promise.all(
target.stateWorkspaceDirs.map(async (workspaceDir) => {
const entries = await readMemoryCoreWorkspaceEntries({
namespace: candidate.namespace,
workspaceDir,
});
return entries.length > 0;
}),
)
).some(Boolean);
}),
)
).some(Boolean);
const hasLegacyDiary = (
await Promise.all(
["DREAMS.md", "dreams.md"].map((relativePath) =>
workspaceFileExists(target.workspaceDir, relativePath),
),
)
).some(Boolean);
if (hasUnscopedState || hasLegacyDiary) {
warnings.push(formatSharedWorkspaceMigrationWarning(target));
}
}
return warnings;
}
async function workspaceFileExists(workspaceDir: string, relativePath: string): Promise<boolean> {
async function fileExists(filePath: string): Promise<boolean> {
try {
const workspace = await fsRoot(workspaceDir);
const opened = await workspace.open(relativePath);
try {
return opened.stat.isFile();
} finally {
await opened.handle.close().catch(() => undefined);
}
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
async function readWorkspaceRegularFile(
workspaceDir: string,
relativePath: string,
): Promise<Buffer> {
const workspace = await fsRoot(workspaceDir);
const opened = await workspace.open(relativePath);
try {
if (!opened.stat.isFile()) {
throw new Error("source is not a regular file");
}
return await opened.handle.readFile();
} finally {
await opened.handle.close().catch(() => undefined);
}
}
async function readJsonFile(workspaceDir: string, relativePath: string): Promise<unknown> {
return JSON.parse((await readWorkspaceRegularFile(workspaceDir, relativePath)).toString("utf8"));
async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
async function archiveLegacySource(params: {
workspaceDir: string;
relativePath: string;
filePath: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const workspace = await fsRoot(params.workspaceDir);
const archivedRelativePath = `${params.relativePath}.migrated`;
const filePath = path.join(params.workspaceDir, params.relativePath);
const archivedPath = path.join(params.workspaceDir, archivedRelativePath);
if (await workspaceFileExists(params.workspaceDir, archivedRelativePath)) {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated Memory Core ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
await workspace.move(params.relativePath, archivedRelativePath);
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived Memory Core ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving Memory Core ${params.label} source ${filePath}: ${String(err)}`,
`Failed archiving Memory Core ${params.label} legacy source: ${String(err)}`,
);
}
}
@@ -264,53 +83,39 @@ async function collectLegacySources(
env: NodeJS.ProcessEnv,
): Promise<LegacySource[]> {
const sources: LegacySource[] = [];
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
const { workspaceDir, stateWorkspaceDir, agentId } = target;
for (const candidate of LEGACY_JSON_CANDIDATES) {
for (const workspaceDir of resolveConfiguredWorkspaces(config, env)) {
const candidates = [
{ label: "daily ingestion", relativePath: DAILY_INGESTION_STATE_RELATIVE_PATH },
{ label: "session ingestion", relativePath: SESSION_INGESTION_STATE_RELATIVE_PATH },
{ label: "short-term recall", relativePath: SHORT_TERM_STORE_RELATIVE_PATH },
{ label: "phase signals", relativePath: SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH },
];
for (const candidate of candidates) {
const filePath = path.join(workspaceDir, candidate.relativePath);
if (await workspaceFileExists(workspaceDir, candidate.relativePath)) {
sources.push({
workspaceDir,
stateWorkspaceDir,
agentId,
label: candidate.label,
relativePath: candidate.relativePath,
filePath,
});
if (await fileExists(filePath)) {
sources.push({ workspaceDir, label: candidate.label, filePath });
}
}
}
return sources;
}
async function workspaceHasRows(
namespace: string,
stateWorkspaceDir: string,
agentId: string,
): Promise<boolean> {
return (
(await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir: stateWorkspaceDir, agentId }))
.length > 0
);
async function workspaceHasRows(namespace: string, workspaceDir: string): Promise<boolean> {
return (await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir })).length > 0;
}
async function migrateDailyIngestion(source: LegacySource): Promise<number> {
const state = normalizeDailyIngestionState(
await readJsonFile(source.workspaceDir, source.relativePath),
);
const state = normalizeDailyIngestionState(await readJsonFile(source.filePath));
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
});
return Object.keys(state.files).length;
}
async function migrateSessionIngestion(source: LegacySource): Promise<number> {
const state = normalizeSessionIngestionState(
await readJsonFile(source.workspaceDir, source.relativePath),
);
const state = normalizeSessionIngestionState(await readJsonFile(source.filePath));
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
Array.from(
{ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) },
@@ -330,14 +135,12 @@ async function migrateSessionIngestion(source: LegacySource): Promise<number> {
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
entries: seenEntries,
}),
]);
@@ -346,21 +149,16 @@ async function migrateSessionIngestion(source: LegacySource): Promise<number> {
async function migrateShortTermRecall(source: LegacySource): Promise<number> {
const nowIso = new Date().toISOString();
const state = normalizeShortTermRecallStore(
await readJsonFile(source.workspaceDir, source.relativePath),
nowIso,
);
const state = normalizeShortTermRecallStore(await readJsonFile(source.filePath), nowIso);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
key: "recall",
value: { updatedAt: state.updatedAt },
}),
@@ -370,21 +168,16 @@ async function migrateShortTermRecall(source: LegacySource): Promise<number> {
async function migratePhaseSignals(source: LegacySource): Promise<number> {
const nowIso = new Date().toISOString();
const state = normalizeShortTermPhaseSignalStore(
await readJsonFile(source.workspaceDir, source.relativePath),
nowIso,
);
const state = normalizeShortTermPhaseSignalStore(await readJsonFile(source.filePath), nowIso);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
workspaceDir: source.workspaceDir,
key: "phase",
value: { updatedAt: state.updatedAt },
}),
@@ -418,159 +211,31 @@ async function migrateSource(source: LegacySource): Promise<number> {
return await migratePhaseSignals(source);
}
async function collectUnscopedStateTargets(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<
Array<
WorkspaceTarget & {
agentId: string;
sourceStateWorkspaceDir: string;
namespace: string;
label: string;
entryCount: number;
}
>
> {
const sources: Array<
WorkspaceTarget & {
agentId: string;
sourceStateWorkspaceDir: string;
namespace: string;
label: string;
entryCount: number;
}
> = [];
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
for (const sourceStateWorkspaceDir of target.stateWorkspaceDirs) {
for (const candidate of SCOPED_STATE_NAMESPACES) {
const entryCount = (
await readMemoryCoreWorkspaceEntries({
namespace: candidate.namespace,
workspaceDir: sourceStateWorkspaceDir,
})
).length;
if (entryCount > 0) {
sources.push({ ...target, sourceStateWorkspaceDir, ...candidate, entryCount });
}
}
}
}
return sources;
}
async function collectLegacyDreamDiarySources(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<Array<WorkspaceTarget & { agentId: string; relativePath: string; filePath: string }>> {
const sources: Array<
WorkspaceTarget & { agentId: string; relativePath: string; filePath: string }
> = [];
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
const seenPaths = new Set<string>();
for (const filename of ["DREAMS.md", "dreams.md"]) {
const filePath = path.join(target.workspaceDir, filename);
if (await workspaceFileExists(target.workspaceDir, filename)) {
const realPath = await fs.realpath(filePath).catch(() => filePath);
if (!seenPaths.has(realPath)) {
seenPaths.add(realPath);
sources.push({ ...target, relativePath: filename, filePath });
}
}
}
}
return sources;
}
async function migrateLegacyDreamDiary(params: {
workspaceDir: string;
agentId: string;
relativePath: string;
filePath: string;
changes: string[];
warnings: string[];
}): Promise<void> {
let legacyContent: string;
try {
legacyContent = (
await readWorkspaceRegularFile(params.workspaceDir, params.relativePath)
).toString("utf8");
} catch {
params.warnings.push(
`Skipped Memory Core dream diary migration for ${params.filePath} because it is not a regular file`,
);
return;
}
try {
await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing) => {
const normalizedLegacy = legacyContent.trim();
const migratedPrefix = `${legacyContent.trimEnd()}\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->`;
if (
!normalizedLegacy ||
existing.trim() === normalizedLegacy ||
existing.includes(migratedPrefix)
) {
return { content: existing, result: undefined, shouldWrite: false };
}
if (!existing.trim()) {
return { content: legacyContent, result: undefined };
}
return {
content: `${legacyContent.trimEnd()}\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->\n\n${existing.trimStart()}`,
result: undefined,
};
},
});
} catch (err) {
params.warnings.push(
`Skipped Memory Core dream diary migration for ${params.filePath}: ${String(err)}`,
);
return;
}
await archiveLegacySource({
workspaceDir: params.workspaceDir,
relativePath: params.relativePath,
label: "dream diary",
changes: params.changes,
warnings: params.warnings,
});
params.changes.push(`Migrated Memory Core dream diary -> agent-scoped path (${params.agentId})`);
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "memory-core-dreams-json-to-sqlite",
label: "Memory Core dreaming state",
async detectLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const [sources, sharedWarnings] = await Promise.all([
collectLegacySources(params.config, params.env),
collectSharedJsonMigrationWarnings(params.config, params.env),
]);
if (sources.length === 0 && sharedWarnings.length === 0) {
const sources = await collectLegacySources(params.config, params.env);
if (sources.length === 0) {
return null;
}
return {
preview: [
...sources.map(
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
),
...sharedWarnings.map((warning) => `- ${warning}`),
],
preview: sources.map(
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
),
};
},
async migrateLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const changes: string[] = [];
const warnings = await collectSharedJsonMigrationWarnings(params.config, params.env);
const warnings: string[] = [];
for (const source of await collectLegacySources(params.config, params.env)) {
const targetHasRows = (
await Promise.all(
targetNamespacesForSource(source.label).map((namespace) =>
workspaceHasRows(namespace, source.stateWorkspaceDir, source.agentId),
workspaceHasRows(namespace, source.workspaceDir),
),
)
).some(Boolean);
@@ -593,8 +258,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
`Migrated Memory Core ${source.label} -> SQLite plugin state (${imported} row(s))`,
);
await archiveLegacySource({
workspaceDir: source.workspaceDir,
relativePath: source.relativePath,
filePath: source.filePath,
label: source.label,
changes,
warnings,
@@ -603,53 +267,4 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
return { changes, warnings };
},
},
{
id: "memory-core-workspace-state-to-agent-scope",
label: "Memory Core agent-scoped dreaming state",
async detectLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const [stateSources, diarySources, sharedWarnings] = await Promise.all([
collectUnscopedStateTargets(params.config, params.env),
collectLegacyDreamDiarySources(params.config, params.env),
collectSharedAgentScopeMigrationWarnings(params.config, params.env),
]);
if (stateSources.length === 0 && diarySources.length === 0 && sharedWarnings.length === 0) {
return null;
}
return {
preview: [
...stateSources.map(
(source) =>
`- Memory Core ${source.label}: ${source.entryCount} workspace-scoped row(s) -> agent ${source.agentId}`,
),
...diarySources.map(
(source) => `- Memory Core dream diary: ${source.filePath} -> agent ${source.agentId}`,
),
...sharedWarnings.map((warning) => `- ${warning}`),
],
};
},
async migrateLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const changes: string[] = [];
const warnings = await collectSharedAgentScopeMigrationWarnings(params.config, params.env);
for (const source of await collectUnscopedStateTargets(params.config, params.env)) {
const result = await migrateMemoryCoreWorkspaceNamespaceToAgent({
namespace: source.namespace,
agentId: source.agentId,
sourceWorkspaceDir: source.sourceStateWorkspaceDir,
workspaceDir: source.stateWorkspaceDir,
});
if (result.sourceEntries > 0) {
changes.push(
`Migrated Memory Core ${source.label} -> agent-scoped SQLite state (${result.migratedEntries} row(s), ${result.retainedAgentEntries} existing agent row(s) retained)`,
);
}
}
for (const source of await collectLegacyDreamDiarySources(params.config, params.env)) {
await migrateLegacyDreamDiary({ ...source, changes, warnings });
}
return { changes, warnings };
},
},
];

View File

@@ -201,11 +201,5 @@
}
}
}
},
"configContracts": {
"compatibilityMigrationPaths": [
"plugins.entries.memory-core.config",
"plugins.slots.memory"
]
}
}

View File

@@ -24,7 +24,6 @@
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-api.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
// Memory Core setup hooks register compatibility migration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { migrateMemoryCoreLegacyConfig } from "./src/config-compat.js";
export default definePluginEntry({
id: "memory-core",
name: "Memory Core Setup",
description: "Memory Core compatibility migration hooks",
register(api) {
api.registerConfigMigration((config) => migrateMemoryCoreLegacyConfig(config));
},
});

View File

@@ -5,7 +5,6 @@ import os from "node:os";
import path from "node:path";
import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
@@ -114,8 +113,8 @@ type LoadedMemoryCommandConfig = {
function getMemoryCommandSecretTargetIds(): Set<string> {
return new Set([
"memory.search.remote.apiKey",
"agents.list[].memory.search.remote.apiKey",
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
]);
}
@@ -149,11 +148,9 @@ function emitMemorySecretResolveDiagnostics(
}
}
function resolveMemoryPluginConfig(
cfg: OpenClawConfig,
agentId = resolveDefaultAgentId(cfg),
): Record<string, unknown> {
return resolveMemoryCorePluginConfig(cfg, agentId) ?? {};
function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
}
const DAILY_MEMORY_FILE_NAME_RE = /^(\d{4}-\d{2}-\d{2})(?:-[^/]+)?\.md$/i;
@@ -186,7 +183,6 @@ async function createHistoricalRemHarnessWorkspace(params: {
inputPath: string;
remLimit: number;
nowMs: number;
agentId?: string;
timezone?: string;
}): Promise<{
workspaceDir: string;
@@ -213,7 +209,6 @@ async function createHistoricalRemHarnessWorkspace(params: {
filePaths: workspaceSourceFiles,
limit: params.remLimit,
nowMs: params.nowMs,
agentId: params.agentId,
timezone: params.timezone,
});
return {
@@ -226,9 +221,9 @@ async function createHistoricalRemHarnessWorkspace(params: {
};
}
function formatDreamingSummary(cfg: OpenClawConfig, agentId: string): string {
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg, agentId });
function formatDreamingSummary(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryPluginConfig(cfg);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
if (!dreaming.enabled) {
return "off";
}
@@ -793,18 +788,17 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
let dreamingAudit: DreamingArtifactsAuditSummary | undefined;
let dreamingRepair: RepairDreamingArtifactsResult | undefined;
if (workspaceDir) {
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
if (opts.fix && dreamingAudit.issues.some((issue) => issue.fixable)) {
dreamingRepair = await repairDreamingArtifacts({ workspaceDir, agentId });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
}
if (opts.fix) {
repair = await repairShortTermPromotionArtifacts({ workspaceDir, agentId });
repair = await repairShortTermPromotionArtifacts({ workspaceDir });
}
const customQmd = asRecord(asRecord(status.custom)?.qmd);
audit = await auditShortTermPromotionArtifacts({
workspaceDir,
agentId,
qmd:
status.backend === "qmd"
? {
@@ -887,7 +881,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(storePath)}`,
`${label("Workspace")} ${info(workspacePath)}`,
`${label("Dreaming")} ${info(formatDreamingSummary(cfg, agentId))}`,
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state =
@@ -1251,16 +1245,14 @@ export async function runMemorySearch(
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentId = resolveAgent(cfg, opts.agent);
const memoryPluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const memoryPluginConfig = resolveMemoryPluginConfig(cfg);
const dreamingEnabled = resolveMemoryDreamingConfig({
pluginConfig: memoryPluginConfig,
cfg,
agentId,
}).enabled;
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: memoryPluginConfig,
cfg,
agentId,
});
await withMemoryManagerForAgent({
cfg,
@@ -1288,7 +1280,6 @@ export async function runMemorySearch(
if (dreamingEnabled) {
void recordShortTermRecalls({
workspaceDir,
agentId,
query,
results,
timezone: dreaming.timezone,
@@ -1344,9 +1335,8 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
pluginConfig: resolveMemoryPluginConfig(cfg),
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory promote requires a resolvable workspace directory.");
@@ -1358,7 +1348,6 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
try {
candidates = await rankShortTermPromotionCandidates({
workspaceDir,
agentId,
limit: opts.limit,
minScore: opts.minScore ?? dreaming.minScore,
minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount,
@@ -1378,7 +1367,6 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
try {
applyResult = await applyShortTermPromotions({
workspaceDir,
agentId,
candidates,
limit: opts.limit,
minScore: opts.minScore ?? dreaming.minScore,
@@ -1395,12 +1383,11 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
}
}
const storePath = resolveShortTermRecallStorePath(workspaceDir, agentId);
const lockPath = resolveShortTermRecallLockPath(workspaceDir, agentId);
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
const customQmd = asRecord(asRecord(status.custom)?.qmd);
const audit = await auditShortTermPromotionArtifacts({
workspaceDir,
agentId,
qmd:
status.backend === "qmd"
? {
@@ -1533,9 +1520,8 @@ export async function runMemoryPromoteExplain(
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
pluginConfig: resolveMemoryPluginConfig(cfg),
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory promote-explain requires a resolvable workspace directory.");
@@ -1547,7 +1533,6 @@ export async function runMemoryPromoteExplain(
try {
candidates = await rankShortTermPromotionCandidates({
workspaceDir,
agentId,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
@@ -1641,7 +1626,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
run: async (manager) => {
const status = manager.status();
const managerWorkspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const pluginConfig = resolveMemoryPluginConfig(cfg);
if (!managerWorkspaceDir && !opts.path) {
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
process.exitCode = 1;
@@ -1650,7 +1635,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig,
cfg,
agentId,
});
const nowMs = Date.now();
let workspaceDir = managerWorkspaceDir ?? "";
@@ -1665,7 +1649,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
inputPath: opts.path,
remLimit: remConfig.limit,
nowMs,
agentId,
timezone: remConfig.timezone,
});
workspaceDir = historical.workspaceDir;
@@ -1693,7 +1676,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
const preview = await previewRemHarness({
workspaceDir,
cfg,
agentId,
pluginConfig,
grounded: Boolean(opts.grounded),
groundedInputPaths,
@@ -1824,11 +1806,10 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
run: async (manager) => {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const pluginConfig = resolveMemoryPluginConfig(cfg);
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig,
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory rem-backfill requires a resolvable workspace directory.");
@@ -1838,10 +1819,10 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
if (opts.rollback || opts.rollbackShortTerm) {
const diaryRollback = opts.rollback
? await removeBackfillDiaryEntries({ workspaceDir, agentId })
? await removeBackfillDiaryEntries({ workspaceDir })
: null;
const shortTermRollback = opts.rollbackShortTerm
? await removeGroundedShortTermCandidates({ workspaceDir, agentId })
? await removeGroundedShortTermCandidates({ workspaceDir })
: null;
if (opts.json) {
defaultRuntime.writeJson({
@@ -1953,20 +1934,18 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
const written = await writeBackfillDiaryEntries({
workspaceDir,
agentId,
entries,
timezone: remConfig.timezone,
});
let stagedShortTermEntries = 0;
let replacedShortTermEntries = 0;
if (opts.stageShortTerm) {
const cleared = await removeGroundedShortTermCandidates({ workspaceDir, agentId });
const cleared = await removeGroundedShortTermCandidates({ workspaceDir });
replacedShortTermEntries = cleared.removed;
const shortTermSeedItems = collectGroundedShortTermSeedItems(grounded.files);
if (shortTermSeedItems.length > 0) {
await recordGroundedShortTermCandidates({
workspaceDir,
agentId,
query: "__dreaming_grounded_backfill__",
items: shortTermSeedItems,
dedupeByQueryPerDay: true,

View File

@@ -117,7 +117,7 @@ afterAll(async () => {
});
describe("memory cli", () => {
const inactiveMemorySecretDiagnostic = "memory.search.remote.apiKey inactive"; // pragma: allowlist secret
const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret
function firstMockCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown {
const call = mock.mock.calls[0];
@@ -426,52 +426,6 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("reports dreaming status for each agent", async () => {
getRuntimeConfig.mockReturnValue({
memory: {
extensions: {
"memory-core": {
dreaming: { enabled: true, frequency: "0 9 * * *" },
},
},
},
agents: {
defaults: {
workspace: "/tmp/main",
},
list: [
{ id: "main", default: true, workspace: "/tmp/main" },
{
id: "writer",
workspace: "/tmp/writer",
memory: {
extensions: {
"memory-core": {
dreaming: { enabled: false },
},
},
},
},
],
},
});
const close = vi.fn(async () => {});
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
},
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Dreaming: 0 9 * * *");
expectLogged(log, "Dreaming: off");
expect(close).toHaveBeenCalledTimes(2);
});
it("prints index identity mismatch reasons", async () => {
const close = vi.fn(async () => {});
mockManager({
@@ -534,10 +488,12 @@ describe("memory cli", () => {
it("resolves configured memory SecretRefs through gateway snapshot", async () => {
const config = {
memory: {
search: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
},
},
},
@@ -560,8 +516,8 @@ describe("memory cli", () => {
expect(secretRefsCall.commandName).toBe("memory status");
expect(secretRefsCall.targetIds).toStrictEqual(
new Set([
"memory.search.remote.apiKey",
"agents.list[].memory.search.remote.apiKey",
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
]),
);
});
@@ -761,7 +717,6 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router vlan",
results: [
{
@@ -793,41 +748,33 @@ describe("memory cli", () => {
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
await shortTermTesting.writeRawRecallStore(
workspaceDir,
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "QMD router cache note",
recallCount: 1,
totalScore: 0.8,
maxScore: 0.8,
firstRecalledAt: "2026-04-04T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a"],
},
bad: {
path: "",
},
await shortTermTesting.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "QMD router cache note",
recallCount: 1,
totalScore: 0.8,
maxScore: 0.8,
firstRecalledAt: "2026-04-04T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a"],
},
bad: {
path: "",
},
},
"main",
);
await shortTermTesting.writeShortTermLock(
workspaceDir,
{
owner: "999999:0",
acquiredAt: Date.now() - 120_000,
},
"main",
);
});
await shortTermTesting.writeShortTermLock(workspaceDir, {
owner: "999999:0",
acquiredAt: Date.now() - 120_000,
});
const close = vi.fn(async () => {});
mockManager({
@@ -840,11 +787,7 @@ describe("memory cli", () => {
await runMemoryCli(["status", "--fix"]);
expectLogged(log, "Repair: rewrote store");
const audit = await shortTermTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
const audit = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
const repaired = audit as {
entries: Record<string, { conceptTags?: string[] }>;
};
@@ -855,19 +798,15 @@ describe("memory cli", () => {
it("shows the fix hint only before --fix has been run", async () => {
await withTempWorkspace(async (workspaceDir) => {
await shortTermTesting.writeRawRecallStore(
workspaceDir,
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
bad: {
path: "",
},
await shortTermTesting.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
bad: {
path: "",
},
},
"main",
);
});
const close = vi.fn(async () => {});
mockManager({
@@ -893,14 +832,7 @@ describe("memory cli", () => {
it("repairs contaminated dreaming artifacts during status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const sessionCorpusDir = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"session-corpus",
);
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(
path.join(sessionCorpusDir, "2026-04-11.txt"),
@@ -910,16 +842,12 @@ describe("memory cli", () => {
].join("\n"),
"utf-8",
);
const dreamsPath = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"DREAMS.md",
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
"utf-8",
);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
await fs.writeFile(dreamsPath, "# Dream Diary\n", "utf-8");
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "# Dream Diary\n", "utf-8");
const close = vi.fn(async () => {});
mockManager({
@@ -934,7 +862,12 @@ describe("memory cli", () => {
expectLogged(log, "Dream repair: archived session corpus");
expectLogged(log, "Dream archive:");
await expectPathMissing(sessionCorpusDir);
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary");
await expectPathMissing(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
);
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"# Dream Diary",
);
expect(close).toHaveBeenCalled();
});
});
@@ -1354,7 +1287,6 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router notes",
results: [
{
@@ -1399,7 +1331,6 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router notes",
results: [
{
@@ -1440,7 +1371,6 @@ describe("memory cli", () => {
);
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "weather plans",
nowMs,
results: [
@@ -1628,10 +1558,7 @@ describe("memory cli", () => {
await runMemoryCli(["rem-backfill", "--path", historyPath]);
const dreams = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf-8",
);
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(dreams).toContain("openclaw:dreaming:backfill-entry");
expect(dreams).toContain(`source=${historyPath}`);
expect(dreams).toContain("January 1, 2025");
@@ -1675,10 +1602,7 @@ describe("memory cli", () => {
expect(
errors.mock.calls.some((call) => String(call[0]).includes("found no YYYY-MM-DD.md files")),
).toBe(false);
const dreams = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf-8",
);
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(dreams).toContain(`source=${sluggedPath}`);
expect(dreams).toContain(`source=${secondSluggedPath}`);
expect(dreams).toContain("Happy Together");
@@ -1727,7 +1651,7 @@ describe("memory cli", () => {
await runMemoryCli(["rem-backfill", "--path", historyPath, "--stage-short-term"]);
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
const entries = await readShortTermRecallEntries({ workspaceDir });
expect(entries).toHaveLength(1);
expect(entries[0]?.snippet).toContain("Happy Together");
expect(entries[0]?.groundedCount).toBe(3);
@@ -1764,7 +1688,7 @@ describe("memory cli", () => {
});
await runMemoryCli(["rem-backfill", "--rollback-short-term"]);
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
const entries = await readShortTermRecallEntries({ workspaceDir });
expect(entries).toHaveLength(0);
expect(close).toHaveBeenCalled();
});
@@ -1972,15 +1896,7 @@ describe("memory cli", () => {
it("rolls back grounded rem backfill entries from DREAMS.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
const dreamsPath = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"DREAMS.md",
);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(
dreamsPath,
[
@@ -2043,7 +1959,6 @@ describe("memory cli", () => {
]);
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "network setup",
results: [
{
@@ -2092,7 +2007,6 @@ describe("memory cli", () => {
const nowMs = Date.now();
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router vlan",
nowMs: nowMs - 2 * dayMs,
results: [
@@ -2108,7 +2022,6 @@ describe("memory cli", () => {
});
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "glacier backup",
nowMs: nowMs - dayMs,
results: [
@@ -2171,11 +2084,13 @@ describe("memory cli", () => {
},
]);
getRuntimeConfig.mockReturnValue({
memory: {
extensions: {
plugins: {
entries: {
"memory-core": {
dreaming: {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
@@ -2190,7 +2105,7 @@ describe("memory cli", () => {
await runMemoryCli(["search", "glacier", "--json"]);
const entries = await waitFor(async () => {
const recalled = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
const recalled = await readShortTermRecallEntries({ workspaceDir });
expect(recalled).toHaveLength(1);
return recalled;
});
@@ -2274,7 +2189,7 @@ describe("memory cli", () => {
}
expect(payload.results).toHaveLength(1);
expect(payload.results[0]?.path).toBe("memory/2026-04-03.md");
expect(await readShortTermRecallEntries({ workspaceDir, agentId: "main" })).toHaveLength(0);
expect(await readShortTermRecallEntries({ workspaceDir })).toHaveLength(0);
expect(close).toHaveBeenCalled();
});
});

View File

@@ -1,91 +0,0 @@
// Memory Core tests cover canonical dreaming config migration.
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { migrateMemoryCoreLegacyConfig } from "./config-compat.js";
describe("memory-core config compatibility", () => {
it("moves dreaming from the selected non-core memory plugin", () => {
const migration = migrateMemoryCoreLegacyConfig({
plugins: {
slots: {
memory: "memory-lancedb",
},
entries: {
"memory-lancedb": {
config: {
embedding: {
model: "text-embedding-3-small",
},
dreaming: {
enabled: true,
frequency: "0 */6 * * *",
},
},
},
},
},
} as OpenClawConfig);
expect(migration?.changes).toEqual([
"Moved plugins.entries.memory-lancedb.config.dreaming → memory.extensions.memory-core.dreaming.",
]);
expect(migration?.config.memory?.extensions?.["memory-core"]).toEqual({
dreaming: {
enabled: true,
frequency: "0 */6 * * *",
},
});
expect(migration?.config.plugins?.entries?.["memory-lancedb"]?.config).toEqual({
embedding: {
model: "text-embedding-3-small",
},
});
});
it("preserves explicit canonical dreaming settings", () => {
const migration = migrateMemoryCoreLegacyConfig({
memory: {
extensions: {
"memory-core": {
dreaming: {
enabled: false,
},
},
},
},
plugins: {
slots: {
memory: "memory-lancedb",
},
entries: {
"memory-lancedb": {
config: {
dreaming: {
enabled: true,
frequency: "0 */6 * * *",
},
},
},
},
},
} as OpenClawConfig);
expect(migration?.config.memory?.extensions?.["memory-core"]).toEqual({
dreaming: {
enabled: false,
frequency: "0 */6 * * *",
},
});
});
it("declares the selected memory slot as a compatibility migration trigger", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as {
configContracts?: { compatibilityMigrationPaths?: string[] };
};
expect(manifest.configContracts?.compatibilityMigrationPaths).toContain("plugins.slots.memory");
});
});

View File

@@ -1,159 +0,0 @@
// Memory Core compatibility migration moves global dreaming settings into agent memory config.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>): void {
for (const [key, value] of Object.entries(source)) {
const existing = asRecord(target[key]);
const nested = asRecord(value);
if (existing && nested) {
mergeMissing(existing, nested);
continue;
}
if (!Object.hasOwn(target, key)) {
target[key] = value;
}
}
}
function ensureMemoryCoreExtension(memory: Record<string, unknown>): Record<string, unknown> {
const extensions = asRecord(memory.extensions) ?? {};
memory.extensions = extensions;
const core = asRecord(extensions["memory-core"]) ?? {};
extensions["memory-core"] = core;
return core;
}
function ensureRootMemory(config: OpenClawConfig): Record<string, unknown> {
const memory = asRecord(config.memory) ?? {};
config.memory = memory;
return memory;
}
function ensureAgentMemory(agent: Record<string, unknown>): Record<string, unknown> {
const memory = asRecord(agent.memory) ?? {};
agent.memory = memory;
return memory;
}
function normalizePluginId(value: string): string {
return value.trim().toLowerCase();
}
function resolveSelectedMemoryPluginConfig(
config: OpenClawConfig,
): { pluginId: string; config: Record<string, unknown> } | undefined {
const selectedId = config.plugins?.slots?.memory;
if (
typeof selectedId !== "string" ||
!selectedId.trim() ||
normalizePluginId(selectedId) === "none" ||
normalizePluginId(selectedId) === "memory-core"
) {
return undefined;
}
const entries = asRecord(config.plugins?.entries);
for (const [pluginId, rawEntry] of Object.entries(entries ?? {})) {
if (normalizePluginId(pluginId) !== normalizePluginId(selectedId)) {
continue;
}
const pluginConfig = asRecord(asRecord(rawEntry)?.config);
if (pluginConfig) {
return { pluginId, config: pluginConfig };
}
}
return undefined;
}
/** Moves legacy global dreaming config and agent dreaming flags to canonical memory extensions. */
export function migrateMemoryCoreLegacyConfig(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const legacyPluginConfig = asRecord(config.plugins?.entries?.["memory-core"]?.config);
const selectedMemoryPlugin = resolveSelectedMemoryPluginConfig(config);
const selectedPluginDreaming = asRecord(selectedMemoryPlugin?.config.dreaming);
const legacyAgentDreaming = (config.agents?.list ?? []).some(
(agent) => asRecord(agent)?.dreaming !== undefined,
);
if (!legacyPluginConfig && !selectedPluginDreaming && !legacyAgentDreaming) {
return null;
}
const next = structuredClone(config);
const changes: string[] = [];
if (legacyPluginConfig) {
const plugins = asRecord(next.plugins) ?? {};
next.plugins = plugins;
const entries = asRecord(plugins.entries) ?? {};
plugins.entries = entries;
const entry = asRecord(entries["memory-core"]) ?? {};
entries["memory-core"] = entry;
const pluginConfig = asRecord(entry.config) ?? {};
const core = ensureMemoryCoreExtension(ensureRootMemory(next));
if (Object.keys(core).length > 0) {
mergeMissing(core, pluginConfig);
changes.push(
"Merged plugins.entries.memory-core.config → memory.extensions.memory-core (kept explicit memory settings).",
);
} else {
Object.assign(core, pluginConfig);
changes.push(
"Moved plugins.entries.memory-core.config → memory.extensions.memory-core.",
);
}
delete entry.config;
}
if (selectedPluginDreaming && selectedMemoryPlugin) {
const selectedPluginConfig = resolveSelectedMemoryPluginConfig(next)?.config;
if (!selectedPluginConfig) {
throw new Error(
`Cannot migrate dreaming config: missing selected memory plugin "${selectedMemoryPlugin.pluginId}".`,
);
}
const core = ensureMemoryCoreExtension(ensureRootMemory(next));
const existingDreaming = asRecord(core.dreaming);
if (existingDreaming) {
mergeMissing(existingDreaming, selectedPluginDreaming);
changes.push(
`Merged plugins.entries.${selectedMemoryPlugin.pluginId}.config.dreaming → memory.extensions.memory-core.dreaming (kept explicit core dreaming settings).`,
);
} else {
core.dreaming = selectedPluginDreaming;
changes.push(
`Moved plugins.entries.${selectedMemoryPlugin.pluginId}.config.dreaming → memory.extensions.memory-core.dreaming.`,
);
}
delete selectedPluginConfig.dreaming;
}
const agents = asRecord(next.agents);
if (Array.isArray(agents?.list)) {
for (const [index, rawAgent] of agents.list.entries()) {
const agent = asRecord(rawAgent);
const dreaming = asRecord(agent?.dreaming);
if (!agent || !dreaming) {
continue;
}
const core = ensureMemoryCoreExtension(ensureAgentMemory(agent));
const existingDreaming = asRecord(core.dreaming);
if (existingDreaming) {
mergeMissing(existingDreaming, dreaming);
} else {
core.dreaming = dreaming;
}
delete agent.dreaming;
changes.push(
`Moved agents.list.${index}.dreaming → agents.list.${index}.memory.extensions.memory-core.dreaming.`,
);
}
}
return { config: next, changes };
}

View File

@@ -16,21 +16,9 @@ function asRecord(value: unknown): Record<string, unknown> | null {
}
function resolveStoredDreaming(config: OpenClawConfig): Record<string, unknown> {
const memory = asRecord(config.memory);
const extensions = asRecord(memory?.extensions);
const memoryCore = asRecord(extensions?.["memory-core"]);
return asRecord(memoryCore?.dreaming) ?? {};
}
function resolveAgentStoredDreaming(
config: OpenClawConfig,
agentId: string,
): Record<string, unknown> {
const agent = config.agents?.list?.find((entry) => entry.id === agentId);
const memory = asRecord(agent?.memory);
const extensions = asRecord(memory?.extensions);
const memoryCore = asRecord(extensions?.["memory-core"]);
return asRecord(memoryCore?.dreaming) ?? {};
const entry = asRecord(config.plugins?.entries?.["memory-core"]);
const pluginConfig = asRecord(entry?.config);
return asRecord(pluginConfig?.dreaming) ?? {};
}
function createHarness(initialConfig: OpenClawConfig = {}) {
@@ -87,7 +75,7 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
function createCommandContext(
args?: string,
overrides?: Partial<Pick<PluginCommandContext, "agentId" | "gatewayClientScopes" | "sessionKey">>,
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
): PluginCommandContext {
return {
channel: "webchat",
@@ -95,9 +83,7 @@ function createCommandContext(
commandBody: args ? `/dreaming ${args}` : "/dreaming",
args,
config: {},
agentId: overrides?.agentId,
gatewayClientScopes: overrides?.gatewayClientScopes,
sessionKey: overrides?.sessionKey,
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
@@ -124,18 +110,20 @@ describe("memory-core /dreaming command", () => {
);
});
it("persists default-agent enablement under memory.extensions.memory-core", async () => {
it("persists global enablement under plugins.entries.memory-core.config.dreaming.enabled", async () => {
const { command, runtime, getRuntimeConfig } = createHarness({
memory: {
extensions: {
plugins: {
entries: {
"memory-core": {
dreaming: {
phases: {
deep: {
minScore: 0.9,
config: {
dreaming: {
phases: {
deep: {
minScore: 0.9,
},
},
frequency: "0 */6 * * *",
},
frequency: "0 */6 * * *",
},
},
},
@@ -151,82 +139,6 @@ describe("memory-core /dreaming command", () => {
expect(result.text).toContain("Dreaming disabled.");
});
it("uses the host-routed agent when the session key does not encode one", async () => {
const { command, getRuntimeConfig } = createHarness({
memory: {
extensions: {
"memory-core": {
dreaming: { enabled: true },
},
},
},
agents: {
list: [{ id: "research" }],
},
});
await command.handler(
createCommandContext("off", {
agentId: "research",
sessionKey: "plugin-owned:command-session",
}),
);
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
expect(resolveAgentStoredDreaming(getRuntimeConfig(), "research").enabled).toBe(false);
});
it("matches host-routed canonical agent ids to raw configured ids", async () => {
const { command, getRuntimeConfig } = createHarness({
memory: {
extensions: {
"memory-core": {
dreaming: { enabled: true },
},
},
},
agents: {
list: [{ id: "Team Ops" }],
},
});
await command.handler(
createCommandContext("off", {
agentId: "team-ops",
sessionKey: "plugin-owned:command-session",
}),
);
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
expect(resolveAgentStoredDreaming(getRuntimeConfig(), "Team Ops").enabled).toBe(false);
});
it("rejects unknown routed agents without changing inherited defaults", async () => {
const { command, runtime, getRuntimeConfig } = createHarness({
memory: {
extensions: {
"memory-core": {
dreaming: { enabled: true },
},
},
},
agents: {
list: [{ id: "research" }],
},
});
const result = await command.handler(
createCommandContext("off", {
agentId: "writer",
sessionKey: "plugin-owned:command-session",
}),
);
expect(result.text).toContain('cannot be changed for unknown agent "writer"');
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
});
it("blocks unscoped gateway callers from persisting dreaming config", async () => {
const { command, runtime } = createHarness();
@@ -269,11 +181,13 @@ describe("memory-core /dreaming command", () => {
it("returns status without mutating config", async () => {
const { command, runtime } = createHarness({
memory: {
extensions: {
plugins: {
entries: {
"memory-core": {
dreaming: {
frequency: "15 */8 * * *",
config: {
dreaming: {
frequency: "15 */8 * * *",
},
},
},
},
@@ -281,7 +195,6 @@ describe("memory-core /dreaming command", () => {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
});

View File

@@ -1,53 +1,38 @@
// Memory Core plugin module implements dreaming command behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime";
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
function updateDreamingEnabledInConfig(
cfg: OpenClawConfig,
agentId: string,
enabled: boolean,
): OpenClawConfig | null {
const agentList = [...(cfg.agents?.list ?? [])];
const agentIndex = agentList.findIndex(
(entry) => normalizeAgentId(entry?.id) === normalizeAgentId(agentId),
);
const isDefaultAgent = normalizeAgentId(agentId) === normalizeAgentId(resolveDefaultAgentId(cfg));
if (agentIndex < 0 && (agentList.length > 0 || !isDefaultAgent)) {
return null;
}
const existingAgentMemory =
agentIndex >= 0 ? (agentList[agentIndex]?.memory ?? {}) : (cfg.memory ?? {});
const extensions = { ...existingAgentMemory.extensions };
const memoryCore = asRecord(extensions["memory-core"]) ?? {};
const dreaming = asRecord(memoryCore.dreaming) ?? {};
extensions["memory-core"] = {
...memoryCore,
dreaming: {
...dreaming,
enabled,
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
}
function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
const entries = { ...cfg.plugins?.entries };
const existingEntry = asRecord(entries["memory-core"]) ?? {};
const existingConfig = asRecord(existingEntry.config) ?? {};
const existingSleep = asRecord(existingConfig.dreaming) ?? {};
entries["memory-core"] = {
...existingEntry,
config: {
...existingConfig,
dreaming: {
...existingSleep,
enabled,
},
},
};
const memory = { ...existingAgentMemory, extensions };
if (agentIndex >= 0) {
agentList[agentIndex] = { ...agentList[agentIndex], memory };
return {
...cfg,
agents: {
...cfg.agents,
list: agentList,
},
};
}
return {
...cfg,
memory,
plugins: {
...cfg.plugins,
entries,
},
};
}
@@ -63,12 +48,13 @@ function formatPhaseGuide(): string {
].join("\n");
}
function formatStatus(cfg: OpenClawConfig, agentId: string): string {
function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const dreaming = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
agentId,
});
const deep = resolveShortTermPromotionDreamingConfig({ cfg, agentId });
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
return [
@@ -102,18 +88,13 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
.filter(Boolean)
.map((token) => normalizeLowercaseStringOrEmpty(token));
const currentConfig = api.runtime.config.current() as OpenClawConfig;
const agentId = normalizeAgentId(
ctx.agentId ??
parseAgentSessionKey(ctx.sessionKey)?.agentId ??
resolveDefaultAgentId(currentConfig),
);
if (!firstToken || firstToken === "help" || firstToken === "options" || firstToken === "phases") {
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
return { text: formatUsage(formatStatus(currentConfig)) };
}
if (firstToken === "status") {
return { text: formatStatus(currentConfig, agentId) };
return { text: formatStatus(currentConfig) };
}
if (firstToken === "on" || firstToken === "off") {
@@ -121,16 +102,10 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." };
}
const enabled = firstToken === "on";
if (!updateDreamingEnabledInConfig(currentConfig, agentId, enabled)) {
return { text: `Dreaming config cannot be changed for unknown agent "${agentId}".` };
}
const committed = await api.runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const nextConfig = updateDreamingEnabledInConfig(draft, agentId, enabled);
if (!nextConfig) {
throw new Error(`Dreaming config target disappeared: ${agentId}`);
}
const nextConfig = updateDreamingEnabledInConfig(draft, enabled);
Object.assign(draft, nextConfig);
},
});
@@ -138,12 +113,12 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
text: [
`Dreaming ${enabled ? "enabled" : "disabled"}.`,
"",
formatStatus(committed.nextConfig, agentId),
formatStatus(committed.nextConfig),
].join("\n"),
};
}
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
return { text: formatUsage(formatStatus(currentConfig)) };
}
export function registerDreamingCommand(api: OpenClawPluginApi): void {

View File

@@ -5,12 +5,7 @@ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
assertNoSymlinkParents,
readRegularFile,
replaceFileAtomic,
} from "openclaw/plugin-sdk/security-runtime";
import { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const DEEP_START_MARKER = "<!-- openclaw:dreaming:deep:start -->";
@@ -24,17 +19,7 @@ type DreamsFileLockEntry = {
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
export async function resolveDreamsPath(workspaceDir: string, agentId?: string): Promise<string> {
if (agentId?.trim()) {
return path.join(
workspaceDir,
"memory",
".dreams",
"agents",
normalizeAgentId(agentId),
DREAMS_FILENAMES[0],
);
}
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
for (const name of DREAMS_FILENAMES) {
const target = path.join(workspaceDir, name);
try {
@@ -94,66 +79,7 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
}
}
async function assertSafeDreamingArtifactPath(filePath: string): Promise<void> {
const stat = await fs.lstat(filePath).catch((err: unknown) => {
if (extractErrorCode(err) === "ENOENT") {
return null;
}
throw err;
});
if (!stat) {
return;
}
if (stat.isSymbolicLink()) {
throw new Error("Refusing to write symlinked dreaming artifact");
}
if (!stat.isFile()) {
throw new Error("Refusing to write non-file dreaming artifact");
}
}
export async function ensureDreamingArtifactDirectory(params: {
workspaceDir: string;
filePath: string;
}): Promise<void> {
const directoryPath = path.dirname(params.filePath);
await assertNoSymlinkParents({
rootDir: params.workspaceDir,
targetPath: directoryPath,
requireDirectories: true,
});
await fs.mkdir(directoryPath, { recursive: true });
await assertNoSymlinkParents({
rootDir: params.workspaceDir,
targetPath: directoryPath,
allowMissing: false,
requireDirectories: true,
});
}
export async function writeDreamingArtifactFile(params: {
workspaceDir: string;
filePath: string;
content: string;
}): Promise<void> {
await ensureDreamingArtifactDirectory(params);
await assertSafeDreamingArtifactPath(params.filePath);
await replaceFileAtomic({
filePath: params.filePath,
content: params.content,
mode: 0o600,
preserveExistingMode: true,
tempPrefix: `${path.basename(params.filePath)}.dreams`,
throwOnCleanupError: true,
});
}
async function writeDreamsFileAtomic(
workspaceDir: string,
dreamsPath: string,
content: string,
): Promise<void> {
await ensureDreamingArtifactDirectory({ workspaceDir, filePath: dreamsPath });
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
await assertSafeDreamsPath(dreamsPath);
await replaceFileAtomic({
filePath: dreamsPath,
@@ -167,7 +93,6 @@ async function writeDreamsFileAtomic(
export async function updateDreamsFile<T>(params: {
workspaceDir: string;
agentId?: string;
updater: (
existing: string,
dreamsPath: string,
@@ -179,7 +104,8 @@ export async function updateDreamsFile<T>(params: {
shouldWrite?: boolean;
};
}): Promise<T> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir, params.agentId);
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
let lockEntry = dreamsFileLocks.get(dreamsPath);
if (!lockEntry) {
lockEntry = { withLock: createAsyncLock(), refs: 0 };
@@ -188,18 +114,10 @@ export async function updateDreamsFile<T>(params: {
lockEntry.refs += 1;
try {
return await lockEntry.withLock(async () => {
await ensureDreamingArtifactDirectory({
workspaceDir: params.workspaceDir,
filePath: dreamsPath,
});
const existing = await readDreamsFile(dreamsPath);
const { content, result, shouldWrite = true } = await params.updater(existing, dreamsPath);
if (shouldWrite) {
await writeDreamsFileAtomic(
params.workspaceDir,
dreamsPath,
content.endsWith("\n") ? content : `${content}\n`,
);
await writeDreamsFileAtomic(dreamsPath, content.endsWith("\n") ? content : `${content}\n`);
}
return result;
});
@@ -213,13 +131,11 @@ export async function updateDreamsFile<T>(params: {
export async function updateDeepDreamsFile(params: {
workspaceDir: string;
agentId?: string;
bodyLines: string[];
}): Promise<string> {
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing, dreamsPath) => ({
content: replaceManagedMarkdownBlock({
original: existing,

View File

@@ -273,51 +273,4 @@ describe("dreaming markdown storage", () => {
).rejects.toThrow("Refusing to write symlinked DREAMS.md");
await expect(fs.readFile(targetPath, "utf-8")).resolves.toBe("outside\n");
});
it("refuses a symlinked parent for agent dreams files", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
const outsideDir = path.join(workspaceDir, "outside");
await fs.mkdir(outsideDir);
await fs.mkdir(path.join(workspaceDir, "memory"));
await fs.symlink(outsideDir, path.join(workspaceDir, "memory", ".dreams"));
await expect(
writeDeepDreamingReport({
workspaceDir,
agentId: "writer",
bodyLines: ["- Do not escape workspace."],
storage: {
mode: "inline",
separateReports: false,
},
nowMs,
timezone,
}),
).rejects.toThrow("must not traverse symlinked directory");
await expect(fs.access(path.join(outsideDir, "agents"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses a symlinked parent for agent dreaming reports", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
const outsideDir = path.join(workspaceDir, "outside");
await fs.mkdir(outsideDir);
await fs.mkdir(path.join(workspaceDir, "memory"));
await fs.symlink(outsideDir, path.join(workspaceDir, "memory", ".dreams"));
await expect(
writeDailyDreamingPhaseBlock({
workspaceDir,
agentId: "writer",
phase: "light",
bodyLines: ["- Do not escape workspace."],
storage: {
mode: "separate",
separateReports: false,
},
nowMs,
timezone,
}),
).rejects.toThrow("must not traverse symlinked directory");
await expect(fs.access(path.join(outsideDir, "agents"))).rejects.toMatchObject({ code: "ENOENT" });
});
});

View File

@@ -11,12 +11,7 @@ import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
ensureDreamingArtifactDirectory,
updateDeepDreamsFile,
writeDreamingArtifactFile,
} from "./dreaming-dreams-file.js";
import { updateDeepDreamsFile } from "./dreaming-dreams-file.js";
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
@@ -40,24 +35,8 @@ function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
};
}
function resolveDailyMemoryPath(
workspaceDir: string,
epochMs: number,
timezone?: string,
agentId?: string,
): string {
function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
const isoDay = formatMemoryDreamingDay(epochMs, timezone);
if (agentId?.trim()) {
return path.join(
workspaceDir,
"memory",
".dreams",
"agents",
normalizeAgentId(agentId),
"daily",
`${isoDay}.md`,
);
}
return path.join(workspaceDir, "memory", `${isoDay}.md`);
}
@@ -66,21 +45,8 @@ function resolveSeparateReportPath(
phase: MemoryDreamingPhaseName,
epochMs: number,
timezone?: string,
agentId?: string,
): string {
const isoDay = formatMemoryDreamingDay(epochMs, timezone);
if (agentId?.trim()) {
return path.join(
workspaceDir,
"memory",
".dreams",
"agents",
normalizeAgentId(agentId),
"reports",
phase,
`${isoDay}.md`,
);
}
return path.join(workspaceDir, "memory", "dreaming", phase, `${isoDay}.md`);
}
@@ -94,7 +60,6 @@ function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean {
export async function writeDailyDreamingPhaseBlock(params: {
workspaceDir: string;
agentId?: string;
phase: Exclude<MemoryDreamingPhaseName, "deep">;
bodyLines: string[];
nowMs?: number;
@@ -107,16 +72,8 @@ export async function writeDailyDreamingPhaseBlock(params: {
let reportPath: string | undefined;
if (shouldWriteInline(params.storage)) {
inlinePath = resolveDailyMemoryPath(
params.workspaceDir,
nowMs,
params.timezone,
params.agentId,
);
await ensureDreamingArtifactDirectory({
workspaceDir: params.workspaceDir,
filePath: inlinePath,
});
inlinePath = resolveDailyMemoryPath(params.workspaceDir, nowMs, params.timezone);
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
@@ -131,11 +88,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
endMarker: markers.end,
body,
});
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: inlinePath,
content: withTrailingNewline(updated),
});
await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
}
if (shouldWriteSeparate(params.storage)) {
@@ -144,34 +97,26 @@ export async function writeDailyDreamingPhaseBlock(params: {
params.phase,
nowMs,
params.timezone,
params.agentId,
);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const report = [
`# ${params.phase === "light" ? "Light Sleep" : "REM Sleep"}`,
"",
body,
"",
].join("\n");
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: reportPath,
content: report,
});
await fs.writeFile(reportPath, report, "utf-8");
}
await appendMemoryHostEvent(
params.workspaceDir,
{
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
phase: params.phase,
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
},
params.agentId,
);
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
phase: params.phase,
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
});
return {
...(inlinePath ? { inlinePath } : {}),
@@ -181,7 +126,6 @@ export async function writeDailyDreamingPhaseBlock(params: {
export async function writeDeepDreamingReport(params: {
workspaceDir: string;
agentId?: string;
bodyLines: string[];
nowMs?: number;
timezone?: string;
@@ -191,36 +135,22 @@ export async function writeDeepDreamingReport(params: {
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
const inlinePath = await updateDeepDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
bodyLines: params.bodyLines,
});
let reportPath: string | undefined;
if (shouldWriteSeparate(params.storage)) {
reportPath = resolveSeparateReportPath(
params.workspaceDir,
"deep",
nowMs,
params.timezone,
params.agentId,
);
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: reportPath,
content: `# Deep Sleep\n\n${body}\n`,
});
reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
}
await appendMemoryHostEvent(
params.workspaceDir,
{
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
phase: "deep",
inlinePath,
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
},
params.agentId,
);
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
phase: "deep",
inlinePath,
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
});
return reportPath;
}

View File

@@ -162,7 +162,6 @@ function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string
export async function appendFallbackNarrativeEntry(params: {
workspaceDir: string;
agentId?: string;
data: NarrativePhaseData;
nowMs: number;
timezone?: string;
@@ -172,7 +171,6 @@ export async function appendFallbackNarrativeEntry(params: {
try {
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
narrative: buildRequestScopedFallbackNarrative(params.data),
nowMs: params.nowMs,
timezone: params.timezone,
@@ -243,7 +241,6 @@ async function startNarrativeRunOrFallback(params: {
message: string;
data: NarrativePhaseData;
workspaceDir: string;
agentId?: string;
nowMs: number;
timezone?: string;
model?: string;
@@ -267,7 +264,6 @@ async function startNarrativeRunOrFallback(params: {
}
await appendFallbackNarrativeEntry({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
data: params.data,
nowMs: params.nowMs,
timezone: params.timezone,
@@ -283,13 +279,9 @@ async function startNarrativeRunOrFallback(params: {
*/
function buildNarrativeSessionKey(params: {
workspaceDir: string;
agentId?: string;
phase: NarrativePhaseData["phase"];
}): string {
const scope = params.agentId?.trim()
? `${params.workspaceDir}\0${params.agentId.trim()}`
: params.workspaceDir;
const workspaceHash = createHash("sha1").update(scope).digest("hex").slice(0, 12);
const workspaceHash = createHash("sha1").update(params.workspaceDir).digest("hex").slice(0, 12);
return `dreaming-narrative-${params.phase}-${workspaceHash}`;
}
@@ -511,7 +503,6 @@ function isOptionalDiaryContextReadError(err: unknown): boolean {
export async function readRecentDreamDiaryEntries(params: {
workspaceDir: string;
agentId?: string;
limit?: number;
}): Promise<string[]> {
const limit = Math.max(0, Math.floor(params.limit ?? RECENT_DIARY_CONTEXT_LIMIT));
@@ -520,7 +511,7 @@ export async function readRecentDreamDiaryEntries(params: {
}
let existing: string;
try {
const dreamsPath = await resolveDreamsPath(params.workspaceDir, params.agentId);
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
existing = await readDreamsFile(dreamsPath);
} catch (err) {
if (isOptionalDiaryContextReadError(err)) {
@@ -647,7 +638,6 @@ export function buildBackfillDiaryEntry(params: {
export async function writeBackfillDiaryEntries(params: {
workspaceDir: string;
agentId?: string;
entries: Array<{
isoDay: string;
bodyLines: string[];
@@ -657,7 +647,6 @@ export async function writeBackfillDiaryEntries(params: {
}): Promise<{ dreamsPath: string; written: number; replaced: number }> {
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing, dreamsPath) => {
const stripped = stripBackfillDiaryBlocks(existing);
const startIdx = stripped.updated.indexOf(DIARY_START_MARKER);
@@ -692,11 +681,9 @@ export async function writeBackfillDiaryEntries(params: {
export async function removeBackfillDiaryEntries(params: {
workspaceDir: string;
agentId?: string;
}): Promise<{ dreamsPath: string; removed: number }> {
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing, dreamsPath) => {
const stripped = stripBackfillDiaryBlocks(existing);
return {
@@ -713,11 +700,9 @@ export async function removeBackfillDiaryEntries(params: {
export async function dedupeDreamDiaryEntries(params: {
workspaceDir: string;
agentId?: string;
}): Promise<{ dreamsPath: string; removed: number; kept: number }> {
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing, dreamsPath) => {
const ensured = ensureDiarySection(existing);
const startIdx = ensured.indexOf(DIARY_START_MARKER);
@@ -762,7 +747,6 @@ export function buildDiaryEntry(narrative: string, dateStr: string): string {
export async function appendNarrativeEntry(params: {
workspaceDir: string;
agentId?: string;
narrative: string;
nowMs: number;
timezone?: string;
@@ -771,7 +755,6 @@ export async function appendNarrativeEntry(params: {
const entry = buildDiaryEntry(params.narrative, dateStr);
return await updateDreamsFile({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
updater: (existing, dreamsPath) => {
let updated: string;
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
@@ -1007,7 +990,6 @@ async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
export async function generateAndAppendDreamNarrative(params: {
subagent: SubagentSurface;
workspaceDir: string;
agentId?: string;
data: NarrativePhaseData;
nowMs?: number;
timezone?: string;
@@ -1022,7 +1004,6 @@ export async function generateAndAppendDreamNarrative(params: {
const sessionKey = buildNarrativeSessionKey({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
phase: params.data.phase,
});
const message = buildNarrativePrompt(params.data);
@@ -1055,7 +1036,6 @@ export async function generateAndAppendDreamNarrative(params: {
message,
data: params.data,
workspaceDir: params.workspaceDir,
agentId: params.agentId,
nowMs,
timezone: params.timezone,
model: attemptModel,
@@ -1098,7 +1078,6 @@ export async function generateAndAppendDreamNarrative(params: {
);
await appendFallbackNarrativeEntry({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
data: params.data,
nowMs,
timezone: params.timezone,
@@ -1134,7 +1113,6 @@ export async function generateAndAppendDreamNarrative(params: {
);
await appendFallbackNarrativeEntry({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
data: params.data,
nowMs,
timezone: params.timezone,
@@ -1146,7 +1124,6 @@ export async function generateAndAppendDreamNarrative(params: {
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
narrative,
nowMs,
timezone: params.timezone,

View File

@@ -20,7 +20,6 @@ import {
} from "./dreaming-phases.js";
import { previewRemHarness } from "./rem-harness.js";
import {
readShortTermRecallEntries,
rankShortTermPromotionCandidates,
recordShortTermRecalls,
testing as shortTermTesting,
@@ -118,33 +117,6 @@ function requireFirstIngestionEntry(sessionIngestion: {
return firstEntry;
}
function normalizeDreamingTestConfig(config: OpenClawConfig): OpenClawConfig {
const canonical = resolveMemoryCorePluginConfig(config);
if (canonical) {
return config;
}
const legacy = config.plugins?.entries?.["memory-core"]?.config;
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) {
return config;
}
return {
...config,
memory: {
...config.memory,
extensions: {
...config.memory?.extensions,
"memory-core": legacy,
},
},
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
},
},
};
}
function createHarness(
config: OpenClawConfig,
workspaceDir?: string,
@@ -155,39 +127,35 @@ function createHarness(
warn: vi.fn(),
error: vi.fn(),
};
const canonicalConfig = normalizeDreamingTestConfig(config);
const resolvedConfig = workspaceDir
? {
...canonicalConfig,
...config,
agents: {
...canonicalConfig.agents,
...config.agents,
defaults: {
...canonicalConfig.agents?.defaults,
...config.agents?.defaults,
workspace: workspaceDir,
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
},
},
}
: {
...canonicalConfig,
...config,
agents: {
...canonicalConfig.agents,
...config.agents,
defaults: {
...canonicalConfig.agents?.defaults,
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
...config.agents?.defaults,
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
},
},
};
const resolvedPluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const beforeAgentReply = async (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => {
const light = resolveMemoryLightDreamingConfig({
pluginConfig: resolvedPluginConfig,
cfg: resolvedConfig,
});
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: resolvedConfig });
const lightResult = await testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
@@ -202,10 +170,7 @@ function createHarness(
if (lightResult) {
return lightResult;
}
const rem = resolveMemoryRemDreamingConfig({
pluginConfig: resolvedPluginConfig,
cfg: resolvedConfig,
});
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: resolvedConfig });
return await testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
@@ -358,12 +323,11 @@ describe("memory-core dreaming phases", () => {
const nowMs = Date.parse("2026-04-05T10:05:00.000Z");
const workspaceHash = createHash("sha1").update(workspaceDir).digest("hex").slice(0, 12);
const expectedSessionKey = `dreaming-narrative-light-${workspaceHash}`;
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
await runDreamingSweepPhases({
workspaceDir,
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
logger,
subagent,
nowMs,
@@ -425,13 +389,12 @@ describe("memory-core dreaming phases", () => {
warn: vi.fn(),
error: vi.fn(),
};
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
await expect(
runDreamingSweepPhases({
workspaceDir,
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
logger,
subagent,
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
@@ -596,12 +559,11 @@ describe("memory-core dreaming phases", () => {
warn: vi.fn(),
error: vi.fn(),
};
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
await runDreamingSweepPhases({
workspaceDir,
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
logger,
subagent,
nowMs,
@@ -1086,168 +1048,6 @@ describe("memory-core dreaming phases", () => {
expectIncludesSubstring(snippets, "Set retention to 365 days.");
});
it("limits an agent-scoped sweep to that agent's sessions in a shared workspace", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const researchSessionsDir = resolveSessionTranscriptsDirForAgent("research");
const writerSessionsDir = resolveSessionTranscriptsDirForAgent("writer");
await fs.mkdir(researchSessionsDir, { recursive: true });
await fs.mkdir(writerSessionsDir, { recursive: true });
await fs.writeFile(
path.join(researchSessionsDir, "research-session.jsonl"),
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:01:00.000Z",
content: [{ type: "text", text: "Research session stays scoped to research." }],
},
}),
].join("\n") + "\n",
"utf-8",
);
await fs.writeFile(
path.join(writerSessionsDir, "writer-session.jsonl"),
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:02:00.000Z",
content: [{ type: "text", text: "Writer session must not enter research dreams." }],
},
}),
].join("\n") + "\n",
"utf-8",
);
const config: OpenClawConfig = {
memory: {
extensions: {
"memory-core": {
dreaming: {
enabled: true,
timezone: "UTC",
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
rem: {
enabled: false,
limit: 0,
},
},
},
},
},
},
agents: {
defaults: {
workspace: workspaceDir,
userTimezone: "UTC",
},
list: [
{ id: "research", workspace: workspaceDir },
{ id: "writer", workspace: workspaceDir },
],
},
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
try {
await runDreamingSweepPhases({
workspaceDir,
cfg: config,
agentId: "research",
pluginConfig: resolveMemoryCorePluginConfig(config, "research"),
logger,
nowMs: Date.parse("2026-04-05T19:00:00.000Z"),
});
await runDreamingSweepPhases({
workspaceDir,
cfg: config,
agentId: "writer",
pluginConfig: resolveMemoryCorePluginConfig(config, "writer"),
logger,
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
});
} finally {
vi.unstubAllEnvs();
}
const researchCorpus = await fs.readFile(
path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"research",
"session-corpus",
"2026-04-05.txt",
),
"utf-8",
);
const writerCorpus = await fs.readFile(
path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"writer",
"session-corpus",
"2026-04-05.txt",
),
"utf-8",
);
expect(researchCorpus).toContain("Research session stays scoped to research.");
expect(researchCorpus).not.toContain("Writer session must not enter research dreams.");
expect(writerCorpus).toContain("Writer session must not enter research dreams.");
expect(writerCorpus).not.toContain("Research session stays scoped to research.");
const researchIngestion = await testing.readSessionIngestionState(workspaceDir, "research");
expect(Object.keys(researchIngestion.files)).toEqual([
"research:sessions/research/research-session.jsonl",
]);
const writerIngestion = await testing.readSessionIngestionState(workspaceDir, "writer");
expect(Object.keys(writerIngestion.files)).toEqual([
"writer:sessions/writer/writer-session.jsonl",
]);
const [researchRecalls, writerRecalls] = await Promise.all([
readShortTermRecallEntries({
workspaceDir,
agentId: "research",
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
}),
readShortTermRecallEntries({
workspaceDir,
agentId: "writer",
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
}),
]);
expectIncludesSubstring(
researchRecalls.map((entry) => entry.snippet),
"Research session stays scoped to research.",
);
expectNotIncludesSubstring(
researchRecalls.map((entry) => entry.snippet),
"Writer session must not enter research dreams.",
);
expectIncludesSubstring(
writerRecalls.map((entry) => entry.snippet),
"Writer session must not enter research dreams.",
);
expectNotIncludesSubstring(
writerRecalls.map((entry) => entry.snippet),
"Research session stays scoped to research.",
);
});
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagentWorkspaceDir = await createDreamingWorkspace();
@@ -1293,10 +1093,7 @@ describe("memory-core dreaming phases", () => {
defaults: {
workspace: workspaceDir,
},
list: [
{ id: "main", workspace: workspaceDir },
{ id: "agi-ceo", workspace: subagentWorkspaceDir },
],
list: [{ id: "agi-ceo", workspace: subagentWorkspaceDir }],
},
plugins: {
entries: {
@@ -2472,14 +2269,12 @@ describe("memory-core dreaming phases", () => {
const { beforeAgentReply } = createHarness(
{
memory: {
search: {
enabled: false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
enabled: false,
},
},
},
plugins: {

View File

@@ -19,7 +19,6 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
@@ -104,6 +103,7 @@ export const SESSION_INGESTION_STATE_RELATIVE_PATH = path.join(
".dreams",
"session-ingestion.json",
);
const SESSION_CORPUS_RELATIVE_DIR = path.join("memory", ".dreams", "session-corpus");
const SESSION_INGESTION_SCORE = 0.58;
const SESSION_INGESTION_MAX_SNIPPET_CHARS = 280;
const SESSION_INGESTION_MIN_SNIPPET_CHARS = 12;
@@ -494,14 +494,10 @@ function normalizeMemoryDay(value: unknown): string | undefined {
return MEMORY_DAY_RE.test(day) ? day : undefined;
}
async function readDailyIngestionState(
workspaceDir: string,
agentId?: string,
): Promise<DailyIngestionState> {
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
const entries = await readMemoryCoreWorkspaceEntries<DailyIngestionFileState>({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId,
});
return normalizeDailyIngestionState({
version: 1,
@@ -512,12 +508,10 @@ async function readDailyIngestionState(
async function writeDailyIngestionState(
workspaceDir: string,
state: DailyIngestionState,
agentId?: string,
): Promise<void> {
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
});
}
@@ -603,20 +597,15 @@ export function normalizeSessionIngestionState(raw: unknown): SessionIngestionSt
return { version: 3, files, seenMessages };
}
async function readSessionIngestionState(
workspaceDir: string,
agentId?: string,
): Promise<SessionIngestionState> {
async function readSessionIngestionState(workspaceDir: string): Promise<SessionIngestionState> {
const [fileEntries, seenChunks] = await Promise.all([
readMemoryCoreWorkspaceEntries<SessionIngestionFileState>({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
agentId,
}),
readMemoryCoreWorkspaceEntries<{ scope: string; index: number; hashes: string[] }>({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
agentId,
}),
]);
const seenMessages: Record<string, string[]> = {};
@@ -645,7 +634,6 @@ async function readSessionIngestionState(
async function writeSessionIngestionState(
workspaceDir: string,
state: SessionIngestionState,
agentId?: string,
): Promise<void> {
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
Array.from({ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) }, (_, index) => {
@@ -663,13 +651,11 @@ async function writeSessionIngestionState(
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
agentId,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
agentId,
entries: seenEntries,
}),
]);
@@ -780,18 +766,18 @@ function resolveSessionAgentsForWorkspace(params: {
async function appendSessionCorpusLines(params: {
workspaceDir: string;
agentId?: string;
day: string;
lines: SessionIngestionMessage[];
}): Promise<MemorySearchResult[]> {
if (params.lines.length === 0) {
return [];
}
const relativeDir = params.agentId
? path.posix.join("memory", ".dreams", "agents", params.agentId, "session-corpus")
: path.posix.join("memory", ".dreams", "session-corpus");
const relativePath = path.posix.join(relativeDir, `${params.day}.txt`);
const absolutePath = path.join(params.workspaceDir, ...relativePath.split("/"));
const relativePath = path.posix.join("memory", ".dreams", "session-corpus", `${params.day}.txt`);
const absolutePath = path.join(
params.workspaceDir,
SESSION_CORPUS_RELATIVE_DIR,
`${params.day}.txt`,
);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
let existing = "";
try {
@@ -830,7 +816,6 @@ async function appendSessionCorpusLines(params: {
async function collectSessionIngestionBatches(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
lookbackDays: number;
nowMs: number;
@@ -846,13 +831,11 @@ async function collectSessionIngestionBatches(params: {
Object.keys(params.state.seenMessages).length > 0,
};
}
const agentIds = params.agentId
? [params.agentId]
: resolveSessionAgentsForWorkspace({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
primaryWorkspaceDir: params.primaryWorkspaceDir,
});
const agentIds = resolveSessionAgentsForWorkspace({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
primaryWorkspaceDir: params.primaryWorkspaceDir,
});
const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays);
const batchByDay = new Map<string, SessionIngestionMessage[]>();
const nextFiles: Record<string, SessionIngestionFileState> = {};
@@ -1117,7 +1100,6 @@ async function collectSessionIngestionBatches(params: {
}
const results = await appendSessionCorpusLines({
workspaceDir: params.workspaceDir,
agentId: params.agentId ? normalizeAgentId(params.agentId) : undefined,
day,
lines,
});
@@ -1136,18 +1118,15 @@ async function collectSessionIngestionBatches(params: {
async function ingestSessionTranscriptSignals(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
lookbackDays: number;
nowMs: number;
timezone?: string;
}): Promise<void> {
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const state = await readSessionIngestionState(params.workspaceDir, agentId);
const state = await readSessionIngestionState(params.workspaceDir);
const collected = await collectSessionIngestionBatches({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.lookbackDays,
nowMs: params.nowMs,
@@ -1158,7 +1137,6 @@ async function ingestSessionTranscriptSignals(params: {
for (const batch of collected.batches) {
await recordShortTermRecalls({
workspaceDir: params.workspaceDir,
agentId,
query: `__dreaming_sessions__:${batch.day}`,
results: batch.results,
signalType: "daily",
@@ -1169,7 +1147,7 @@ async function ingestSessionTranscriptSignals(params: {
});
}
if (collected.changed) {
await writeSessionIngestionState(params.workspaceDir, collected.nextState, agentId);
await writeSessionIngestionState(params.workspaceDir, collected.nextState);
}
}
@@ -1310,14 +1288,12 @@ async function collectDailyIngestionBatches(params: {
async function ingestDailyMemorySignals(params: {
workspaceDir: string;
agentId?: string;
lookbackDays: number;
limit: number;
nowMs: number;
timezone?: string;
}): Promise<void> {
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const state = await readDailyIngestionState(params.workspaceDir, agentId);
const state = await readDailyIngestionState(params.workspaceDir);
const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone);
const collected = await collectDailyIngestionBatches({
workspaceDir: params.workspaceDir,
@@ -1330,7 +1306,6 @@ async function ingestDailyMemorySignals(params: {
for (const batch of collected.batches) {
await recordShortTermRecalls({
workspaceDir: params.workspaceDir,
agentId,
query: `__dreaming_daily__:${batch.day}`,
results: batch.results,
signalType: "daily",
@@ -1341,7 +1316,7 @@ async function ingestDailyMemorySignals(params: {
});
}
if (collected.changed) {
await writeDailyIngestionState(params.workspaceDir, collected.nextState, agentId);
await writeDailyIngestionState(params.workspaceDir, collected.nextState);
}
}
@@ -1350,7 +1325,6 @@ export async function seedHistoricalDailyMemorySignals(params: {
filePaths: string[];
limit: number;
nowMs: number;
agentId?: string;
timezone?: string;
}): Promise<{
importedFileCount: number;
@@ -1444,7 +1418,6 @@ export async function seedHistoricalDailyMemorySignals(params: {
}
await recordShortTermRecalls({
workspaceDir: params.workspaceDir,
agentId: params.agentId ? normalizeAgentId(params.agentId) : undefined,
query: `__dreaming_daily__:${entry.file.day}`,
results,
signalType: "daily",
@@ -1699,7 +1672,6 @@ export function previewRemDreaming(params: {
async function runLightDreaming(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
config: LightDreamingConfig;
logger: Logger;
@@ -1708,10 +1680,8 @@ async function runLightDreaming(params: {
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
agentId,
lookbackDays: params.config.lookbackDays,
limit: params.config.limit,
nowMs,
@@ -1720,7 +1690,6 @@ async function runLightDreaming(params: {
await ingestSessionTranscriptSignals({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.config.lookbackDays,
nowMs,
@@ -1729,11 +1698,7 @@ async function runLightDreaming(params: {
const recentEntries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
agentId,
nowMs,
}),
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
@@ -1750,7 +1715,6 @@ async function runLightDreaming(params: {
);
const recentDiaryEntries = await readRecentDreamDiaryEntries({
workspaceDir: params.workspaceDir,
agentId,
limit: LIGHT_DIARY_HISTORY_LIMIT,
});
const entries = prioritizeLightEntriesByDiaryCoverage(rankedEntries, recentDiaryEntries);
@@ -1758,7 +1722,6 @@ async function runLightDreaming(params: {
const bodyLines = buildLightDreamingBody(capped);
await writeDailyDreamingPhaseBlock({
workspaceDir: params.workspaceDir,
agentId,
phase: "light",
bodyLines,
nowMs,
@@ -1767,7 +1730,6 @@ async function runLightDreaming(params: {
});
await recordDreamingPhaseSignals({
workspaceDir: params.workspaceDir,
agentId,
phase: "light",
keys: capped.map((entry) => entry.key),
nowMs,
@@ -1791,7 +1753,6 @@ async function runLightDreaming(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1802,7 +1763,6 @@ async function runLightDreaming(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1816,7 +1776,6 @@ async function runLightDreaming(params: {
async function runRemDreaming(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
config: RemDreamingConfig;
logger: Logger;
@@ -1825,10 +1784,8 @@ async function runRemDreaming(params: {
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
agentId,
lookbackDays: params.config.lookbackDays,
limit: params.config.limit,
nowMs,
@@ -1837,7 +1794,6 @@ async function runRemDreaming(params: {
await ingestSessionTranscriptSignals({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.config.lookbackDays,
nowMs,
@@ -1846,11 +1802,7 @@ async function runRemDreaming(params: {
const allEntries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
agentId,
nowMs,
}),
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
@@ -1859,7 +1811,6 @@ async function runRemDreaming(params: {
// sequential light→REM pipeline instead of rescanning the full store.
const lightKeys = await readLightStagedKeys({
workspaceDir: params.workspaceDir,
agentId,
nowMs,
});
const stagedEntries =
@@ -1872,7 +1823,6 @@ async function runRemDreaming(params: {
});
await writeDailyDreamingPhaseBlock({
workspaceDir: params.workspaceDir,
agentId,
phase: "rem",
bodyLines: preview.bodyLines,
nowMs,
@@ -1882,14 +1832,12 @@ async function runRemDreaming(params: {
if (stagedEntries.length > 0) {
await recordRemConsideredPhaseSignals({
workspaceDir: params.workspaceDir,
agentId,
keys: stagedEntries.map((entry) => entry.key),
nowMs,
});
}
await recordDreamingPhaseSignals({
workspaceDir: params.workspaceDir,
agentId,
phase: "rem",
keys: preview.candidateKeys,
nowMs,
@@ -1920,7 +1868,6 @@ async function runRemDreaming(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1931,7 +1878,6 @@ async function runRemDreaming(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1946,7 +1892,6 @@ export async function runDreamingSweepPhases(params: {
workspaceDir: string;
pluginConfig?: Record<string, unknown>;
cfg?: DreamingHostConfig;
agentId?: string;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
detachNarratives?: boolean;
@@ -1958,13 +1903,11 @@ export async function runDreamingSweepPhases(params: {
const light = resolveMemoryLightDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg as Parameters<typeof resolveMemoryLightDreamingConfig>[0]["cfg"],
agentId: params.agentId,
});
if (light.enabled && light.limit > 0) {
await runLightDreaming({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId: params.agentId,
config: light,
logger: params.logger,
subagent: params.subagent,
@@ -1976,13 +1919,11 @@ export async function runDreamingSweepPhases(params: {
const rem = resolveMemoryRemDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg as Parameters<typeof resolveMemoryRemDreamingConfig>[0]["cfg"],
agentId: params.agentId,
});
if (rem.enabled && rem.limit > 0) {
await runRemDreaming({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId: params.agentId,
config: rem,
logger: params.logger,
subagent: params.subagent,

View File

@@ -216,102 +216,6 @@ describe("dreaming artifact repair", () => {
).resolves.toEqual([]);
});
it("preserves unscoped legacy ingestion state during an agent-scoped repair", async () => {
const workspaceDir = await createWorkspace();
const agentId = "Team Ops";
const sessionCorpusDir = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"team-ops",
"session-corpus",
);
const legacyIngestionPath = path.join(
workspaceDir,
"memory",
".dreams",
"session-ingestion.json",
);
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
await fs.writeFile(
legacyIngestionPath,
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
"utf-8",
);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
agentId,
entries: [
{
key: "team-ops/session.jsonl",
value: { lastSize: 120, lastMtimeMs: 1_000, lastContentHash: "hash", cursorLine: 42 },
},
],
});
const repair = await repairDreamingArtifacts({ workspaceDir, agentId });
expect(repair.archivedSessionCorpus).toBe(true);
expect(repair.archivedSessionIngestion).toBe(false);
await expect(fs.readFile(legacyIngestionPath, "utf-8")).resolves.toContain('"version": 3');
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
agentId,
}),
).resolves.toEqual([]);
await expect(auditDreamingArtifacts({ workspaceDir })).resolves.toMatchObject({
sessionIngestionExists: true,
});
});
it("refuses to archive agent artifacts through a symlinked parent", async () => {
const workspaceDir = await createWorkspace();
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
const outsideDir = path.join(workspaceDir, "outside");
const sessionCorpusDir = path.join(outsideDir, "agents", "writer", "session-corpus");
await fs.rm(dreamsDir, { recursive: true, force: true });
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
await fs.symlink(outsideDir, dreamsDir);
const repair = await repairDreamingArtifacts({ workspaceDir, agentId: "writer" });
expect(repair.changed).toBe(false);
expect(repair.archivedSessionCorpus).toBe(false);
expect(repair.warnings).toContainEqual(
expect.stringContaining("must not traverse symlinked directory"),
);
await expect(fs.readFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "utf-8")).resolves.toBe(
"corpus\n",
);
});
it("refuses to archive dreaming artifacts through a symlinked archive parent", async () => {
const workspaceDir = await createWorkspace();
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
const outsideDir = path.join(workspaceDir, "outside");
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
await fs.mkdir(outsideDir);
await fs.symlink(outsideDir, path.join(workspaceDir, ".openclaw-repair"));
const repair = await repairDreamingArtifacts({ workspaceDir });
expect(repair.changed).toBe(false);
expect(repair.archivedSessionCorpus).toBe(false);
expect(repair.warnings).toContainEqual(
expect.stringContaining("must not traverse symlinked directory"),
);
await expect(fs.readFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "utf-8")).resolves.toBe(
"corpus\n",
);
});
it("reports ingestion state present from SQLite when legacy JSON is absent", async () => {
const workspaceDir = await createWorkspace();
// Write SQLite ingestion entries but NO legacy session-ingestion.json

View File

@@ -3,14 +3,11 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { assertNoSymlinkParents } from "openclaw/plugin-sdk/security-runtime";
import {
clearMemoryCoreWorkspaceNamespace,
DREAMING_DAILY_INGESTION_NAMESPACE,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
memoryCoreStateReference,
readMemoryCoreWorkspaceEntries,
} from "./dreaming-state.js";
@@ -64,29 +61,7 @@ function requireAbsoluteWorkspaceDir(rawWorkspaceDir: string): string {
return path.resolve(trimmed);
}
async function resolveExistingDreamsPath(
workspaceDir: string,
agentId?: string,
): Promise<string | undefined> {
if (agentId?.trim()) {
const scoped = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
normalizeAgentId(agentId),
DREAMS_FILENAMES[0],
);
try {
await fs.access(scoped);
return scoped;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw err;
}
}
async function resolveExistingDreamsPath(workspaceDir: string): Promise<string | undefined> {
for (const fileName of DREAMS_FILENAMES) {
const candidate = path.join(workspaceDir, fileName);
try {
@@ -142,77 +117,41 @@ async function ensureArchivablePath(targetPath: string): Promise<"file" | "dir"
throw new Error(`Refusing to archive non-file artifact: ${targetPath}`);
}
async function assertSafeRepairArtifactParent(
workspaceDir: string,
targetPath: string,
): Promise<void> {
await assertNoSymlinkParents({
rootDir: workspaceDir,
targetPath: path.dirname(targetPath),
requireDirectories: true,
});
}
async function ensureSafeArchiveDirectory(workspaceDir: string, archiveDir: string): Promise<void> {
await assertNoSymlinkParents({
rootDir: workspaceDir,
targetPath: archiveDir,
requireDirectories: true,
});
await fs.mkdir(archiveDir, { recursive: true });
await assertNoSymlinkParents({
rootDir: workspaceDir,
targetPath: archiveDir,
allowMissing: false,
requireDirectories: true,
});
}
async function moveToArchive(params: {
workspaceDir: string;
targetPath: string;
archiveDir: string;
}): Promise<string | null> {
await assertSafeRepairArtifactParent(params.workspaceDir, params.targetPath);
const kind = await ensureArchivablePath(params.targetPath);
if (!kind) {
return null;
}
await ensureSafeArchiveDirectory(params.workspaceDir, params.archiveDir);
await fs.mkdir(params.archiveDir, { recursive: true });
const baseName = path.basename(params.targetPath);
const destination = path.join(params.archiveDir, `${baseName}.${randomUUID()}`);
await fs.rename(params.targetPath, destination);
return destination;
}
async function clearSessionIngestionState(workspaceDir: string, agentId?: string): Promise<void> {
async function clearSessionIngestionState(workspaceDir: string): Promise<void> {
await Promise.all([
clearMemoryCoreWorkspaceNamespace({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
agentId,
}),
clearMemoryCoreWorkspaceNamespace({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
agentId,
}),
]);
}
export async function auditDreamingArtifacts(params: {
workspaceDir: string;
agentId?: string;
}): Promise<DreamingArtifactsAuditSummary> {
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
const agentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined;
const dreamsPath = await resolveExistingDreamsPath(workspaceDir, agentId);
const sessionCorpusDir = agentId
? path.join(workspaceDir, "memory", ".dreams", "agents", agentId, "session-corpus")
: path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
const sessionIngestionPath = agentId
? memoryCoreStateReference(DREAMING_SESSION_INGESTION_FILES_NAMESPACE, workspaceDir, agentId)
: path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
const sessionCorpusDir = path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
const sessionIngestionPath = path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
const issues: DreamingArtifactsAuditIssue[] = [];
let sessionCorpusFileCount = 0;
let suspiciousSessionCorpusFileCount = 0;
@@ -257,19 +196,17 @@ export async function auditDreamingArtifacts(params: {
}
}
if (!agentId) {
try {
await fs.access(sessionIngestionPath);
sessionIngestionExists = true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
issues.push({
severity: "error",
code: "dreaming-session-ingestion-unreadable",
message: `Dreaming session-ingestion state could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
fixable: false,
});
}
try {
await fs.access(sessionIngestionPath);
sessionIngestionExists = true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
issues.push({
severity: "error",
code: "dreaming-session-ingestion-unreadable",
message: `Dreaming session-ingestion state could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
fixable: false,
});
}
}
@@ -285,7 +222,6 @@ export async function auditDreamingArtifacts(params: {
const entries = await readMemoryCoreWorkspaceEntries({
namespace,
workspaceDir,
agentId,
});
if (entries.length > 0) {
sessionIngestionExists = true;
@@ -320,12 +256,10 @@ export async function auditDreamingArtifacts(params: {
export async function repairDreamingArtifacts(params: {
workspaceDir: string;
agentId?: string;
archiveDiary?: boolean;
now?: Date;
}): Promise<RepairDreamingArtifactsResult> {
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
const agentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined;
const warnings: string[] = [];
const archivedPaths: string[] = [];
let archiveDir: string | undefined;
@@ -344,11 +278,7 @@ export async function repairDreamingArtifacts(params: {
const archivePathIfPresent = async (targetPath: string): Promise<string | null> => {
try {
return await moveToArchive({
workspaceDir,
targetPath,
archiveDir: ensureArchiveDir(),
});
return await moveToArchive({ targetPath, archiveDir: ensureArchiveDir() });
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
return null;
@@ -356,18 +286,16 @@ export async function repairDreamingArtifacts(params: {
};
const sessionCorpusDestination = await archivePathIfPresent(
agentId
? path.join(workspaceDir, "memory", ".dreams", "agents", agentId, "session-corpus")
: path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
);
if (sessionCorpusDestination) {
archivedSessionCorpus = true;
archivedPaths.push(sessionCorpusDestination);
}
const sessionIngestionDestination = agentId
? null
: await archivePathIfPresent(path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH));
const sessionIngestionDestination = await archivePathIfPresent(
path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH),
);
if (sessionIngestionDestination) {
archivedSessionIngestion = true;
archivedPaths.push(sessionIngestionDestination);
@@ -375,7 +303,7 @@ export async function repairDreamingArtifacts(params: {
if (sessionCorpusDestination || sessionIngestionDestination) {
try {
await clearSessionIngestionState(workspaceDir, agentId);
await clearSessionIngestionState(workspaceDir);
} catch (err) {
warnings.push(
`Failed clearing dreaming session-ingestion SQLite state: ${
@@ -386,7 +314,7 @@ export async function repairDreamingArtifacts(params: {
}
if (params.archiveDiary) {
const dreamsPath = await resolveExistingDreamsPath(workspaceDir, agentId);
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
if (dreamsPath) {
const dreamsDestination = await archivePathIfPresent(dreamsPath);
if (dreamsDestination) {

View File

@@ -5,7 +5,6 @@ import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export const MEMORY_CORE_PLUGIN_ID = "memory-core";
export const DREAMING_DAILY_INGESTION_NAMESPACE = "dreaming-daily-ingestion";
@@ -15,7 +14,6 @@ export const SHORT_TERM_RECALL_NAMESPACE = "short-term-recall";
export const SHORT_TERM_PHASE_SIGNAL_NAMESPACE = "short-term-phase-signals";
export const SHORT_TERM_META_NAMESPACE = "short-term-meta";
export const SHORT_TERM_LOCK_NAMESPACE = "short-term-locks";
export const SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE = "short-term-memory-file-locks";
export const DREAMING_WORKSPACE_STATE_MAX_ENTRIES = 50_000;
export const SHORT_TERM_LOCK_MAX_ENTRIES = 4_096;
@@ -29,7 +27,6 @@ type WorkspaceValue<T> = {
version: 1;
workspaceKey: string;
workspaceDir: string;
agentId?: string;
key: string;
value: T;
};
@@ -39,7 +36,6 @@ export type MemoryCoreWorkspaceEntry<T> = { key: string; value: T };
type MemoryCoreWorkspaceParams = {
namespace: string;
workspaceDir: string;
agentId?: string;
};
type WriteMemoryCoreWorkspaceEntriesParams<T> = MemoryCoreWorkspaceParams & {
@@ -84,34 +80,18 @@ export function normalizeMemoryCoreWorkspaceKey(workspaceDir: string): string {
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function normalizeMemoryCoreAgentId(agentId: string | undefined): string | undefined {
return agentId?.trim() ? normalizeAgentId(agentId) : undefined;
export function memoryCoreWorkspaceStateKey(workspaceDir: string): string {
return createHash("sha256").update(normalizeMemoryCoreWorkspaceKey(workspaceDir)).digest("hex");
}
export function memoryCoreWorkspaceStateKey(workspaceDir: string, agentId?: string): string {
const normalizedAgentId = normalizeMemoryCoreAgentId(agentId);
const scope = normalizedAgentId
? `${normalizeMemoryCoreWorkspaceKey(workspaceDir)}\0${normalizedAgentId}`
: normalizeMemoryCoreWorkspaceKey(workspaceDir);
return createHash("sha256").update(scope).digest("hex");
}
export function memoryCoreWorkspaceEntryKey(
workspaceDir: string,
logicalKey: string,
agentId?: string,
): string {
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir, agentId);
export function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string {
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir);
const itemKey = createHash("sha256").update(logicalKey).digest("hex");
return `${workspaceKey}:${itemKey}`;
}
export function memoryCoreStateReference(
namespace: string,
workspaceDir: string,
agentId?: string,
): string {
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir, agentId)}`;
export function memoryCoreStateReference(namespace: string, workspaceDir: string): string {
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir)}`;
}
function openWorkspaceStore<T>(namespace: string): PluginStateKeyedStore<WorkspaceValue<T>> {
@@ -128,7 +108,7 @@ export function readMemoryCoreWorkspaceEntries<T>(
export async function readMemoryCoreWorkspaceEntries(
params: MemoryCoreWorkspaceParams,
): Promise<Array<MemoryCoreWorkspaceEntry<unknown>>> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const entries = await openWorkspaceStore<unknown>(params.namespace).entries();
return entries
@@ -144,18 +124,16 @@ export async function writeMemoryCoreWorkspaceEntries(
params: WriteMemoryCoreWorkspaceEntriesParams<unknown>,
): Promise<void> {
const store = openWorkspaceStore<unknown>(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const agentId = normalizeMemoryCoreAgentId(params.agentId);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const replacementKeys = new Set<string>();
for (const entry of params.entries) {
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key, params.agentId);
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key);
replacementKeys.add(stateKey);
await store.register(stateKey, {
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
...(agentId ? { agentId } : {}),
key: entry.key,
value: entry.value,
});
@@ -174,15 +152,13 @@ export function writeMemoryCoreWorkspaceEntry<T>(
export async function writeMemoryCoreWorkspaceEntry(
params: WriteMemoryCoreWorkspaceEntryParams<unknown>,
): Promise<void> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const agentId = normalizeMemoryCoreAgentId(params.agentId);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
await openWorkspaceStore<unknown>(params.namespace).register(
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key, params.agentId),
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
{
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
...(agentId ? { agentId } : {}),
key: params.key,
value: params.value,
},
@@ -192,10 +168,9 @@ export async function writeMemoryCoreWorkspaceEntry(
export async function clearMemoryCoreWorkspaceNamespace(params: {
namespace: string;
workspaceDir: string;
agentId?: string;
}): Promise<void> {
const store = openWorkspaceStore(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
for (const entry of await store.entries()) {
if (entry.key.startsWith(prefix)) {
@@ -203,45 +178,3 @@ export async function clearMemoryCoreWorkspaceNamespace(params: {
}
}
}
export async function migrateMemoryCoreWorkspaceNamespaceToAgent(params: {
namespace: string;
workspaceDir: string;
sourceWorkspaceDir?: string;
agentId: string;
}): Promise<{ sourceEntries: number; migratedEntries: number; retainedAgentEntries: number }> {
const agentId = normalizeAgentId(params.agentId);
const sourceWorkspaceDir = params.sourceWorkspaceDir ?? params.workspaceDir;
const sourceWorkspaceKey = memoryCoreWorkspaceStateKey(sourceWorkspaceDir);
const targetWorkspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, agentId);
if (sourceWorkspaceKey === targetWorkspaceKey) {
return { sourceEntries: 0, migratedEntries: 0, retainedAgentEntries: 0 };
}
const store = openWorkspaceStore<unknown>(params.namespace);
const sourcePrefix = `${sourceWorkspaceKey}:`;
const sourceEntries = (await store.entries()).filter(
(entry) =>
entry.key.startsWith(sourcePrefix) &&
entry.value.workspaceKey === sourceWorkspaceKey &&
!entry.value.agentId,
);
let migratedEntries = 0;
let retainedAgentEntries = 0;
for (const entry of sourceEntries) {
const targetKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.value.key, agentId);
const migrated = await store.registerIfAbsent(targetKey, {
...entry.value,
workspaceKey: targetWorkspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
agentId,
});
if (migrated) {
migratedEntries += 1;
} else {
retainedAgentEntries += 1;
}
await store.delete(entry.key);
}
return { sourceEntries: sourceEntries.length, migratedEntries, retainedAgentEntries };
}

View File

@@ -7,7 +7,6 @@ import {
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { migrateMemoryCoreLegacyConfig } from "./config-compat.js";
import {
testing,
reconcileShortTermDreamingCronJob,
@@ -90,7 +89,6 @@ function createCronHarness(
addCalls.push(input);
jobs.push({
id: `job-${jobs.length + 1}`,
...(input.agentId ? { agentId: input.agentId } : {}),
name: input.name,
description: input.description,
enabled: input.enabled,
@@ -112,7 +110,6 @@ function createCronHarness(
const current = jobs[index];
jobs[index] = {
...current,
...(patch.agentId ? { agentId: patch.agentId } : {}),
...(patch.name ? { name: patch.name } : {}),
...(patch.description ? { description: patch.description } : {}),
...(typeof patch.enabled === "boolean" ? { enabled: patch.enabled } : {}),
@@ -270,14 +267,7 @@ async function triggerGatewayStart(
onMock: ReturnType<typeof vi.fn>,
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
): Promise<void> {
const migrated = ctx.config ? migrateMemoryCoreLegacyConfig(ctx.config)?.config : undefined;
await getGatewayStartHandler(onMock)(
{ port: 18789 },
{
...ctx,
...(migrated ? { config: migrated } : {}),
},
);
await getGatewayStartHandler(onMock)({ port: 18789 }, ctx);
}
async function triggerGatewayStop(
@@ -288,25 +278,6 @@ async function triggerGatewayStop(
}
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
const normalizeConfig = (config: OpenClawConfig): OpenClawConfig =>
migrateMemoryCoreLegacyConfig(config)?.config ?? config;
let rawConfig = api.config;
Object.defineProperty(api, "config", {
configurable: true,
get: () => normalizeConfig(rawConfig),
set: (config: OpenClawConfig) => {
rawConfig = config;
},
});
const runtime = api.runtime as {
config?: { current?: () => OpenClawConfig };
};
if (runtime.config?.current) {
const current = runtime.config.current;
runtime.config.current = () => normalizeConfig(current());
}
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
}
@@ -576,8 +547,7 @@ describe("short-term dreaming cron reconciliation", () => {
expect(result.status).toBe("added");
expect(harness.addCalls).toHaveLength(1);
const addCall = requireAddCall(harness, 0);
expect(addCall.agentId).toBe("main");
expect(addCall.name).toBe(`${constants.MANAGED_DREAMING_CRON_NAME} (main)`);
expect(addCall.name).toBe(constants.MANAGED_DREAMING_CRON_NAME);
expect(addCall.sessionTarget).toBe("isolated");
expect(addCall.wakeMode).toBe("now");
expect(addCall.delivery?.mode).toBe("none");
@@ -587,41 +557,6 @@ describe("short-term dreaming cron reconciliation", () => {
expectCronSchedule(addCall.schedule, "0 1 * * *", "UTC");
});
it("creates distinct agent-owned managed cron jobs", async () => {
const harness = createCronHarness();
const logger = createLogger();
const config = {
enabled: true,
cron: "0 1 * * *",
limit: 8,
minScore: 0.5,
minRecallCount: 4,
minUniqueQueries: 5,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
} as const;
await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config,
logger,
agentId: "research",
});
await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config,
logger,
agentId: "writer",
});
expect(harness.addCalls).toHaveLength(2);
expect(harness.addCalls.map((job) => job.agentId)).toEqual(["research", "writer"]);
expect(harness.addCalls.map((job) => job.name)).toEqual([
`${constants.MANAGED_DREAMING_CRON_NAME} (research)`,
`${constants.MANAGED_DREAMING_CRON_NAME} (writer)`,
]);
});
it("updates drifted managed jobs and prunes duplicates", async () => {
const desiredConfig = {
enabled: true,
@@ -1344,7 +1279,7 @@ describe("gateway startup reconciliation", () => {
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(2);
expect(harness.listCalls).toBe(1);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
@@ -1353,7 +1288,7 @@ describe("gateway startup reconciliation", () => {
{ trigger: "user", workspaceDir: "." },
);
expect(harness.listCalls).toBe(2);
expect(harness.listCalls).toBe(1);
} finally {
clearInternalHooks();
}
@@ -1395,7 +1330,7 @@ describe("gateway startup reconciliation", () => {
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(2);
expect(harness.listCalls).toBe(1);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
@@ -1407,7 +1342,7 @@ describe("gateway startup reconciliation", () => {
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(harness.listCalls).toBe(4);
expect(harness.listCalls).toBe(2);
} finally {
nowSpy.mockRestore();
clearInternalHooks();
@@ -1756,7 +1691,7 @@ describe("gateway startup reconciliation", () => {
expect(harness.addCalls).toHaveLength(1);
const addCall = requireAddCall(harness, 0);
expect(addCall.name).toBe("Memory Dreaming Promotion (main)");
expect(addCall.name).toBe("Memory Dreaming Promotion");
expectCronSchedule(addCall.schedule, "15 4 * * *", "UTC");
expect(addCall.sessionTarget).toBe("isolated");
const payload = requireAgentTurnPayload(addCall.payload);
@@ -2840,19 +2775,12 @@ describe("short-term dreaming trigger", () => {
trigger: "heartbeat",
workspaceDir: mainWorkspace,
cfg: {
memory: {
search: {
enabled: true,
},
extensions: {
"memory-core": {
dreaming: {
enabled: true,
},
agents: {
defaults: {
memorySearch: {
enabled: true,
},
},
},
agents: {
list: [
{
id: "alpha",

View File

@@ -1,9 +1,5 @@
// Memory Core plugin module implements dreaming behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
listAgentIds,
resolveDefaultAgentId,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import {
DEFAULT_MEMORY_DREAMING_FREQUENCY as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DEEP_DREAMING_LIMIT as DEFAULT_MEMORY_DREAMING_LIMIT,
@@ -50,7 +46,6 @@ type CronPayload =
| { kind: "systemEvent"; text: string }
| { kind: "agentTurn"; message: string; lightContext?: boolean };
type ManagedCronJobCreate = {
agentId?: string;
name: string;
description: string;
enabled: boolean;
@@ -64,7 +59,6 @@ type ManagedCronJobCreate = {
};
type ManagedCronJobPatch = {
agentId?: string;
name?: string;
description?: string;
enabled?: boolean;
@@ -79,7 +73,6 @@ type ManagedCronJobPatch = {
type ManagedCronJobLike = {
id: string;
agentId?: string;
name?: string;
description?: string;
enabled?: boolean;
@@ -162,23 +155,18 @@ function formatRepairSummary(repair: {
return actions.join(", ");
}
function resolveManagedCronDescription(
config: ShortTermPromotionDreamingConfig,
agentId: string,
): string {
function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string {
const recencyHalfLifeDays =
config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS;
return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md for agent=${agentId} (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`;
return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`;
}
function buildManagedDreamingCronJob(
config: ShortTermPromotionDreamingConfig,
agentId = "main",
): ManagedCronJobCreate {
return {
agentId,
name: `${MANAGED_DREAMING_CRON_NAME} (${agentId})`,
description: resolveManagedCronDescription(config, agentId),
name: MANAGED_DREAMING_CRON_NAME,
description: resolveManagedCronDescription(config),
enabled: true,
schedule: {
kind: "cron",
@@ -219,21 +207,7 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
}
const name = normalizeTrimmedString(job.name);
const payloadToken = resolveManagedDreamingPayloadToken(job.payload);
return (
(name === MANAGED_DREAMING_CRON_NAME ||
name?.startsWith(`${MANAGED_DREAMING_CRON_NAME} (`) === true) &&
payloadToken === DREAMING_SYSTEM_EVENT_TEXT
);
}
function isManagedDreamingJobForAgent(job: ManagedCronJobLike, agentId: string): boolean {
const normalizedJobAgentId = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId));
const normalizedAgentId = normalizeLowercaseStringOrEmpty(agentId);
return (
isManagedDreamingJob(job) &&
(normalizedJobAgentId === normalizedAgentId ||
(!normalizedJobAgentId && normalizedAgentId === "main"))
);
return name === MANAGED_DREAMING_CRON_NAME && payloadToken === DREAMING_SYSTEM_EVENT_TEXT;
}
function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
@@ -298,12 +272,6 @@ function buildManagedDreamingPatch(
if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) {
patch.name = desired.name;
}
if (
normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId)) !==
normalizeLowercaseStringOrEmpty(desired.agentId)
) {
patch.agentId = desired.agentId;
}
if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) {
patch.description = desired.description;
}
@@ -420,7 +388,6 @@ function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean {
export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
agentId?: string;
}): ShortTermPromotionDreamingConfig {
const resolved = resolveMemoryDeepDreamingConfig(params);
return {
@@ -445,7 +412,6 @@ export async function reconcileShortTermDreamingCronJob(params: {
cron: CronServiceLike | null;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
agentId?: string;
}): Promise<ReconcileResult> {
const cron = params.cron;
if (!cron) {
@@ -453,8 +419,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
}
const allJobs = await cron.list({ includeDisabled: true });
const agentId = params.agentId ?? "main";
const managed = allJobs.filter((job) => isManagedDreamingJobForAgent(job, agentId));
const managed = allJobs.filter(isManagedDreamingJob);
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
if (!params.config.enabled) {
@@ -482,7 +447,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
return { status: "disabled", removed };
}
const desired = buildManagedDreamingCronJob(params.config, agentId);
const desired = buildManagedDreamingCronJob(params.config);
if (managed.length === 0) {
await cron.add(desired);
const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
@@ -533,7 +498,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
trigger?: string;
workspaceDir?: string;
cfg?: OpenClawConfig;
agentId?: string;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
subagent?: OpenClawPluginApi["runtime"]["subagent"];
@@ -554,8 +518,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
const workspaceCandidates = params.cfg
? resolveMemoryDreamingWorkspaces(params.cfg, {
primaryWorkspaceDir: fallbackWorkspaceDir,
primaryAgentId: params.agentId ?? resolveDefaultAgentId(params.cfg),
agentIds: params.agentId ? [params.agentId] : undefined,
primaryAgentId: "main",
}).map((entry) => entry.workspaceDir)
: [];
const seenWorkspaces = new Set<string>();
@@ -589,10 +552,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
let totalCandidates = 0;
let totalApplied = 0;
let failedWorkspaces = 0;
const pluginConfig =
params.cfg && params.agentId
? resolveMemoryCorePluginConfig(params.cfg, params.agentId)
: undefined;
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
const detachNarratives = params.trigger === "cron";
const [
{ writeDeepDreamingReport },
@@ -616,7 +576,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
workspaceDir,
pluginConfig,
cfg: params.cfg,
agentId: params.agentId,
logger: params.logger,
subagent: params.subagent,
detachNarratives,
@@ -624,10 +583,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
});
const reportLines: string[] = [];
const repair = await repairShortTermPromotionArtifacts({
workspaceDir,
agentId: params.agentId,
});
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (repair.changed) {
params.logger.info(
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}) [workspace=${workspaceDir}].`,
@@ -636,7 +592,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
agentId: params.agentId,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
@@ -663,7 +618,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
const applied = await applyShortTermPromotions({
workspaceDir,
agentId: params.agentId,
candidates,
limit: params.config.limit,
minScore: params.config.minScore,
@@ -692,7 +646,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
await writeDeepDreamingReport({
workspaceDir,
agentId: params.agentId,
bodyLines: reportLines,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -708,7 +661,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
if (!params.subagent) {
await appendFallbackNarrativeEntry({
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -719,7 +671,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -730,7 +681,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -803,44 +753,39 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
resolveStartupCron = null;
};
const runtimeConfigKey = (
plans: Array<{ agentId: string; config: ShortTermPromotionDreamingConfig }>,
): string =>
plans
.map(({ agentId, config }) =>
[
agentId,
config.enabled ? "enabled" : "disabled",
config.cron,
config.timezone ?? "",
String(config.limit),
String(config.minScore),
String(config.minRecallCount),
String(config.minUniqueQueries),
String(config.recencyHalfLifeDays ?? ""),
String(config.maxAgeDays ?? ""),
config.verboseLogging ? "verbose" : "quiet",
config.storage?.mode ?? "",
config.storage?.separateReports ? "separate" : "inline",
].join("|"),
)
.toSorted()
.join("\n");
const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string =>
[
config.enabled ? "enabled" : "disabled",
config.cron,
config.timezone ?? "",
String(config.limit),
String(config.minScore),
String(config.minRecallCount),
String(config.minUniqueQueries),
String(config.recencyHalfLifeDays ?? ""),
String(config.maxAgeDays ?? ""),
config.verboseLogging ? "verbose" : "quiet",
config.storage?.mode ?? "",
config.storage?.separateReports ? "separate" : "inline",
].join("|");
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "startup_retry" | "runtime";
startupConfig?: OpenClawConfig;
startupCron?: (() => CronServiceLike | null) | null;
}): Promise<void> => {
}): Promise<ShortTermPromotionDreamingConfig> => {
const startupCfg =
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const plans = listAgentIds(startupCfg).map((agentId) => ({
agentId,
config: resolveShortTermPromotionDreamingConfig({
cfg: startupCfg,
agentId,
}),
}));
const pluginConfig =
params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg);
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg: startupCfg,
});
if (params.reason === "startup") {
resolveStartupCron = params.startupCron ?? null;
}
@@ -860,8 +805,8 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// Ignore — fall through with cron = null
}
}
const configKey = runtimeConfigKey(plans);
if (!cron && plans.some((plan) => plan.config.enabled) && !unavailableCronWarningEmitted) {
const configKey = runtimeConfigKey(config);
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
// The runtime reconciliation path (heartbeat-driven) will still warn if the
// cron service remains unavailable after boot.
@@ -883,7 +828,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// Startup retries only probe cron availability; the exhausted retry path
// re-enters runtime reconciliation so persistent failures still warn once.
if (!cron && params.reason === "startup_retry") {
return;
return config;
}
if (params.reason === "runtime") {
const now = Date.now();
@@ -894,29 +839,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
lastRuntimeConfigKey === configKey &&
lastRuntimeCronRef === cron
) {
return;
return config;
}
lastRuntimeReconcileAtMs = now;
lastRuntimeConfigKey = configKey;
lastRuntimeCronRef = cron;
}
for (const plan of plans) {
await reconcileShortTermDreamingCronJob({
cron,
config: plan.config,
logger: api.logger,
agentId: plan.agentId,
});
}
if (cron) {
const configuredAgentIds = new Set(plans.map((plan) => plan.agentId));
for (const job of await cron.list({ includeDisabled: true })) {
const jobAgentId = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId));
if (isManagedDreamingJob(job) && (!jobAgentId || !configuredAgentIds.has(jobAgentId))) {
await cron.remove(job.id);
}
}
}
await reconcileShortTermDreamingCronJob({
cron,
config,
logger: api.logger,
});
return config;
};
const scheduleStartupCronRetry = (): void => {
@@ -1010,23 +944,17 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
if (!shouldHandleManagedDreaming && !hasCronManagementContext()) {
return undefined;
}
await reconcileManagedDreamingCron({
const config = await reconcileManagedDreamingCron({
reason: "runtime",
});
if (!shouldHandleManagedDreaming) {
return undefined;
}
const agentId = ctx.agentId ?? resolveDefaultAgentId(currentConfig);
const config = resolveShortTermPromotionDreamingConfig({
cfg: currentConfig,
agentId,
});
return await runShortTermDreamingPromotionIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: currentConfig,
agentId,
config,
logger: api.logger,
subagent: config.enabled ? api.runtime?.subagent : undefined,

View File

@@ -71,20 +71,11 @@ const readAgentMemoryFileMock = vi.fn(
vi.mock("./tools.runtime.js", () => ({
resolveMemoryBackendConfig: ({
cfg,
agentId,
}: {
cfg?: {
memory?: { qmd?: unknown };
agents?: {
list?: Array<{ id?: string; memory?: { qmd?: unknown } }>;
};
};
agentId?: string;
cfg?: { memory?: { backend?: string; qmd?: unknown } };
}) => ({
backend,
qmd:
cfg?.agents?.list?.find((agent) => agent.id === agentId)?.memory?.qmd ??
cfg?.memory?.qmd,
qmd: cfg?.memory?.qmd,
}),
getMemorySearchManager: getMemorySearchManagerMock,
readAgentMemoryFile: readAgentMemoryFileMock,

View File

@@ -385,36 +385,34 @@ describe("memory index", () => {
hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number };
}): TestCfg {
return {
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: { vector: { enabled: params.vectorEnabled ?? false } },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true },
remote: params.batchEnabled
? {
nonBatchConcurrency: 1,
batch: { enabled: true, pollIntervalMs: 0, timeoutMinutes: 1 },
}
: undefined,
query: {
minScore: params.minScore ?? 0,
hybrid: params.hybrid ?? { enabled: false },
},
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
experimental: { sessionMemory: params.sessionMemory ?? false },
},
},
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
model: params.model ?? "mock-embed",
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: { vector: { enabled: params.vectorEnabled ?? false } },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true },
remote: params.batchEnabled
? {
nonBatchConcurrency: 1,
batch: { enabled: true, pollIntervalMs: 0, timeoutMinutes: 1 },
}
: undefined,
query: {
minScore: params.minScore ?? 0,
hybrid: params.hybrid ?? { enabled: false },
},
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
multimodal: params.multimodal,
sources: params.sources,
experimental: { sessionMemory: params.sessionMemory ?? false },
},
},
list: [{ id: "main", default: true }],
},

View File

@@ -11,7 +11,6 @@ import {
createSubsystemLogger,
onSessionTranscriptUpdate,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveSessionTranscriptsDirForAgent,
resolveUserPath,
type OpenClawConfig,
@@ -156,7 +155,6 @@ const SOURCE_WIDE_SESSION_INDEX_FLUSH_FILES = 128;
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
const MEMORY_WATCH_PRESSURE_STARTUP_CHECK_DELAY_MS = 10_000;
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
".dreams",
".git",
"node_modules",
".pnpm-store",
@@ -934,7 +932,7 @@ export abstract class MemoryManagerSyncOps {
count,
unit,
"Large memory folders or extraPaths can make OpenClaw run out of file watchers or open files.",
"Remove large extraPaths, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
(message) => log.warn(message),
);
}
@@ -1788,18 +1786,11 @@ export abstract class MemoryManagerSyncOps {
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
: null;
const agentIds = new Set([
this.agentId,
...(this.cfg.agents?.list ?? [])
.map((entry) => entry.id?.trim())
.filter((agentId): agentId is string => Boolean(agentId)),
]);
const excludedRoots = Array.from(agentIds, (agentId) =>
path.join(resolveAgentWorkspaceDir(this.cfg, agentId), "memory", ".dreams"),
const files = await listMemoryFiles(
this.workspaceDir,
this.settings.extraPaths,
this.settings.multimodal,
);
const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths, this.settings.multimodal, {
excludedRoots,
});
const fileEntries = (
await runWithConcurrency(
files.map(

View File

@@ -74,17 +74,17 @@ describe("memory manager FTS-only reindex", () => {
const cfg = {
memory: {
backend: "builtin",
search: {
provider: params.provider ?? "auto",
model: "",
store,
cache: { enabled: false },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: params.provider ?? "auto",
model: "",
store,
cache: { enabled: false },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},

View File

@@ -62,23 +62,21 @@ describe("memory manager reindex recovery", () => {
sources?: Array<"memory" | "sessions">;
}): OpenClawConfig {
return {
memory: {
backend: "builtin",
search: {
provider: params.provider ?? "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: false },
remote: { nonBatchConcurrency: 1 },
cache: { enabled: false },
sources: params.sources,
experimental: { sessionMemory: params.sources?.includes("sessions") ?? false },
},
},
memory: { backend: "builtin" },
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: params.provider ?? "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: false },
remote: { nonBatchConcurrency: 1 },
cache: { enabled: false },
sources: params.sources,
experimental: { sessionMemory: params.sources?.includes("sessions") ?? false },
},
},
list: [{ id: "main", default: true }],
},
@@ -200,6 +198,21 @@ describe("memory manager reindex recovery", () => {
).toEqual(publishedRows);
});
it("rejects a full reindex while another process owns the build lock", async () => {
const memoryManager = await openManager(createCfg({ provider: "none", sources: ["memory"] }));
const harness = memoryManager as unknown as ReindexHarness;
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
const lock = acquireMemoryReindexLock(databasePath);
try {
await expect(harness.runInPlaceReindex({ reason: "test", force: true })).rejects.toThrow(
/another reindex is active/,
);
} finally {
lock.release();
}
});
it("forces source-wide session sync when retrying a failed full reindex", async () => {
const memoryManager = await openManager(
createCfg({
@@ -230,26 +243,6 @@ describe("memory manager reindex recovery", () => {
expect(harness.sessionsFullRetryDirty).toBe(false);
});
it("rejects a full reindex while another process owns the build lock", async () => {
const memoryManager = await openManager(
createCfg({
provider: "none",
sources: ["memory"],
}),
);
const harness = memoryManager as unknown as ReindexHarness;
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
const lock = acquireMemoryReindexLock(databasePath);
try {
await expect(harness.runInPlaceReindex({ reason: "test", force: true })).rejects.toThrow(
/another reindex is active/,
);
} finally {
lock.release();
}
});
it("closes the database after constructor schema failure", async () => {
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
await fs.mkdir(path.dirname(databasePath), { recursive: true });
@@ -347,5 +340,4 @@ describe("memory manager reindex recovery", () => {
expect(harness.dirty).toBe(false);
expect(harness.memoryFullRetryDirty).toBe(false);
});
});

View File

@@ -77,19 +77,17 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
? undefined
: { vector: { enabled: params.vectorEnabled } };
const cfg = {
memory: {
backend: "builtin",
search: {
provider: params.provider ?? "auto",
model: "",
store,
cache: { enabled: false },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
memory: { backend: "builtin" },
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: params.provider ?? "auto",
model: "",
store,
cache: { enabled: false },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},

View File

@@ -145,7 +145,7 @@ function resolveConfiguredMemoryEmbeddingProvider(params: {
const agentEntry = params.cfg.agents?.list?.find(
(entry) => entry && normalizeAgentId(entry.id) === normalizedAgentId,
);
return agentEntry?.memory?.search?.provider ?? params.cfg.memory?.search?.provider;
return agentEntry?.memorySearch?.provider ?? params.cfg.agents?.defaults?.memorySearch?.provider;
}
function resolveMemoryEmbeddingProviderRequirement(params: {

View File

@@ -199,6 +199,7 @@ describe("memory watcher config", () => {
}
await closeAllMemorySearchManagers();
clearRegistry();
vi.unstubAllEnvs();
if (workspaceDir) {
await fs.rm(workspaceDir, { recursive: true, force: true });
workspaceDir = "";
@@ -208,6 +209,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"));
extraDir = path.join(workspaceDir, "extra");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.mkdir(extraDir, { recursive: true });
@@ -215,23 +217,22 @@ describe("memory watcher config", () => {
}
function createWatcherConfig(overrides?: Partial<MemorySearchConfig>): OpenClawConfig {
return {
memory: {
backend: "builtin",
search: {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
extraPaths: [extraDir],
...overrides,
},
const defaults: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> = {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
extraPaths: [extraDir],
...overrides,
},
};
return {
memory: { backend: "builtin" },
agents: {
defaults: {
workspace: workspaceDir,
},
defaults,
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
@@ -289,9 +290,6 @@ describe("memory watcher config", () => {
expect(ignored?.(path.join(workspaceDir, "memory", "node_modules", "pkg", "index.md"))).toBe(
true,
);
expect(
ignored?.(path.join(workspaceDir, "memory", ".dreams", "agents", "writer", "DREAMS.md")),
).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);

View File

@@ -175,6 +175,9 @@ describe("QmdMemoryManager slugified path resolution", () => {
process.env.OPENCLAW_STATE_DIR = stateDir;
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
@@ -183,9 +186,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
} as OpenClawConfig;
});

File diff suppressed because it is too large Load Diff

View File

@@ -97,7 +97,6 @@ const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState");
const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
".dreams",
".git",
".cache",
"node_modules",
@@ -496,7 +495,6 @@ export class QmdMemoryManager implements MemorySearchManager {
// default models directory into our custom cache so the index stays
// isolated while models are shared.
await this.symlinkSharedModels();
await this.refreshManagedCollectionIndexConfig();
await this.ensureCollections();
if (mode === "cli") {
@@ -632,10 +630,6 @@ export class QmdMemoryManager implements MemorySearchManager {
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
// QMD collection add and rebind rewrite collection entries, dropping managed
// fields such as private artifact ignore patterns.
await this.refreshManagedCollectionIndexConfig();
}
private async tryRebindSameNameCollection(params: {
@@ -1168,12 +1162,6 @@ export class QmdMemoryManager implements MemorySearchManager {
` path: ${this.quoteYamlString(collection.path)}`,
` pattern: ${this.quoteYamlString(collection.pattern)}`,
);
if (collection.ignore?.length) {
lines.push(" ignore:");
for (const pattern of collection.ignore) {
lines.push(` - ${this.quoteYamlString(pattern)}`);
}
}
}
return `${lines.join("\n")}\n`;
}
@@ -1230,14 +1218,6 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
}
try {
// QMD collection add rewrites entries and drops managed fields such as ignore.
await this.refreshManagedCollectionIndexConfig();
} catch (configErr) {
log.warn(
`qmd managed collection index refresh failed after update repair (${reason}): ${formatErrorMessage(configErr)}`,
);
}
log.warn(`qmd managed collections rebuilt for update repair (${reason})`);
}
@@ -1783,7 +1763,7 @@ export class QmdMemoryManager implements MemorySearchManager {
count,
"paths",
"Large QMD collections can make OpenClaw run out of file watchers or open files.",
"Remove large collections, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
(message) => log.warn(message),
);
}

View File

@@ -153,32 +153,27 @@ function createQmdCfg(
): OpenClawConfig {
return {
memory: { backend: "qmd", qmd },
agents: {
list: [{ id: agentId, default: true, workspace }],
},
agents: { list: [{ id: agentId, default: true, workspace }] },
};
}
function createBuiltinCfg(agentId: string): OpenClawConfig {
return {
memory: {
search: {
provider: "openai",
model: "text-embedding-3-small",
store: {
path: "/tmp/index.sqlite",
vector: { enabled: false },
},
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
sources: ["memory"],
experimental: { sessionMemory: false },
},
},
agents: {
defaults: {
workspace: "/tmp/workspace",
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
store: {
path: "/tmp/index.sqlite",
vector: { enabled: false },
},
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
sources: ["memory"],
experimental: { sessionMemory: false },
},
},
list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }],
},
@@ -503,9 +498,7 @@ describe("getMemorySearchManager caching", () => {
const agentId = "missing-workspace";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: {
list: [{ id: agentId, default: true, workspace }],
},
agents: { list: [{ id: agentId, default: true, workspace }] },
} as OpenClawConfig;
try {

View File

@@ -38,17 +38,13 @@ describe("listMemoryCorePublicArtifacts", () => {
"# Dream Report\n",
"utf8",
);
await appendMemoryHostEvent(
workspaceDir,
{
type: "memory.recall.recorded",
timestamp: "2026-04-06T12:00:00.000Z",
query: "alpha",
resultCount: 0,
results: [],
},
"main",
);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: "2026-04-06T12:00:00.000Z",
query: "alpha",
resultCount: 0,
results: [],
});
const cfg: OpenClawConfig = {
agents: {
@@ -84,8 +80,8 @@ describe("listMemoryCorePublicArtifacts", () => {
{
kind: "event-log",
workspaceDir,
relativePath: "memory/.dreams/agents/main/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir, "main"),
relativePath: "memory/.dreams/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
agentIds: ["main"],
contentType: "json",
},

View File

@@ -7,7 +7,6 @@ import type { OpenClawConfig } from "../api.js";
export async function listMemoryCorePublicArtifacts(params: {
cfg: OpenClawConfig;
agentId?: string;
}): Promise<MemoryPluginPublicArtifact[]> {
return await listMemoryHostPublicArtifacts(params);
}

View File

@@ -27,7 +27,6 @@ type MemoryRemHarnessDeepConfig = ReturnType<typeof resolveMemoryDeepDreamingCon
export type PreviewRemHarnessOptions = {
workspaceDir: string;
cfg?: OpenClawConfig;
agentId?: string;
pluginConfig?: Record<string, unknown>;
grounded?: boolean;
groundedInputPaths?: string[];
@@ -124,16 +123,13 @@ export async function previewRemHarness(
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
agentId: params.agentId,
});
const deepConfig = resolveMemoryDeepDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
agentId: params.agentId,
});
const allRecallEntries = await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
nowMs,
});
const recallEntries = await filterLiveShortTermRecallEntries({
@@ -175,7 +171,6 @@ export async function previewRemHarness(
const candidateLimit = normalizeOptionalPositiveLimit(params.candidateLimit);
const rankedCandidates = await rankShortTermPromotionCandidates({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,

View File

@@ -1425,101 +1425,6 @@ describe("short-term promotion", () => {
});
});
it("serializes shared MEMORY.md writes across symlinked agent workspaces", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
"Research promotion.",
"Writer promotion.",
]);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
const aliasWorkspaceDir = path.join(fixtureRoot, `case-${caseId++}-alias`);
const aliasMemoryPath = path.join(aliasWorkspaceDir, "MEMORY.md");
await fs.symlink(workspaceDir, aliasWorkspaceDir);
const originalReadFile = fs.readFile.bind(fs);
let memoryReadCount = 0;
let releaseFirstMemoryRead!: () => void;
const firstMemoryRead = new Promise<void>((resolve) => {
releaseFirstMemoryRead = resolve;
});
vi.spyOn(fs, "readFile").mockImplementation(async (filePath, options) => {
if (filePath === memoryPath || filePath === aliasMemoryPath) {
memoryReadCount += 1;
if (memoryReadCount === 1) {
await firstMemoryRead;
}
}
return await originalReadFile(filePath, options);
});
const candidate = (params: { key: string; line: number; snippet: string }) => ({
key: params.key,
path: "memory/2026-04-03.md",
startLine: params.line,
endLine: params.line,
source: "memory" as const,
snippet: params.snippet,
recallCount: 1,
avgScore: 1,
maxScore: 1,
uniqueQueries: 1,
firstRecalledAt: "2026-04-03T10:00:00.000Z",
lastRecalledAt: "2026-04-03T10:00:00.000Z",
ageDays: 0,
score: 1,
recallDays: ["2026-04-03"],
conceptTags: [],
components: {
frequency: 1,
relevance: 1,
diversity: 1,
recency: 1,
consolidation: 1,
conceptual: 1,
},
});
const research = applyShortTermPromotions({
workspaceDir,
agentId: "research",
candidates: [
candidate({
key: "memory:memory/2026-04-03.md:1:1",
line: 1,
snippet: "Research promotion.",
}),
],
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
await vi.waitFor(() => expect(memoryReadCount).toBe(1));
const writer = applyShortTermPromotions({
workspaceDir: aliasWorkspaceDir,
agentId: "writer",
candidates: [
candidate({
key: "memory:memory/2026-04-03.md:2:2",
line: 2,
snippet: "Writer promotion.",
}),
],
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
await new Promise<void>((resolve) => {
setTimeout(resolve, 25);
});
expect(memoryReadCount).toBe(1);
releaseFirstMemoryRead();
await Promise.all([research, writer]);
await expect(fs.readFile(memoryPath, "utf8")).resolves.toContain("Research promotion.");
await expect(fs.readFile(memoryPath, "utf8")).resolves.toContain("Writer promotion.");
});
});
it("does not rank contaminated dreaming snippets from an existing short-term store", async () => {
await withTempWorkspace(async (workspaceDir) => {
await testing.writeRawRecallStore(workspaceDir, {

View File

@@ -24,7 +24,6 @@ import { asRecord, formatErrorMessage } from "./dreaming-shared.js";
import {
SHORT_TERM_LOCK_MAX_ENTRIES,
SHORT_TERM_LOCK_NAMESPACE,
SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
SHORT_TERM_META_NAMESPACE,
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
SHORT_TERM_RECALL_NAMESPACE,
@@ -41,7 +40,7 @@ import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
const DREAMING_MEMORY_PATH_RE = /(?:^|\/)memory\/dreaming\//;
const SHORT_TERM_SESSION_CORPUS_RE =
/(?:^|\/)memory\/\.dreams\/(?:agents\/[^/]+\/)?session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/;
/(?:^|\/)memory\/\.dreams\/session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/;
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_RECENCY_HALF_LIFE_DAYS = 14;
@@ -237,7 +236,6 @@ export type RepairShortTermPromotionArtifactsResult = {
type RankShortTermPromotionOptions = {
workspaceDir: string;
agentId?: string;
limit?: number;
minScore?: number;
minRecallCount?: number;
@@ -251,7 +249,6 @@ type RankShortTermPromotionOptions = {
type ApplyShortTermPromotionsOptions = {
workspaceDir: string;
agentId?: string;
candidates: PromotionCandidate[];
limit?: number;
minScore?: number;
@@ -804,16 +801,16 @@ function calculatePhaseSignalBoost(
);
}
function resolveStorePath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir, agentId);
function resolveStorePath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir);
}
function resolvePhaseSignalPath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir, agentId);
function resolvePhaseSignalPath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir);
}
function resolveLockPath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir, agentId);
function resolveLockPath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir);
}
function parseLockOwnerPid(raw: string): number | null {
@@ -879,13 +876,9 @@ async function withInProcessShortTermLock<T>(lockPath: string, task: () => Promi
}
}
async function withShortTermLock<T>(
workspaceDir: string,
agentId: string | undefined,
task: () => Promise<T>,
): Promise<T> {
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, agentId);
const lockRef = resolveLockPath(workspaceDir, agentId);
async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>): Promise<T> {
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockRef = resolveLockPath(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -928,76 +921,15 @@ async function withShortTermLock<T>(
});
}
async function withMemoryFileWriteLock<T>(
workspaceDir: string,
task: () => Promise<T>,
): Promise<T> {
const physicalWorkspaceDir = await fs.realpath(workspaceDir);
const lockKey = memoryCoreWorkspaceStateKey(physicalWorkspaceDir);
const lockRef = memoryCoreStateReference(
SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
physicalWorkspaceDir,
);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
});
return await withInProcessShortTermLock(
`${SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE}:${lockKey}`,
async () => {
const startedAt = Date.now();
while (true) {
const owner = `${process.pid}:${Date.now()}`;
const acquired = await lockStore.registerIfAbsent(lockKey, {
owner,
acquiredAt: Date.now(),
});
if (acquired) {
try {
return await task();
} finally {
const current = await lockStore.lookup(lockKey).catch(() => undefined);
if (current?.owner === owner) {
await lockStore.delete(lockKey).catch(() => false);
}
}
}
const existing = await lockStore.lookup(lockKey);
if (existing && Date.now() - existing.acquiredAt > SHORT_TERM_LOCK_STALE_MS) {
const ownerPid = parseLockOwnerPid(existing.owner);
if (ownerPid === null || !isProcessLikelyAlive(ownerPid)) {
await lockStore.delete(lockKey);
continue;
}
}
if (Date.now() - startedAt >= SHORT_TERM_LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for shared MEMORY.md write lock at ${lockRef}`);
}
await sleep(SHORT_TERM_LOCK_RETRY_DELAY_MS);
}
},
);
}
async function readStore(
workspaceDir: string,
nowIso: string,
agentId?: string,
): Promise<ShortTermRecallStore> {
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
const [entryRows, metaRows] = await Promise.all([
readMemoryCoreWorkspaceEntries<ShortTermRecallEntry>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId,
}),
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
}),
]);
const meta = metaRows.find((entry) => entry.key === "recall")?.value;
@@ -1079,18 +1011,15 @@ export function normalizeShortTermPhaseSignalStore(
async function readPhaseSignalStore(
workspaceDir: string,
nowIso: string,
agentId?: string,
): Promise<ShortTermPhaseSignalStore> {
const [entryRows, metaRows] = await Promise.all([
readMemoryCoreWorkspaceEntries<ShortTermPhaseSignalEntry>({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
agentId,
}),
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
}),
]);
const meta = metaRows.find((entry) => entry.key === "phase")?.value;
@@ -1107,43 +1036,34 @@ async function readPhaseSignalStore(
async function writePhaseSignalStore(
workspaceDir: string,
store: ShortTermPhaseSignalStore,
agentId?: string,
): Promise<void> {
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
agentId,
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
key: "phase",
value: { updatedAt: store.updatedAt },
}),
]);
}
async function writeStore(
workspaceDir: string,
store: ShortTermRecallStore,
agentId?: string,
): Promise<void> {
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
enforceShortTermRecallSnippetCap(store);
enforceShortTermRecallStoreRetention(store);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId,
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
key: "recall",
value: { updatedAt: store.updatedAt },
}),
@@ -1272,17 +1192,16 @@ function trimDreamingStatsEntries(
export async function loadShortTermPromotionDreamingStats(params: {
workspaceDir: string;
agentId?: string;
nowMs: number;
timezone?: string;
}): Promise<ShortTermDreamingStats> {
const workspaceDir = params.workspaceDir.trim();
const nowIso = new Date(params.nowMs).toISOString();
const store = await readStore(workspaceDir, nowIso, params.agentId);
const store = await readStore(workspaceDir, nowIso);
let phaseSignalError: string | undefined;
let phaseStore: ShortTermPhaseSignalStore;
try {
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso);
} catch (err) {
phaseSignalError = formatErrorMessage(err);
phaseStore = emptyPhaseSignalStore(nowIso);
@@ -1383,8 +1302,8 @@ export async function loadShortTermPromotionDreamingStats(params: {
remPhaseHitCount,
promotedTotal,
promotedToday,
storePath: resolveStorePath(workspaceDir, params.agentId),
phaseSignalPath: resolvePhaseSignalPath(workspaceDir, params.agentId),
storePath: resolveStorePath(workspaceDir),
phaseSignalPath: resolvePhaseSignalPath(workspaceDir),
shortTermEntries: trimDreamingStatsEntries(
shortTermEntries,
compareDreamingStatsEntryByRecency,
@@ -1461,7 +1380,6 @@ function buildMemoryRecallSkippedEvent(params: {
export async function recordShortTermRecalls(params: {
workspaceDir?: string;
agentId?: string;
query: string;
results: MemorySearchResult[];
signalType?: "recall" | "daily";
@@ -1496,7 +1414,6 @@ export async function recordShortTermRecalls(params: {
eligibleResultCount: relevant.length,
skipped,
}),
params.agentId,
);
return;
}
@@ -1504,8 +1421,8 @@ export async function recordShortTermRecalls(params: {
const queryHash = hashQuery(query);
const todayBucket =
normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, params.agentId, async () => {
const store = await readStore(workspaceDir, nowIso, params.agentId);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
for (const result of relevant) {
const normalizedPath = normalizeMemoryPath(result.path);
@@ -1571,23 +1488,19 @@ export async function recordShortTermRecalls(params: {
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store, params.agentId);
await appendMemoryHostEvent(
workspaceDir,
{
type: "memory.recall.recorded",
timestamp: nowIso,
query,
resultCount: relevant.length,
results: relevant.map((result) => ({
path: normalizeMemoryPath(result.path),
startLine: Math.max(1, Math.floor(result.startLine)),
endLine: Math.max(1, Math.floor(result.endLine)),
score: clampScore(result.score),
})),
},
params.agentId,
);
await writeStore(workspaceDir, store);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: nowIso,
query,
resultCount: relevant.length,
results: relevant.map((result) => ({
path: normalizeMemoryPath(result.path),
startLine: Math.max(1, Math.floor(result.startLine)),
endLine: Math.max(1, Math.floor(result.endLine)),
score: clampScore(result.score),
})),
});
if (skipped.length > 0) {
await appendMemoryHostEvent(
workspaceDir,
@@ -1597,7 +1510,6 @@ export async function recordShortTermRecalls(params: {
eligibleResultCount: relevant.length,
skipped,
}),
params.agentId,
);
}
});
@@ -1605,7 +1517,6 @@ export async function recordShortTermRecalls(params: {
export async function recordGroundedShortTermCandidates(params: {
workspaceDir?: string;
agentId?: string;
query: string;
items: Array<{
path: string;
@@ -1665,8 +1576,8 @@ export async function recordGroundedShortTermCandidates(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const fallbackDayBucket = formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, params.agentId, async () => {
const store = await readStore(workspaceDir, nowIso, params.agentId);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
for (const item of relevant) {
const dayBucket = item.dayBucket ?? fallbackDayBucket;
@@ -1726,13 +1637,12 @@ export async function recordGroundedShortTermCandidates(params: {
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store, params.agentId);
await writeStore(workspaceDir, store);
});
}
export async function recordDreamingPhaseSignals(params: {
workspaceDir?: string;
agentId?: string;
phase: "light" | "rem";
keys: string[];
nowMs?: number;
@@ -1748,10 +1658,10 @@ export async function recordDreamingPhaseSignals(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
await withShortTermLock(workspaceDir, params.agentId, async () => {
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
const knownKeys = new Set(Object.keys(store.entries));
@@ -1781,13 +1691,12 @@ export async function recordDreamingPhaseSignals(params: {
}
phaseSignals.updatedAt = nowIso;
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
await writePhaseSignalStore(workspaceDir, phaseSignals);
});
}
export async function recordRemConsideredPhaseSignals(params: {
workspaceDir?: string;
agentId?: string;
keys: string[];
nowMs?: number;
}): Promise<void> {
@@ -1802,10 +1711,10 @@ export async function recordRemConsideredPhaseSignals(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
await withShortTermLock(workspaceDir, params.agentId, async () => {
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
const knownKeys = new Set(Object.keys(store.entries));
@@ -1829,13 +1738,12 @@ export async function recordRemConsideredPhaseSignals(params: {
}
phaseSignals.updatedAt = nowIso;
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
await writePhaseSignalStore(workspaceDir, phaseSignals);
});
}
export async function readLightStagedKeys(params: {
workspaceDir: string;
agentId?: string;
nowMs?: number;
}): Promise<Set<string>> {
const workspaceDir = params.workspaceDir?.trim();
@@ -1844,7 +1752,7 @@ export async function readLightStagedKeys(params: {
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const store = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
const store = await readPhaseSignalStore(workspaceDir, nowIso);
const keys = new Set<string>();
for (const [key, entry] of Object.entries(store.entries)) {
if (entry.lightHits <= 0) {
@@ -1895,8 +1803,8 @@ export async function rankShortTermPromotionCandidates(
const weights = normalizeWeights(options.weights);
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso, options.agentId),
readPhaseSignalStore(workspaceDir, nowIso, options.agentId),
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
const candidates: PromotionCandidate[] = [];
@@ -2010,7 +1918,6 @@ export async function rankShortTermPromotionCandidates(
export async function readShortTermRecallEntries(params: {
workspaceDir: string;
agentId?: string;
nowMs?: number;
}): Promise<ShortTermRecallEntry[]> {
const workspaceDir = params.workspaceDir.trim();
@@ -2019,7 +1926,7 @@ export async function readShortTermRecallEntries(params: {
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const store = await readStore(workspaceDir, nowIso, params.agentId);
const store = await readStore(workspaceDir, nowIso);
return Object.values(store.entries).filter(
(entry): entry is ShortTermRecallEntry =>
Boolean(entry) && entry.source === "memory" && isShortTermMemoryPath(entry.path),
@@ -2477,174 +2384,162 @@ export async function applyShortTermPromotions(
const maxAgeDays = toFiniteNonNegativeInt(options.maxAgeDays, -1);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
return await withShortTermLock(
workspaceDir,
options.agentId,
async () =>
await withMemoryFileWriteLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso, options.agentId);
const selected = options.candidates
.filter((candidate) => {
if (isContaminatedDreamingSnippet(candidate.snippet)) {
return false;
}
if (candidate.promotedAt) {
return false;
}
if (candidate.score < minScore) {
return false;
}
const candidateSignalCount = Math.max(
0,
candidate.signalCount ??
totalSignalCountForEntry({
recallCount: candidate.recallCount,
dailyCount: candidate.dailyCount,
groundedCount: candidate.groundedCount,
}),
);
if (candidateSignalCount < minRecallCount) {
return false;
}
if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
return false;
}
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
return false;
}
const latest = store.entries[candidate.key];
if (latest?.promotedAt) {
return false;
}
return true;
})
.slice(0, limit);
const rehydratedSelected: PromotionCandidate[] = [];
for (const candidate of selected) {
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
if (rehydrated && !isContaminatedDreamingSnippet(rehydrated.snippet)) {
rehydratedSelected.push(rehydrated);
}
return await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
const selected = options.candidates
.filter((candidate) => {
if (isContaminatedDreamingSnippet(candidate.snippet)) {
return false;
}
if (rehydratedSelected.length === 0) {
return {
memoryPath,
applied: 0,
appended: 0,
reconciledExisting: 0,
appliedCandidates: [],
compactedSections: 0,
compactedDates: [],
};
if (candidate.promotedAt) {
return false;
}
const existingMemory = await fs.readFile(memoryPath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const existingMarkers = extractPromotionMarkers(existingMemory);
const alreadyWritten = rehydratedSelected.filter((candidate) =>
existingMarkers.has(candidate.key),
);
const toAppend = rehydratedSelected.filter(
(candidate) => !existingMarkers.has(candidate.key),
);
let compactedDates: string[] = [];
if (toAppend.length > 0) {
const section = buildPromotionSection(
toAppend,
nowMs,
options.timezone,
options.maxPromotedSnippetTokens,
);
const budgetChars =
typeof options.memoryFileMaxChars === "number" &&
Number.isFinite(options.memoryFileMaxChars)
? Math.max(0, Math.floor(options.memoryFileMaxChars))
: DEFAULT_MEMORY_FILE_MAX_CHARS;
const compaction = compactMemoryForBudget({
existingMemory,
newSection: section,
budgetChars,
});
compactedDates = compaction.droppedDates;
const baseMemory = compaction.compacted;
const header = baseMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
await fs.writeFile(
memoryPath,
`${header}${withTrailingNewline(baseMemory)}${section}`,
"utf-8",
);
if (candidate.score < minScore) {
return false;
}
for (const candidate of rehydratedSelected) {
const entry = store.entries[candidate.key];
if (!entry) {
continue;
}
entry.startLine = candidate.startLine;
entry.endLine = candidate.endLine;
entry.snippet = candidate.snippet;
entry.promotedAt = nowIso;
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store, options.agentId);
await appendMemoryHostEvent(
workspaceDir,
{
type: "memory.promotion.applied",
timestamp: nowIso,
memoryPath,
applied: rehydratedSelected.length,
candidates: rehydratedSelected.map((candidate) => ({
key: candidate.key,
path: candidate.path,
startLine: candidate.startLine,
endLine: candidate.endLine,
score: candidate.score,
const candidateSignalCount = Math.max(
0,
candidate.signalCount ??
totalSignalCountForEntry({
recallCount: candidate.recallCount,
})),
},
options.agentId,
dailyCount: candidate.dailyCount,
groundedCount: candidate.groundedCount,
}),
);
if (candidateSignalCount < minRecallCount) {
return false;
}
if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
return false;
}
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
return false;
}
const latest = store.entries[candidate.key];
if (latest?.promotedAt) {
return false;
}
return true;
})
.slice(0, limit);
return {
memoryPath,
applied: rehydratedSelected.length,
appended: toAppend.length,
reconciledExisting: alreadyWritten.length,
appliedCandidates: rehydratedSelected,
compactedSections: compactedDates.length,
compactedDates,
};
}),
);
const rehydratedSelected: PromotionCandidate[] = [];
for (const candidate of selected) {
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
if (rehydrated && !isContaminatedDreamingSnippet(rehydrated.snippet)) {
rehydratedSelected.push(rehydrated);
}
}
if (rehydratedSelected.length === 0) {
return {
memoryPath,
applied: 0,
appended: 0,
reconciledExisting: 0,
appliedCandidates: [],
compactedSections: 0,
compactedDates: [],
};
}
const existingMemory = await fs.readFile(memoryPath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const existingMarkers = extractPromotionMarkers(existingMemory);
const alreadyWritten = rehydratedSelected.filter((candidate) =>
existingMarkers.has(candidate.key),
);
const toAppend = rehydratedSelected.filter((candidate) => !existingMarkers.has(candidate.key));
let compactedDates: string[] = [];
if (toAppend.length > 0) {
const section = buildPromotionSection(
toAppend,
nowMs,
options.timezone,
options.maxPromotedSnippetTokens,
);
const budgetChars =
typeof options.memoryFileMaxChars === "number" &&
Number.isFinite(options.memoryFileMaxChars)
? Math.max(0, Math.floor(options.memoryFileMaxChars))
: DEFAULT_MEMORY_FILE_MAX_CHARS;
const compaction = compactMemoryForBudget({
existingMemory,
newSection: section,
budgetChars,
});
compactedDates = compaction.droppedDates;
const baseMemory = compaction.compacted;
const header = baseMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
await fs.writeFile(
memoryPath,
`${header}${withTrailingNewline(baseMemory)}${section}`,
"utf-8",
);
}
for (const candidate of rehydratedSelected) {
const entry = store.entries[candidate.key];
if (!entry) {
continue;
}
entry.startLine = candidate.startLine;
entry.endLine = candidate.endLine;
entry.snippet = candidate.snippet;
entry.promotedAt = nowIso;
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.promotion.applied",
timestamp: nowIso,
memoryPath,
applied: rehydratedSelected.length,
candidates: rehydratedSelected.map((candidate) => ({
key: candidate.key,
path: candidate.path,
startLine: candidate.startLine,
endLine: candidate.endLine,
score: candidate.score,
recallCount: candidate.recallCount,
})),
});
return {
memoryPath,
applied: rehydratedSelected.length,
appended: toAppend.length,
reconciledExisting: alreadyWritten.length,
appliedCandidates: rehydratedSelected,
compactedSections: compactedDates.length,
compactedDates,
};
});
}
export function resolveShortTermRecallStorePath(workspaceDir: string, agentId?: string): string {
return resolveStorePath(workspaceDir, agentId);
export function resolveShortTermRecallStorePath(workspaceDir: string): string {
return resolveStorePath(workspaceDir);
}
export function resolveShortTermRecallLockPath(workspaceDir: string, agentId?: string): string {
return resolveLockPath(workspaceDir, agentId);
export function resolveShortTermRecallLockPath(workspaceDir: string): string {
return resolveLockPath(workspaceDir);
}
export async function auditShortTermPromotionArtifacts(params: {
workspaceDir: string;
agentId?: string;
qmd?: {
dbPath?: string;
collections?: number;
};
}): Promise<ShortTermAuditSummary> {
const workspaceDir = params.workspaceDir.trim();
const storePath = resolveStorePath(workspaceDir, params.agentId);
const lockPath = resolveLockPath(workspaceDir, params.agentId);
const storePath = resolveStorePath(workspaceDir);
const lockPath = resolveLockPath(workspaceDir);
const issues: ShortTermAuditIssue[] = [];
let entryCount = 0;
let promotedCount = 0;
@@ -2658,7 +2553,6 @@ export async function auditShortTermPromotionArtifacts(params: {
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId: params.agentId,
});
const exists = rawEntries.length > 0;
if (exists) {
@@ -2704,7 +2598,7 @@ export async function auditShortTermPromotionArtifacts(params: {
}
}
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -2787,7 +2681,6 @@ export async function auditShortTermPromotionArtifacts(params: {
export async function repairShortTermPromotionArtifacts(params: {
workspaceDir: string;
agentId?: string;
}): Promise<RepairShortTermPromotionArtifactsResult> {
const workspaceDir = params.workspaceDir.trim();
const nowIso = new Date().toISOString();
@@ -2796,7 +2689,7 @@ export async function repairShortTermPromotionArtifacts(params: {
let removedOverflowEntries = 0;
let removedStaleLock = false;
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -2809,11 +2702,10 @@ export async function repairShortTermPromotionArtifacts(params: {
}
}
await withShortTermLock(workspaceDir, params.agentId, async () => {
await withShortTermLock(workspaceDir, async () => {
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId: params.agentId,
});
if (rawEntries.length > 0) {
const normalized = normalizeShortTermRecallStore(
@@ -2862,14 +2754,10 @@ export async function repairShortTermPromotionArtifacts(params: {
removedOverflowEntries > 0 ||
JSON.stringify(normalized.entries) !== JSON.stringify(comparableStore.entries);
if (needsRewrite) {
await writeStore(
workspaceDir,
{
...comparableStore,
updatedAt: nowIso,
},
params.agentId,
);
await writeStore(workspaceDir, {
...comparableStore,
updatedAt: nowIso,
});
rewroteStore = true;
}
}
@@ -2886,17 +2774,16 @@ export async function repairShortTermPromotionArtifacts(params: {
export async function removeGroundedShortTermCandidates(params: {
workspaceDir: string;
agentId?: string;
}): Promise<{ removed: number; storePath: string }> {
const workspaceDir = params.workspaceDir.trim();
const storePath = resolveStorePath(workspaceDir, params.agentId);
const storePath = resolveStorePath(workspaceDir);
const nowIso = new Date().toISOString();
let removed = 0;
await withShortTermLock(workspaceDir, params.agentId, async () => {
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
for (const [key, entry] of Object.entries(store.entries)) {
@@ -2920,8 +2807,8 @@ export async function removeGroundedShortTermCandidates(params: {
store.updatedAt = nowIso;
phaseSignals.updatedAt = nowIso;
await Promise.all([
writeStore(workspaceDir, store, params.agentId),
writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId),
writeStore(workspaceDir, store),
writePhaseSignalStore(workspaceDir, phaseSignals),
]);
}
});
@@ -2935,20 +2822,18 @@ export const testing = {
isProcessLikelyAlive,
readRecallStore: readStore,
readPhaseSignalStore,
writeRawRecallStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
writeRawRecallStore: async (workspaceDir: string, raw: unknown) => {
const record = asRecord(raw);
const entries = asRecord(record?.entries);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId,
entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [],
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
key: "recall",
value: {
updatedAt:
@@ -2959,20 +2844,18 @@ export const testing = {
}),
]);
},
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown) => {
const record = asRecord(raw);
const entries = asRecord(record?.entries);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
agentId,
entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [],
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
agentId,
key: "phase",
value: {
updatedAt:
@@ -2983,17 +2866,17 @@ export const testing = {
}),
]);
},
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry, agentId?: string) => {
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).register(memoryCoreWorkspaceStateKey(workspaceDir, agentId), entry);
}).register(memoryCoreWorkspaceStateKey(workspaceDir), entry);
},
deleteShortTermLock: async (workspaceDir: string, agentId?: string) => {
deleteShortTermLock: async (workspaceDir: string) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).delete(memoryCoreWorkspaceStateKey(workspaceDir, agentId));
}).delete(memoryCoreWorkspaceStateKey(workspaceDir));
},
deriveConceptTags,
calculateConsolidationComponent,

View File

@@ -91,9 +91,7 @@ describe("memory search citations", () => {
setMemoryBackend("builtin");
const cfg = asOpenClawConfig({
memory: { citations: "on" },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_on", { query: "notes" });
@@ -107,9 +105,7 @@ describe("memory search citations", () => {
setMemoryBackend("builtin");
const cfg = asOpenClawConfig({
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_off", { query: "notes" });
@@ -123,9 +119,7 @@ describe("memory search citations", () => {
setMemoryBackend("qmd");
const cfg = asOpenClawConfig({
memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_qmd", { query: "notes" });
@@ -175,9 +169,7 @@ describe("memory tools", () => {
const tool = createMemorySearchToolOrThrow({
config: asOpenClawConfig({
memory: { backend: "qmd", qmd: { command: "qmd" } },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
}),
});
@@ -197,9 +189,7 @@ describe("memory tools", () => {
const tool = createMemorySearchToolOrThrow({
config: asOpenClawConfig({
memory: { backend: "qmd", qmd: { command: "qmd" } },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
}),
oneShotCliRun: true,
});
@@ -320,18 +310,18 @@ describe("memory tools", () => {
const tool = createMemorySearchToolOrThrow({
config: asOpenClawConfig({
memory: {
extensions: {
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"memory-core": {
dreaming: {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
list: [{ id: "main", default: true }],
},
}),
});
await tool.execute("call_recall_persist", { query: "glacier backup" });
@@ -340,7 +330,6 @@ describe("memory tools", () => {
const store = await shortTermPromotionTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
const values = Object.values(store.entries);
expect(values).toHaveLength(1);
@@ -350,7 +339,7 @@ describe("memory tools", () => {
expect(entry?.path).toBe("memory/2026-04-03.md");
expect(entry?.recallCount).toBe(1);
const events = await waitFor(async () => {
const memoryEvents = await readMemoryHostEvents({ workspaceDir, agentId: "main" });
const memoryEvents = await readMemoryHostEvents({ workspaceDir });
expect(memoryEvents).toHaveLength(1);
return memoryEvents;
});

View File

@@ -1,18 +1,14 @@
// Memory Core plugin module implements tools.citations behavior.
import {
parseAgentSessionKey,
resolveAgentMemoryConfig,
type MemoryCitationsMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
export function resolveMemoryCitationsMode(
cfg: OpenClawConfig,
agentId: string,
): MemoryCitationsMode {
const mode = resolveAgentMemoryConfig(cfg, agentId)?.citations;
export function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
const mode = cfg.memory?.citations;
if (mode === "on" || mode === "off" || mode === "auto") {
return mode;
}

View File

@@ -67,20 +67,22 @@ describe("memory_search recall tracking", () => {
const tool = createSearchTool(
asOpenClawConfig({
memory: {
backend: "qmd",
citations: "on",
qmd: { limits: { maxInjectedChars: 100 } },
extensions: {
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"memory-core": {
dreaming: {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
list: [{ id: "main", default: true }],
memory: {
backend: "qmd",
citations: "on",
qmd: { limits: { maxInjectedChars: 100 } },
},
}),
);
@@ -112,18 +114,18 @@ describe("memory_search recall tracking", () => {
const tool = createSearchTool(
asOpenClawConfig({
memory: {
extensions: {
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"memory-core": {
dreaming: {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
list: [{ id: "main", default: true }],
},
}),
);
setMemorySearchImpl(async () => [
@@ -174,23 +176,24 @@ describe("memory_search recall tracking", () => {
const tool = createSearchTool(
asOpenClawConfig({
memory: {
extensions: {
"memory-core": {
dreaming: {
enabled: true,
timezone: "Europe/London",
},
},
},
},
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
list: [{ id: "main", default: true }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
timezone: "Europe/London",
},
},
},
},
},
}),
);
@@ -215,18 +218,18 @@ describe("memory_search recall tracking", () => {
const tool = createSearchTool(
asOpenClawConfig({
memory: {
extensions: {
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"memory-core": {
dreaming: {
enabled: false,
config: {
dreaming: {
enabled: false,
},
},
},
},
},
agents: {
list: [{ id: "main", default: true }],
},
}),
);

View File

@@ -142,7 +142,6 @@ export function buildMemorySearchUnavailableResult(
export async function searchMemoryCorpusSupplements(params: {
query: string;
maxResults?: number;
agentId?: string;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all" | "sessions";
}): Promise<MemoryCorpusSearchResult[]> {
@@ -172,7 +171,6 @@ export async function getMemoryCorpusSupplementResult(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentId?: string;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all" | "sessions";
}) {

View File

@@ -43,9 +43,7 @@ export function createAutoCitationsMemorySearchTool(agentSessionKey: string) {
return createMemorySearchToolOrThrow({
config: asOpenClawConfig({
memory: { citations: "auto" },
agents: {
list: [{ id: "main", default: true }],
},
agents: { list: [{ id: "main", default: true }] },
}),
agentSessionKey,
});

View File

@@ -239,10 +239,8 @@ describe("memory_search unavailable payloads", () => {
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
},
});
const result = await tool.execute("closed-db", { query: "hidden thread codename" });
@@ -288,10 +286,8 @@ describe("memory_search unavailable payloads", () => {
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
},
oneShotCliRun: true,
});
@@ -338,10 +334,8 @@ describe("memory_search unavailable payloads", () => {
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
},
});
const result = await tool.execute("zero-hit-retry", { query: "hidden thread codename" });
@@ -368,10 +362,8 @@ describe("memory_search unavailable payloads", () => {
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
},
});
const result = await tool.execute("paused-index", { query: "hidden thread codename" });
@@ -465,8 +457,8 @@ describe("memory_search corpus labels", () => {
config: asOpenClawConfig({
agents: {
list: [
{ id: "main", default: true, memory: { search: { enabled: false } } },
{ id: "recall", memory: { search: { enabled: true } } },
{ id: "main", default: true, memorySearch: { enabled: false } },
{ id: "recall", memorySearch: { enabled: true } },
],
},
}),
@@ -481,27 +473,31 @@ describe("memory_search corpus labels", () => {
it("re-resolves config when executing a previously created tool", async () => {
const startupConfig = asOpenClawConfig({
agents: {
defaults: {
memorySearch: {
provider: "ollama",
model: "nomic-embed-text",
},
},
list: [{ id: "main", default: true }],
},
memory: {
backend: "builtin",
search: {
provider: "ollama",
model: "nomic-embed-text",
},
},
agents: {
list: [{ id: "main", default: true }],
},
});
const patchedConfig = asOpenClawConfig({
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
},
},
list: [{ id: "main", default: true }],
},
memory: {
backend: "builtin",
search: {
provider: "openai",
model: "text-embedding-3-small",
},
},
agents: {
list: [{ id: "main", default: true }],
},
});
let liveConfig = startupConfig;
@@ -541,10 +537,8 @@ describe("memory_search corpus labels", () => {
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
agents: {
list: [{ id: "main", default: true }],
},
tools: { sessions: { visibility: "all" } },
},
agentSessionKey: "agent:main:main",

View File

@@ -15,6 +15,7 @@ import type {
MemorySearchRuntimeDebug,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
@@ -238,7 +239,6 @@ function resolveRecallTrackingResults(
function queueShortTermRecallTracking(params: {
workspaceDir?: string;
agentId?: string;
query: string;
rawResults: MemorySearchResult[];
surfacedResults: MemorySearchResult[];
@@ -247,7 +247,6 @@ function queueShortTermRecallTracking(params: {
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
void recordShortTermRecalls({
workspaceDir: params.workspaceDir,
agentId: params.agentId,
query: params.query,
results: trackingResults,
timezone: params.timezone,
@@ -294,7 +293,6 @@ async function getSupplementMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}) {
@@ -302,7 +300,6 @@ async function getSupplementMemoryReadResult(params: {
lookup: params.relPath,
fromLine: params.from,
lineCount: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
corpus: params.corpus,
});
@@ -322,7 +319,6 @@ async function resolveMemoryReadFailureResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
}) {
if (params.requestedCorpus === "all") {
@@ -330,7 +326,6 @@ async function resolveMemoryReadFailureResult(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
corpus: params.requestedCorpus,
});
@@ -348,7 +343,6 @@ async function executeMemoryReadResult<T>(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
}) {
try {
@@ -360,7 +354,6 @@ async function executeMemoryReadResult<T>(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
});
}
@@ -456,18 +449,19 @@ export function createMemorySearchTool(options: {
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
}
const citationsMode = resolveMemoryCitationsMode(cfg, agentId);
const citationsMode = resolveMemoryCitationsMode(cfg);
const includeCitations = shouldIncludeCitations({
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const dreamingEnabled = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
agentId,
}).enabled;
const dreaming = resolveMemoryDeepDreamingConfig({
pluginConfig,
cfg,
agentId,
});
const searchStartedAt = Date.now();
let rawResults: MemorySearchResult[] = [];
@@ -576,7 +570,6 @@ export function createMemorySearchTool(options: {
if (dreamingEnabled) {
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
agentId,
query,
rawResults,
surfacedResults: memoryResults,
@@ -613,7 +606,6 @@ export function createMemorySearchTool(options: {
await searchMemoryCorpusSupplements({
query,
maxResults,
agentId,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
}),
@@ -688,7 +680,6 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
@@ -716,7 +707,6 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
});
}
@@ -739,7 +729,6 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
});
},

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