Compare commits

..

33 Commits

Author SHA1 Message Date
Vincent Koc
7c86d8d501 feat(memory): support global baselines and agent overrides 2026-06-19 10:00:02 +08:00
Vincent Koc
f15a70be21 fix(memory): exclude peer dream artifacts from extra paths 2026-06-19 02:44:09 +08:00
Vincent Koc
a31204ac6c fix(ui): scope dreaming wiki data by agent 2026-06-19 02:37:25 +08:00
Vincent Koc
4838915c95 test(gateway): scope qmd startup fixtures by agent 2026-06-19 02:33:02 +08:00
Vincent Koc
e14b2d9ba8 test(memory): align fixtures with agent config 2026-06-19 02:26:39 +08:00
Vincent Koc
a43ed080f3 fix(memory): preserve legacy dream diary content 2026-06-19 02:10:58 +08:00
Vincent Koc
bb44b27c2a fix(memory): protect agent dream diary reads 2026-06-19 02:07:19 +08:00
Vincent Koc
223b643112 fix(memory): migrate shared state to owner workspace 2026-06-19 02:03:10 +08:00
Vincent Koc
bda5ccf1c8 fix(memory): preserve shared legacy dreaming state 2026-06-19 01:59:15 +08:00
Vincent Koc
8b90900b8d fix(ui): preserve configured agent ids in dreaming patches 2026-06-19 01:54:07 +08:00
Vincent Koc
29a01b86c2 fix(memory): validate agent-scoped maintenance targets 2026-06-19 01:48:03 +08:00
Vincent Koc
6efd70ea20 fix(memory): start explicitly configured qmd agents 2026-06-19 01:42:49 +08:00
Vincent Koc
b85ba100b7 fix(ui): scope dreaming config by agent 2026-06-19 01:37:05 +08:00
Vincent Koc
d8b88c35c2 fix(memory): guard dreaming repair paths 2026-06-19 01:31:37 +08:00
Vincent Koc
1aa9837321 fix(config): prune relocated memory index paths 2026-06-19 01:26:53 +08:00
Vincent Koc
a064e11269 fix(memory): harden agent dreaming artifacts 2026-06-19 01:21:24 +08:00
Vincent Koc
01cdf9ca63 fix(memory-wiki): reject unknown gateway agent IDs 2026-06-19 01:21:24 +08:00
Vincent Koc
71168a2ae5 fix(memory): preserve QMD private artifact ignores 2026-06-19 01:21:24 +08:00
Vincent Koc
55f2ab04f0 fix(memory-wiki): type shared vault agent ids 2026-06-19 01:21:24 +08:00
Vincent Koc
80d2f54d31 fix(memory-wiki): retain shared vault bridge artifacts 2026-06-19 01:21:23 +08:00
Vincent Koc
021ae312a7 fix(memory): lock shared promotions by physical workspace 2026-06-19 01:21:23 +08:00
Vincent Koc
6c639c739c fix(codex): bind citations to resolved session agent 2026-06-19 01:21:23 +08:00
Vincent Koc
516263eefd fix(memory-wiki): retain shared vault bridge pages 2026-06-19 01:21:23 +08:00
Vincent Koc
86b2d0a569 fix(memory): preserve shared legacy dreaming state 2026-06-19 01:21:23 +08:00
Vincent Koc
8b14f45bae fix(memory-wiki): canonicalize gateway agent ids 2026-06-19 01:21:23 +08:00
Vincent Koc
b4bc84caa9 fix(memory): harden agent-scoped memory isolation 2026-06-19 01:21:23 +08:00
Vincent Koc
bf8c975cea fix(memory-wiki): type agent config fixture 2026-06-19 01:21:23 +08:00
Vincent Koc
44725f80c7 fix(memory): align agent-scoped runtime contracts 2026-06-19 01:21:23 +08:00
Vincent Koc
d38c702221 fix(memory): hide unscoped private artifacts 2026-06-19 01:21:23 +08:00
Vincent Koc
647869d425 feat(memory): unify agent-scoped memory configuration 2026-06-19 01:21:23 +08:00
Aaron Wong
1690c3f0dd fix(docs): oxfmt trailing comma in memory-config.md
(cherry picked from commit 7cd774a665)
2026-06-19 01:20:47 +08:00
Aaron Wong
7fe54772a9 feat(memory): add per-agent dreaming control
Add ability to selectively enable/disable dreaming for specific agents.

- Add 'dreaming.enabled' option to AgentConfig type
- Add corresponding Zod schema for config validation
- Modify resolveMemoryDreamingWorkspaces() to skip agents with dreaming.enabled = false
- Add test coverage for the filtering logic
- Update memory-config.md documentation

Fixes #67413

(cherry picked from commit 17f1d61d98)
2026-06-19 01:20:47 +08:00
Aaron Wong
740237831f feat(memory): add per-agent dreaming control
Add ability to selectively enable/disable dreaming for specific agents.

- Add 'dreaming.enabled' option to AgentConfig type
- Modify resolveMemoryDreamingWorkspaces() to skip agents with dreaming.disabled = false
- Addresses GitHub issue #67413

(cherry picked from commit 06814bcb42)
2026-06-19 01:20:47 +08:00
479 changed files with 13627 additions and 14234 deletions

View File

@@ -1,34 +1,44 @@
---
name: channel-message-flows
description: "Use when running QA Lab channel message flow evidence."
description: "Use when previewing local channel message flow fixtures."
---
# Channel Message Flows
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.
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.
## QA Scenario
## Telegram
Run the scenario through QA Lab:
Native Telegram `sendMessageDraft` tool progress, then a final answer:
```bash
pnpm openclaw qa suite --scenario channel-message-flows
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow working-final \
--duration-ms 20000
```
Run the focused e2e test directly in a Codex worktree:
Thinking preview, then a final answer:
```bash
node scripts/run-vitest.mjs extensions/telegram/src/channel-message-flows.qa.e2e.test.ts
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow thinking-final
```
## References
## Options
- `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`
- `--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.
The scenario covers `channels.streaming` as primary evidence and records
secondary coverage for thread preservation, delivery ordering, and reasoning
preview visibility.
## 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.

View File

@@ -16,8 +16,11 @@ This skill owns the operational workflow for:
- `taxonomy.yaml`
- `docs/maturity-scores.yaml`
- `docs/concepts/qa-e2e-automation.md`
- `qa/scenarios/index.yaml`
- `docs/maturity-scorecard.md`
- `docs/taxonomy.md`
- `docs/taxonomy-outline.md`
- `scripts/render-maturity-docs.mjs`
- `.github/workflows/maturity-scorecard.yml`
Keep person-specific, maintainer-private, Discord archive, and discrawl facts
out of this repo. If a score needs private evidence, use the redacted
@@ -28,18 +31,12 @@ 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.
- 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.
- `docs/maturity-scorecard.md`, `docs/taxonomy.md`, and
`docs/taxonomy-outline.md` are deterministic docs generated from the root
taxonomy and aggregate score source.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
enrich generated artifact docs, but they are not committed as inventory.
@@ -47,28 +44,22 @@ out of this repo. If a score needs private evidence, use the redacted
Run from the openclaw repo root.
Validate YAML structure after source edits:
Render committed docs:
```bash
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
pnpm maturity:render
```
Check docs when touching docs prose:
Check generated docs are current:
```bash
pnpm check:docs
pnpm maturity:check
```
Run focused QA/profile checks when changing coverage IDs or profile membership:
Render an evidence-enriched docs artifact from downloaded QA artifacts:
```bash
pnpm openclaw qa coverage --json
pnpm maturity:render -- --evidence-dir .artifacts/maturity-evidence --output-dir .artifacts/maturity-docs
```
## Scoring Workflow
@@ -84,13 +75,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 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.
6. Run `pnpm maturity:render`.
7. Run `pnpm maturity:check`.
For subjective score changes, make the smallest defensible edit and leave the
evidence path in the PR or task summary. Keep manual prose in current docs and
keep score data in `docs/maturity-scores.yaml`.
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.
## Default Completeness Process
@@ -167,9 +158,13 @@ Bands:
- `Alpha`: 50-70
- `Experimental`: 0-50
## Artifacts
## 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.
Do not add the maintainer repo's `docs/kevinslin/maturity-scorecard/inventory/`
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.
tree to openclaw. Those generated reports are intentionally replaced here by
short-lived artifact docs and the committed aggregate scorecard pages.

View File

@@ -85,22 +85,12 @@ 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
@@ -153,9 +143,8 @@ 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 $rootfsUrl -OutFile $rootfs -UseBasicParsing
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -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/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 normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- 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:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
## Validation

View File

@@ -2,415 +2,6 @@
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
@@ -643,7 +234,6 @@ 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
@@ -730,7 +320,6 @@ 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
@@ -968,7 +557,6 @@ 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
@@ -1153,7 +741,6 @@ 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
@@ -1246,7 +833,6 @@ 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
@@ -1461,7 +1047,6 @@ 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
@@ -1592,7 +1177,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.
@@ -1891,7 +1476,6 @@ 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 @@
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl
7b0d7f0a21c91718fd05151778bb8ff1f16b622599c4dd0a868d72459ad08559 plugin-sdk-api-baseline.json
65e710ce7c379b49abf1f5d1b4ef7b4cbabf2820be87f7f300f2988f05f63ec5 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 `plugins.entries.memory-core.config.dreaming.enabled: true`.
- Enable with `memory.extensions.memory-core.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 `memorySearch.extraPaths`.
- `memory status` includes any extra paths configured via `memory.search.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` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.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` | `agents.*.memory.qmd.sessions.enabled` and `agents.*.memory.search.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 `agents.defaults.memorySearch.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 `memory.search.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,6 +32,7 @@ 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
@@ -266,15 +267,16 @@ These require the official `obsidian` CLI on `PATH` when
## Configuration tie-ins
`openclaw wiki` behavior is shaped by:
`openclaw wiki` resolves config for the selected `--agent` (or the configured
default agent) from:
- `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`
- `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`
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 `memorySearch.provider` is unset, OpenClaw uses OpenAI embeddings. Set
`memorySearch.provider` explicitly for local, Ollama, Gemini, Voyage,
If `memory.search.provider` is unset, OpenClaw uses OpenAI embeddings. Set
`memory.search.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 `memorySearch.fallback` only when you want a deliberate
Set an optional `memory.search.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,8 +18,10 @@ Dreaming is **opt-in** and disabled by default.
Dreaming keeps two kinds of output:
- **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`.
- **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`.
Long-term promotion still writes only to `MEMORY.md`.
@@ -52,7 +54,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 `DREAMS.md` and optionally writes `memory/dreaming/deep/YYYY-MM-DD.md`.
- Writes a `## Deep Sleep` summary into the agent's `DREAMS.md` and optionally writes an agent-private report.
</Accordion>
<Accordion title="REM phase">
@@ -72,7 +74,12 @@ Dreaming can ingest redacted session transcripts into the dreaming corpus. When
## Dream Diary
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.
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.
<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`.
@@ -105,7 +112,8 @@ 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 `memory/.dreams/phase-signals.json`.
Light and REM phase hits add a small recency-decayed boost from agent-scoped
dreaming state.
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
@@ -136,16 +144,18 @@ harmful verdicts map to `reject`; none of those recommendations writes to
## Scheduling
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.
When enabled, `memory-core` auto-manages one cron job per enabled agent. Each
sweep runs phases in order: light → REM → deep.
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.
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.
Default cadence behavior:
| Setting | Default |
| -------------------- | ------------- |
| `dreaming.frequency` | `0 3 * * *` |
| `dreaming.model` | default model |
| Setting | Default |
| -------------------------------------------------- | ------------- |
| `memory.extensions.memory-core.dreaming.frequency` | `0 3 * * *` |
| `memory.extensions.memory-core.dreaming.model` | default model |
## Quick start
@@ -153,12 +163,14 @@ Default cadence behavior:
<Tab title="Enable dreaming">
```json
{
"plugins": {
"entries": {
"memory-core": {
"config": {
"dreaming": {
"enabled": true
"agents": {
"defaults": {
"memory": {
"extensions": {
"memory-core": {
"dreaming": {
"enabled": true
}
}
}
}
@@ -170,14 +182,16 @@ Default cadence behavior:
<Tab title="Custom sweep cadence">
```json
{
"plugins": {
"entries": {
"memory-core": {
"config": {
"dreaming": {
"enabled": true,
"timezone": "America/Los_Angeles",
"frequency": "0 */6 * * *"
"agents": {
"defaults": {
"memory": {
"extensions": {
"memory-core": {
"dreaming": {
"enabled": true,
"timezone": "America/Los_Angeles",
"frequency": "0 */6 * * *"
}
}
}
}
@@ -233,7 +247,7 @@ Default cadence behavior:
## Key defaults
All settings live under `plugins.entries.memory-core.config.dreaming`.
All settings live under `memory.extensions.memory-core.dreaming`.
<ParamField path="enabled" type="boolean" default="false">
Enable or disable the dreaming sweep.
@@ -249,7 +263,7 @@ All settings live under `plugins.entries.memory-core.config.dreaming`.
</ParamField>
<Warning>
`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.
`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.
</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 | `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) |
| 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) |
| 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,11 +27,9 @@ To set a provider explicitly:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
memory: {
search: {
provider: "openai",
},
},
}
@@ -48,14 +46,12 @@ openclaw plugins install @openclaw/llama-cpp-provider
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "local",
fallback: "none",
local: {
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
},
memory: {
search: {
provider: "local",
fallback: "none",
local: {
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
},
},
},
@@ -77,7 +73,7 @@ openclaw plugins install @openclaw/llama-cpp-provider
| OpenAI-compatible | `openai-compatible` | Generic `/v1/embeddings` endpoint |
| Voyage | `voyage` | |
Set `memorySearch.provider` to switch away from OpenAI.
Set `memory.search.provider` to switch away from OpenAI.
## How indexing works
@@ -95,7 +91,7 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
<Info>
You can also index Markdown files outside the workspace with
`memorySearch.extraPaths`. See the
`memory.search.extraPaths`. See the
[configuration reference](/reference/memory-config#additional-memory-paths).
</Info>
@@ -127,7 +123,7 @@ openclaw memory index --force --agent main
```
Both standalone CLI commands and the Gateway use the same `local` provider id.
Set `memorySearch.provider: "local"` when you want local embeddings.
Set `memory.search.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,11 +18,9 @@ backend, set a provider explicitly:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
},
memory: {
search: {
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
},
},
}
@@ -39,8 +37,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 `memorySearch.queryInputType` and
`memorySearch.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
for indexed chunks. Configure those with `memory.search.queryInputType` and
`memory.search.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
## Supported providers
@@ -82,7 +80,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
`memorySearch.provider` to a concrete remote-backed provider and that provider
`memory.search.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
@@ -117,14 +115,12 @@ different daily notes.
```json5
{
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
mmr: { enabled: true },
temporalDecay: { enabled: true },
},
memory: {
search: {
query: {
hybrid: {
mmr: { enabled: true },
temporalDecay: { enabled: true },
},
},
},
@@ -143,7 +139,7 @@ setup.
You can optionally index session transcripts so `memory_search` can recall
earlier conversations. This is opt-in via
`memorySearch.experimental.sessionMemory`. See the
`memory.search.experimental.sessionMemory`. See the
[configuration reference](/reference/memory-config) for details.
## Troubleshooting
@@ -156,7 +152,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
`agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds` and rerun
`memory.search.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
`agents.defaults.memorySearch.provider` explicitly to use Gemini, Voyage,
`memory.search.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 the short-term dreaming store under
`memory/.dreams/` and is what the normal deep phase uses when deciding what
can graduate into `MEMORY.md`.
- **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`.
- **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,36 +130,38 @@ 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[].memorySearch.qmd.extraCollections`. Use `agents.defaults.memorySearch.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[].memory.search.qmd.extraCollections`. Use `memory.search.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",
memorySearch: {
qmd: {
extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main"
memory: {
search: {
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,8 +57,6 @@ 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,14 +281,6 @@ 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
@@ -332,6 +324,17 @@ 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 `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`
- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `memory.extensions.memory-core.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"`).
- `plugins.entries.memory-core.config.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
- `memory.extensions.memory-core.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):
- `agents.defaults.memorySearch.*`
- `memory.search.*`
- `memory.backend`
- `memory.citations`
- `memory.qmd.*`
- `plugins.entries.memory-core.config.dreaming`
- `memory.extensions.memory-core.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 `memorySearch.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
- **Legacy auto provider**: treats `memory.search.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 `memorySearch.provider = "auto"` resolve to OpenAI too.
configs that still say `memory.search.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 `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,
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,
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 memorySearch.provider = "local"
- You are configuring memory.search.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
`memorySearch.provider: "local"`.
`memory.search.provider: "local"`.
Install it before using local memory embeddings:
@@ -28,13 +28,11 @@ Set the memory search provider to `local`:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "local",
local: {
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
},
memory: {
search: {
provider: "local",
local: {
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
},
},
},

View File

@@ -397,51 +397,54 @@ engines or legacy prompt assembly that explicitly consume memory supplements.
## Configuration
Put config under `plugins.entries.memory-wiki.config`:
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.
```json5
{
plugins: {
entries: {
"memory-wiki": { enabled: true },
},
},
memory: {
extensions: {
"memory-wiki": {
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,
},
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,
},
},
},
@@ -449,6 +452,27 @@ Put config under `plugins.entries.memory-wiki.config`:
}
```
Agent entries can override the same `memory.extensions.memory-wiki` object:
```json5
{
agents: {
list: [
{
id: "research",
memory: {
extensions: {
"memory-wiki": {
vaultMode: "isolated",
},
},
},
},
],
},
}
```
Key toggles:
- `vaultMode`: `isolated`, `bridge`, `unsafe-local`
@@ -468,30 +492,30 @@ knowledge layer:
```json5
{
memory: {
backend: "qmd",
},
plugins: {
entries: {
"memory-wiki": { enabled: true },
},
},
memory: {
backend: "qmd",
extensions: {
"memory-wiki": {
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,
},
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. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. 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
- Install route: npm; ClawHub: `clawhub:@openclaw/stepfun-provider`
## Surface

View File

@@ -371,16 +371,14 @@ 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 `agents.defaults.memorySearch.provider` to `"bedrock"`:
inference provider -- set `memory.search.provider` to `"bedrock"`:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0", // default
},
memory: {
search: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0", // default
},
},
}
@@ -388,7 +386,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 `memorySearch.provider: "bedrock"` explicitly to use Bedrock
needed. Set `memory.search.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` | `agents.defaults.memorySearch.provider: "deepinfra"` |
| Memory embeddings | `BAAI/bge-m3` | `memory.search.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,19 +216,17 @@ have logged in, OpenClaw can use it for embeddings without a separate API key.
### Config
Set `memorySearch.provider` explicitly to use GitHub Copilot embeddings. If a
Set `memory.search.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
{
agents: {
defaults: {
memorySearch: {
provider: "github-copilot",
// Optional: override the auto-discovered model
model: "text-embedding-3-small",
},
memory: {
search: {
provider: "github-copilot",
// Optional: override the auto-discovered model
model: "text-embedding-3-small",
},
},
}

View File

@@ -208,7 +208,9 @@ matching `sampleRate` only if your upstream stream is already raw PCM.
```json5
{
memorySearch: { provider: "mistral" },
memory: {
search: { 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 `agents.defaults.memorySearch.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 `memory.search.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.*.memorySearch.remote.apiKey` is sent only to its remote embedding host.
- `agents.*.memory.search.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,14 +972,12 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "ollama",
remote: {
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
nonBatchConcurrency: 1,
},
memory: {
search: {
provider: "ollama",
remote: {
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
nonBatchConcurrency: 1,
},
},
},
@@ -990,16 +988,14 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "ollama",
model: "nomic-embed-text",
remote: {
baseUrl: "http://gpu-box.local:11434",
apiKey: "ollama-local",
nonBatchConcurrency: 2,
},
memory: {
search: {
provider: "ollama",
model: "nomic-embed-text",
remote: {
baseUrl: "http://gpu-box.local:11434",
apiKey: "ollama-local",
nonBatchConcurrency: 2,
},
},
},

View File

@@ -123,19 +123,17 @@ OpenClaw can use OpenAI, or an OpenAI-compatible embedding endpoint, for
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
},
memory: {
search: {
provider: "openai",
model: "text-embedding-3-small",
},
},
}
```
For OpenAI-compatible endpoints that require asymmetric embedding labels, set
`queryInputType` and `documentInputType` under `memorySearch`. OpenClaw forwards
`queryInputType` and `documentInputType` under `memory.search`. 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 `memorySearch.store.path`; doctor
resolved runtime contracts must not expose `memory.search.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
`memorySearch.store.path` sidecar opt-in has been retired to doctor config
`memory.search.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 `memorySearch.store.path` is now removed by doctor config migration.
custom `memory.search.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`, `memorySearch.*`,
`plugins.entries.firecrawl.config.webFetch.apiKey`, `memory.search.*`,
`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:
- `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)
- `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)
- Optional fallback to a remote provider if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).
You can keep it local with `memory.search.provider = "local"` (no API usage).
See [Memory](/concepts/memory).

View File

@@ -29,10 +29,45 @@ This page lists every configuration knob for OpenClaw memory search. For concept
</Card>
</CardGroup>
All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` unless noted otherwise.
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"],
},
},
},
],
},
}
```
<Note>
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memorySearch`.
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`.
Active memory uses a two-gate model:
@@ -71,7 +106,8 @@ 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 `memorySearch.provider` to
Explicit non-local providers fail closed. If you set
`memory.search.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`
@@ -81,7 +117,13 @@ provider/auth configuration, switch to a reachable provider, or set
### Custom provider ids
`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:
`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:
```json5
{
@@ -95,12 +137,10 @@ provider/auth configuration, switch to a reachable provider, or set
},
},
},
agents: {
defaults: {
memorySearch: {
provider: "ollama-5080",
model: "qwen3-embedding:0.6b",
},
memory: {
search: {
provider: "ollama-5080",
model: "qwen3-embedding:0.6b",
},
},
}
@@ -144,15 +184,13 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "openai-compatible",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_KEY",
},
memory: {
search: {
provider: "openai-compatible",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_KEY",
},
},
},
@@ -187,18 +225,16 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "openai-compatible",
remote: {
baseUrl: "https://embeddings.example/v1",
apiKey: "${EMBEDDINGS_API_KEY}",
},
model: "asymmetric-embedder",
queryInputType: "query",
documentInputType: "passage",
memory: {
search: {
provider: "openai-compatible",
remote: {
baseUrl: "https://embeddings.example/v1",
apiKey: "${EMBEDDINGS_API_KEY}",
},
model: "asymmetric-embedder",
queryInputType: "query",
documentInputType: "passage",
},
},
}
@@ -214,12 +250,10 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
},
memory: {
search: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
},
},
}
@@ -308,7 +342,7 @@ Unset uses the provider default: 600 seconds for local/self-hosted providers suc
## Hybrid search config
All under `memorySearch.query.hybrid`:
All under `memory.search.query.hybrid`:
| Key | Type | Default | Description |
| --------------------- | --------- | ------- | ---------------------------------- |
@@ -339,16 +373,14 @@ All under `memorySearch.query.hybrid`:
```json5
{
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
vectorWeight: 0.7,
textWeight: 0.3,
mmr: { enabled: true, lambda: 0.7 },
temporalDecay: { enabled: true, halfLifeDays: 30 },
},
memory: {
search: {
query: {
hybrid: {
vectorWeight: 0.7,
textWeight: 0.3,
mmr: { enabled: true, lambda: 0.7 },
temporalDecay: { enabled: true, halfLifeDays: 30 },
},
},
},
@@ -366,11 +398,9 @@ All under `memorySearch.query.hybrid`:
```json5
{
agents: {
defaults: {
memorySearch: {
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
memory: {
search: {
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
},
}
@@ -378,7 +408,14 @@ All under `memorySearch.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[].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.
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.
---
@@ -577,9 +614,10 @@ When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for e
## Dreaming
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`.
Dreaming is configured under `memory.extensions.memory-core.dreaming`, not under `memory.search`.
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail.
Each enabled agent gets its own scheduled dreaming sweep. The sweep uses
internal light/deep/REM phases as an implementation detail.
For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
@@ -592,10 +630,50 @@ 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": {
@@ -603,13 +681,6 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
allowModelOverride: true,
allowedModels: ["anthropic/claude-sonnet-4-6"],
},
config: {
dreaming: {
enabled: true,
frequency: "0 3 * * *",
model: "anthropic/claude-sonnet-4-6",
},
},
},
},
},
@@ -617,8 +688,11 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
```
<Note>
- Dreaming writes machine state to `memory/.dreams/`.
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
- 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.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`
- `agents.defaults.memorySearch.remote.apiKey`
- `memory.search.remote.apiKey`
- `agents.list[].tts.providers.*.apiKey`
- `agents.list[].memorySearch.remote.apiKey`
- `agents.list[].memory.search.remote.apiKey`
- `talk.providers.*.apiKey`
- `talk.realtime.providers.*.apiKey`
- `messages.tts.providers.*.apiKey`

View File

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

View File

@@ -1398,6 +1398,12 @@ 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: {
@@ -1405,12 +1411,6 @@ describe("active-memory plugin", () => {
},
},
},
memory: {
backend: "qmd",
qmd: {
searchMode: "query",
},
},
};
api.pluginConfig = {
agents: ["main"],
@@ -1434,7 +1434,8 @@ describe("active-memory plugin", () => {
);
const config = embeddedRunConfig();
expect(config.memory).toEqual({
const agents = requireRecord(config.agents, "expected agents config");
expect(requireRecord(agents.defaults, "expected agent defaults").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 agents.defaults.memorySearch.provider to another provider.",
"or set memory.search.provider to another provider.",
);
}
const { provider, client } = await createBedrockEmbeddingProvider({

View File

@@ -16,6 +16,7 @@ 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";
@@ -256,7 +257,13 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
files: memoryReferenceFiles,
toolNames: params.memoryToolNames,
memoryToolRouted: memoryToolsAvailable,
citationsMode: params.params.config?.memory?.citations,
citationsMode: params.params.config
? resolveAgentMemoryConfig(
params.params.config,
params.params.agentId ?? params.sessionAgentId,
)?.citations
: undefined,
agentId: params.params.agentId ?? params.sessionAgentId,
})
: undefined,
heartbeatCollaborationInstructions:
@@ -810,11 +817,13 @@ 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({
@@ -828,11 +837,13 @@ 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,7 +2,6 @@
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import { describe, expect, it } from "vitest";
import {
fitCodexProjectedContextForTurnStart,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@@ -198,34 +197,6 @@ 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,16 +8,10 @@ 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>";
@@ -29,9 +23,6 @@ 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;
@@ -53,25 +44,25 @@ export function projectContextEngineAssemblyForCodex(params: {
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
toolPayloadMode: params.toolPayloadMode ?? "elide",
});
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;
const promptText = renderedContext
? [
CONTEXT_HEADER,
CONTEXT_SAFETY_NOTE,
"",
CONTEXT_OPEN,
truncateOlderContext(renderedContext, maxRenderedContextChars),
CONTEXT_CLOSE,
"",
REQUEST_HEADER,
prompt,
].join("\n")
: prompt;
return {
...(params.systemPromptAddition?.trim()
? { developerInstructionAddition: params.systemPromptAddition.trim() }
: {}),
promptText,
...(promptContextRange ? { promptContextRange } : {}),
assembledMessages: params.assembledMessages,
prePromptMessageCount: params.originalHistoryMessages.length,
};
@@ -117,50 +108,6 @@ 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,102 +314,6 @@ 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,10 +55,6 @@ type ChildState = {
transcriptPollAttempt: number;
transcriptPollTimer?: ReturnType<typeof setTimeout>;
transcriptTerminal: boolean;
idle: boolean;
lastAgentMessage?: string;
lastAgentMessageAt?: number;
agentMessageCompletionDelivered: boolean;
pendingCompletion?: CodexNativeSubagentCompletion;
pendingCompletionEventAt?: number;
completionDeliveryAttempt: number;
@@ -215,10 +211,7 @@ 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 {
@@ -559,8 +552,6 @@ export class CodexNativeSubagentMonitor {
parentThreadId: normalizedParentThreadId,
transcriptPollAttempt: 0,
transcriptTerminal: false,
idle: false,
agentMessageCompletionDelivered: false,
completionDeliveryAttempt: 0,
};
this.childStates.set(normalizedChildThreadId, childState);
@@ -570,83 +561,6 @@ 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,9 +71,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -368,7 +366,9 @@ 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,7 +30,6 @@ 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,
@@ -2166,22 +2165,8 @@ 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";
@@ -2200,57 +2185,12 @@ 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 () => {
@@ -4847,28 +4787,11 @@ describe("runCodexAppServerAttempt", () => {
}
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
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,
),
userMessage("post-binding user context", 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({
@@ -4912,8 +4835,7 @@ describe("runCodexAppServerAttempt", () => {
const inputText =
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
"";
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 user context");
expect(inputText).toContain("post-binding assistant context");
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-1");

View File

@@ -44,6 +44,7 @@ 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,
@@ -139,8 +140,6 @@ import {
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
import {
type CodexProjectedContextRange,
fitCodexProjectedContextForTurnStart,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@@ -898,15 +897,8 @@ 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;
@@ -917,10 +909,8 @@ 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 (
@@ -940,7 +930,10 @@ export async function runCodexAppServerAttempt(
.map((tool) => tool.name)
.filter(isNonEmptyString),
),
citationsMode: params.config?.memory?.citations,
citationsMode: params.config
? resolveAgentMemoryConfig(params.config, sessionAgentId)?.citations
: undefined,
agentId: sessionAgentId,
modelId: params.modelId,
contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
providerId: params.provider,
@@ -960,7 +953,12 @@ export async function runCodexAppServerAttempt(
originalHistoryMessages: historyMessages,
prompt: params.prompt,
systemPromptAddition: assembled.systemPromptAddition,
maxRenderedContextChars: codexContextProjectionMaxChars,
maxRenderedContextChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
}),
toolPayloadMode: contextEngineProjection ? "preserve" : "elide",
});
const projectionDecision = contextEngineProjection
@@ -969,6 +967,7 @@ export async function runCodexAppServerAttempt(
expectedBinding: buildContextEngineBinding(
buildActiveRunAttemptParams(),
contextEngineProjection,
{ agentId: sessionAgentId },
),
projection: contextEngineProjection,
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
@@ -992,7 +991,6 @@ 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,
@@ -1021,31 +1019,12 @@ 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) => {
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
const decorateCodexTurnPromptText = (prompt: string) =>
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, {
@@ -1114,10 +1093,8 @@ export async function runCodexAppServerAttempt(
assembledMessages: newerVisibleMessages,
originalHistoryMessages: historyMessages,
prompt: params.prompt,
maxRenderedContextChars: codexContextProjectionMaxChars,
});
promptText = projection.promptText;
promptContextRange = projection.promptContextRange;
prePromptMessageCount = projection.prePromptMessageCount;
return true;
};
@@ -1194,11 +1171,6 @@ 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,6 +1,7 @@
// 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,
@@ -12,7 +13,6 @@ import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import {
shouldRotateCodexAppServerBindingForRuntime,
startOrResumeThread,
@@ -1296,7 +1296,8 @@ describe("Codex app-server thread lifecycle bindings", () => {
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.config = { memory: { citations: "inline" } } as never;
params.config = { memory: { citations: "on" },
} as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
@@ -1325,7 +1326,44 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
'"turnMaintenanceMode":"foreground"',
);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
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"');
});
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {

View File

@@ -8,6 +8,10 @@ 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";
@@ -341,7 +345,9 @@ 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),
buildContextEngineBinding(params.params, params.contextEngineProjection, {
agentId: params.agentId,
}),
);
const userMcpServersConfigPatch =
params.userMcpServersEnabled === false
@@ -964,6 +970,7 @@ function isTransientWebSearchRestriction(
export function buildContextEngineBinding(
params: EmbeddedRunAttemptParams,
projection?: CodexContextEngineThreadBootstrapProjection,
options?: { agentId?: string },
): CodexAppServerContextEngineBinding | undefined {
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
@@ -981,7 +988,10 @@ export function buildContextEngineBinding(
engineVersion: contextEngine.info.version,
ownsCompaction: contextEngine.info.ownsCompaction === true,
turnMaintenanceMode: contextEngine.info.turnMaintenanceMode,
citationsMode: resolveContextEngineCitationsMode(params.config),
citationsMode: resolveContextEngineCitationsMode(
params.config,
options?.agentId ?? params.agentId,
),
contextTokenBudget: params.contextTokenBudget,
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
@@ -1032,10 +1042,14 @@ function areContextEngineProjectionBindingsCompatible(
);
}
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
function resolveContextEngineCitationsMode(
config: unknown,
agentId?: string,
): JsonValue | undefined {
const rootConfig = isUnknownRecord(config) ? config : undefined;
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
const citations = memoryConfig?.citations;
const citations = rootConfig
? resolveAgentMemoryConfig(rootConfig as OpenClawConfig, agentId ?? "main")?.citations
: undefined;
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.*.memorySearch.remote.apiKey",
path: "agents.*.memory.search.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.*.memorySearch.remote.apiKey",
path: "agents.*.memory.search.remote.apiKey",
});
if (!trimmed) {
return undefined;

View File

@@ -15,9 +15,9 @@ native installs and updates.
## Configure
Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin
Set `memory.search.provider` to `local`. By default, the plugin
downloads and uses the EmbeddingGemma GGUF model. Configure
`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging
`memory.search.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 agents.defaults.memorySearch.provider to a remote embedding provider such as "openai", "ollama", "lmstudio", or "voyage".',
'Or set memory.search.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.*.memorySearch.remote.apiKey",
path: "agents.*.memory.search.remote.apiKey",
})
: undefined;
// memorySearch.remote is shared across primary + fallback providers.

View File

@@ -16,7 +16,11 @@ 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";
@@ -69,6 +73,14 @@ 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");
@@ -155,7 +167,7 @@ describe("memory-core doctor dreaming migration", () => {
);
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf8");
const migration = stateMigrations[0];
const migration = migrationById("memory-core-dreams-json-to-sqlite");
const preview = await migration.detectLegacyState(migrationParams());
expect(preview?.preview).toEqual([
expect.stringContaining("Memory Core daily ingestion"),
@@ -185,16 +197,21 @@ 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);
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir, "main");
expect(daily.files["memory/2026-04-05.md"]?.mtimeMs).toBe(1);
const session = await dreamingTesting.readSessionIngestionState(workspaceDir);
const session = await dreamingTesting.readSessionIngestionState(workspaceDir, "main");
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");
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
"2026-04-05T12:00:00.000Z",
"main",
);
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);
});
@@ -203,7 +220,9 @@ 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 stateMigrations[0].migrateLegacyState(migrationParams());
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
migrationParams(),
);
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
@@ -212,7 +231,11 @@ 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());
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
expect(recall.entries).toEqual({});
});
@@ -245,10 +268,14 @@ describe("memory-core doctor dreaming migration", () => {
);
const config = { agents: { list: [{ id: "main", default: true }] } };
const preview = await stateMigrations[0].detectLegacyState(migrationParams(config));
const preview = await migrationById("memory-core-dreams-json-to-sqlite").detectLegacyState(
migrationParams(config),
);
expect(preview?.preview).toEqual([expect.stringContaining("Memory Core short-term recall")]);
const result = await stateMigrations[0].migrateLegacyState(migrationParams(config));
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
migrationParams(config),
);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
@@ -256,7 +283,493 @@ 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");
const recall = await shortTermTesting.readRecallStore(
workspaceDir,
"2026-04-05T12:00:00.000Z",
"main",
);
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,8 +1,14 @@
// Memory Core doctor contract migrates shipped workspace dreaming state.
import fs from "node:fs/promises";
import path from "node:path";
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
import {
listAgentIds,
resolveDefaultAgentId,
resolveAgentWorkspaceDir,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
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,
@@ -18,6 +24,7 @@ import {
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
SHORT_TERM_RECALL_NAMESPACE,
configureMemoryCoreDreamingState,
migrateMemoryCoreWorkspaceNamespaceToAgent,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntry,
@@ -31,49 +38,223 @@ import {
type LegacySource = {
workspaceDir: string;
stateWorkspaceDir: string;
agentId: string;
label: string;
relativePath: string;
filePath: string;
};
function resolveConfiguredWorkspaces(config: unknown, env: NodeJS.ProcessEnv): string[] {
return resolveMemoryDreamingWorkspaces(
config as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
{ env },
).map((entry) => entry.workspaceDir);
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;
});
}
async function fileExists(filePath: string): Promise<boolean> {
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> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
const workspace = await fsRoot(workspaceDir);
const opened = await workspace.open(relativePath);
try {
return opened.stat.isFile();
} finally {
await opened.handle.close().catch(() => undefined);
}
} catch {
return false;
}
}
async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await fs.readFile(filePath, "utf8"));
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 archiveLegacySource(params: {
filePath: string;
workspaceDir: string;
relativePath: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
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)) {
params.warnings.push(
`Left migrated Memory Core ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
await workspace.move(params.relativePath, archivedRelativePath);
params.changes.push(`Archived Memory Core ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving Memory Core ${params.label} legacy source: ${String(err)}`,
`Failed archiving Memory Core ${params.label} source ${filePath}: ${String(err)}`,
);
}
}
@@ -83,39 +264,53 @@ async function collectLegacySources(
env: NodeJS.ProcessEnv,
): Promise<LegacySource[]> {
const sources: LegacySource[] = [];
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) {
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
const { workspaceDir, stateWorkspaceDir, agentId } = target;
for (const candidate of LEGACY_JSON_CANDIDATES) {
const filePath = path.join(workspaceDir, candidate.relativePath);
if (await fileExists(filePath)) {
sources.push({ workspaceDir, label: candidate.label, filePath });
if (await workspaceFileExists(workspaceDir, candidate.relativePath)) {
sources.push({
workspaceDir,
stateWorkspaceDir,
agentId,
label: candidate.label,
relativePath: candidate.relativePath,
filePath,
});
}
}
}
return sources;
}
async function workspaceHasRows(namespace: string, workspaceDir: string): Promise<boolean> {
return (await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir })).length > 0;
async function workspaceHasRows(
namespace: string,
stateWorkspaceDir: string,
agentId: string,
): Promise<boolean> {
return (
(await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir: stateWorkspaceDir, agentId }))
.length > 0
);
}
async function migrateDailyIngestion(source: LegacySource): Promise<number> {
const state = normalizeDailyIngestionState(await readJsonFile(source.filePath));
const state = normalizeDailyIngestionState(
await readJsonFile(source.workspaceDir, source.relativePath),
);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
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.filePath));
const state = normalizeSessionIngestionState(
await readJsonFile(source.workspaceDir, source.relativePath),
);
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
Array.from(
{ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) },
@@ -135,12 +330,14 @@ async function migrateSessionIngestion(source: LegacySource): Promise<number> {
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
entries: seenEntries,
}),
]);
@@ -149,16 +346,21 @@ 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.filePath), nowIso);
const state = normalizeShortTermRecallStore(
await readJsonFile(source.workspaceDir, source.relativePath),
nowIso,
);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
key: "recall",
value: { updatedAt: state.updatedAt },
}),
@@ -168,16 +370,21 @@ 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.filePath), nowIso);
const state = normalizeShortTermPhaseSignalStore(
await readJsonFile(source.workspaceDir, source.relativePath),
nowIso,
);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.workspaceDir,
workspaceDir: source.stateWorkspaceDir,
agentId: source.agentId,
key: "phase",
value: { updatedAt: state.updatedAt },
}),
@@ -211,31 +418,159 @@ 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 = await collectLegacySources(params.config, params.env);
if (sources.length === 0) {
const [sources, sharedWarnings] = await Promise.all([
collectLegacySources(params.config, params.env),
collectSharedJsonMigrationWarnings(params.config, params.env),
]);
if (sources.length === 0 && sharedWarnings.length === 0) {
return null;
}
return {
preview: sources.map(
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
),
preview: [
...sources.map(
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
),
...sharedWarnings.map((warning) => `- ${warning}`),
],
};
},
async migrateLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const changes: string[] = [];
const warnings: string[] = [];
const warnings = await collectSharedJsonMigrationWarnings(params.config, params.env);
for (const source of await collectLegacySources(params.config, params.env)) {
const targetHasRows = (
await Promise.all(
targetNamespacesForSource(source.label).map((namespace) =>
workspaceHasRows(namespace, source.workspaceDir),
workspaceHasRows(namespace, source.stateWorkspaceDir, source.agentId),
),
)
).some(Boolean);
@@ -258,7 +593,8 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
`Migrated Memory Core ${source.label} -> SQLite plugin state (${imported} row(s))`,
);
await archiveLegacySource({
filePath: source.filePath,
workspaceDir: source.workspaceDir,
relativePath: source.relativePath,
label: source.label,
changes,
warnings,
@@ -267,4 +603,53 @@ 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,5 +201,11 @@
}
}
}
},
"configContracts": {
"compatibilityMigrationPaths": [
"plugins.entries.memory-core.config",
"plugins.slots.memory"
]
}
}

View File

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

View File

@@ -0,0 +1,12 @@
// 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,6 +5,7 @@ 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";
@@ -113,8 +114,8 @@ type LoadedMemoryCommandConfig = {
function getMemoryCommandSecretTargetIds(): Set<string> {
return new Set([
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
"memory.search.remote.apiKey",
"agents.list[].memory.search.remote.apiKey",
]);
}
@@ -148,9 +149,11 @@ function emitMemorySecretResolveDiagnostics(
}
}
function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
function resolveMemoryPluginConfig(
cfg: OpenClawConfig,
agentId = resolveDefaultAgentId(cfg),
): Record<string, unknown> {
return resolveMemoryCorePluginConfig(cfg, agentId) ?? {};
}
const DAILY_MEMORY_FILE_NAME_RE = /^(\d{4}-\d{2}-\d{2})(?:-[^/]+)?\.md$/i;
@@ -183,6 +186,7 @@ async function createHistoricalRemHarnessWorkspace(params: {
inputPath: string;
remLimit: number;
nowMs: number;
agentId?: string;
timezone?: string;
}): Promise<{
workspaceDir: string;
@@ -209,6 +213,7 @@ async function createHistoricalRemHarnessWorkspace(params: {
filePaths: workspaceSourceFiles,
limit: params.remLimit,
nowMs: params.nowMs,
agentId: params.agentId,
timezone: params.timezone,
});
return {
@@ -221,9 +226,9 @@ async function createHistoricalRemHarnessWorkspace(params: {
};
}
function formatDreamingSummary(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryPluginConfig(cfg);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
function formatDreamingSummary(cfg: OpenClawConfig, agentId: string): string {
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg, agentId });
if (!dreaming.enabled) {
return "off";
}
@@ -788,17 +793,18 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
let dreamingAudit: DreamingArtifactsAuditSummary | undefined;
let dreamingRepair: RepairDreamingArtifactsResult | undefined;
if (workspaceDir) {
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
if (opts.fix && dreamingAudit.issues.some((issue) => issue.fixable)) {
dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
dreamingRepair = await repairDreamingArtifacts({ workspaceDir, agentId });
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
}
if (opts.fix) {
repair = await repairShortTermPromotionArtifacts({ workspaceDir });
repair = await repairShortTermPromotionArtifacts({ workspaceDir, agentId });
}
const customQmd = asRecord(asRecord(status.custom)?.qmd);
audit = await auditShortTermPromotionArtifacts({
workspaceDir,
agentId,
qmd:
status.backend === "qmd"
? {
@@ -881,7 +887,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))}`,
`${label("Dreaming")} ${info(formatDreamingSummary(cfg, agentId))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state =
@@ -1245,14 +1251,16 @@ 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);
const memoryPluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const dreamingEnabled = resolveMemoryDreamingConfig({
pluginConfig: memoryPluginConfig,
cfg,
agentId,
}).enabled;
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: memoryPluginConfig,
cfg,
agentId,
});
await withMemoryManagerForAgent({
cfg,
@@ -1280,6 +1288,7 @@ export async function runMemorySearch(
if (dreamingEnabled) {
void recordShortTermRecalls({
workspaceDir,
agentId,
query,
results,
timezone: dreaming.timezone,
@@ -1335,8 +1344,9 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryPluginConfig(cfg),
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory promote requires a resolvable workspace directory.");
@@ -1348,6 +1358,7 @@ 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,
@@ -1367,6 +1378,7 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
try {
applyResult = await applyShortTermPromotions({
workspaceDir,
agentId,
candidates,
limit: opts.limit,
minScore: opts.minScore ?? dreaming.minScore,
@@ -1383,11 +1395,12 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
}
}
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
const storePath = resolveShortTermRecallStorePath(workspaceDir, agentId);
const lockPath = resolveShortTermRecallLockPath(workspaceDir, agentId);
const customQmd = asRecord(asRecord(status.custom)?.qmd);
const audit = await auditShortTermPromotionArtifacts({
workspaceDir,
agentId,
qmd:
status.backend === "qmd"
? {
@@ -1520,8 +1533,9 @@ export async function runMemoryPromoteExplain(
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const dreaming = resolveShortTermPromotionDreamingConfig({
pluginConfig: resolveMemoryPluginConfig(cfg),
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory promote-explain requires a resolvable workspace directory.");
@@ -1533,6 +1547,7 @@ export async function runMemoryPromoteExplain(
try {
candidates = await rankShortTermPromotionCandidates({
workspaceDir,
agentId,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
@@ -1626,7 +1641,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
run: async (manager) => {
const status = manager.status();
const managerWorkspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg);
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
if (!managerWorkspaceDir && !opts.path) {
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
process.exitCode = 1;
@@ -1635,6 +1650,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig,
cfg,
agentId,
});
const nowMs = Date.now();
let workspaceDir = managerWorkspaceDir ?? "";
@@ -1649,6 +1665,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
inputPath: opts.path,
remLimit: remConfig.limit,
nowMs,
agentId,
timezone: remConfig.timezone,
});
workspaceDir = historical.workspaceDir;
@@ -1676,6 +1693,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
const preview = await previewRemHarness({
workspaceDir,
cfg,
agentId,
pluginConfig,
grounded: Boolean(opts.grounded),
groundedInputPaths,
@@ -1806,10 +1824,11 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
run: async (manager) => {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg);
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig,
cfg,
agentId,
});
if (!workspaceDir) {
defaultRuntime.error("Memory rem-backfill requires a resolvable workspace directory.");
@@ -1819,10 +1838,10 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
if (opts.rollback || opts.rollbackShortTerm) {
const diaryRollback = opts.rollback
? await removeBackfillDiaryEntries({ workspaceDir })
? await removeBackfillDiaryEntries({ workspaceDir, agentId })
: null;
const shortTermRollback = opts.rollbackShortTerm
? await removeGroundedShortTermCandidates({ workspaceDir })
? await removeGroundedShortTermCandidates({ workspaceDir, agentId })
: null;
if (opts.json) {
defaultRuntime.writeJson({
@@ -1934,18 +1953,20 @@ 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 });
const cleared = await removeGroundedShortTermCandidates({ workspaceDir, agentId });
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 = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret
const inactiveMemorySecretDiagnostic = "memory.search.remote.apiKey inactive"; // pragma: allowlist secret
function firstMockCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown {
const call = mock.mock.calls[0];
@@ -426,6 +426,52 @@ 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({
@@ -488,12 +534,10 @@ describe("memory cli", () => {
it("resolves configured memory SecretRefs through gateway snapshot", async () => {
const config = {
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
memory: {
search: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
},
},
@@ -516,8 +560,8 @@ describe("memory cli", () => {
expect(secretRefsCall.commandName).toBe("memory status");
expect(secretRefsCall.targetIds).toStrictEqual(
new Set([
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
"memory.search.remote.apiKey",
"agents.list[].memory.search.remote.apiKey",
]),
);
});
@@ -717,6 +761,7 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router vlan",
results: [
{
@@ -748,33 +793,41 @@ 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: "",
},
},
},
});
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,
},
"main",
);
const close = vi.fn(async () => {});
mockManager({
@@ -787,7 +840,11 @@ describe("memory cli", () => {
await runMemoryCli(["status", "--fix"]);
expectLogged(log, "Repair: rewrote store");
const audit = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
const audit = await shortTermTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
const repaired = audit as {
entries: Record<string, { conceptTags?: string[] }>;
};
@@ -798,15 +855,19 @@ 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({
@@ -832,7 +893,14 @@ describe("memory cli", () => {
it("repairs contaminated dreaming artifacts during status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
const sessionCorpusDir = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"session-corpus",
);
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(
path.join(sessionCorpusDir, "2026-04-11.txt"),
@@ -842,12 +910,16 @@ describe("memory cli", () => {
].join("\n"),
"utf-8",
);
await fs.writeFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
"utf-8",
const dreamsPath = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"DREAMS.md",
);
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "# Dream Diary\n", "utf-8");
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
await fs.writeFile(dreamsPath, "# Dream Diary\n", "utf-8");
const close = vi.fn(async () => {});
mockManager({
@@ -862,12 +934,7 @@ describe("memory cli", () => {
expectLogged(log, "Dream repair: archived session corpus");
expectLogged(log, "Dream archive:");
await expectPathMissing(sessionCorpusDir);
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",
);
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary");
expect(close).toHaveBeenCalled();
});
});
@@ -1287,6 +1354,7 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router notes",
results: [
{
@@ -1331,6 +1399,7 @@ describe("memory cli", () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router notes",
results: [
{
@@ -1371,6 +1440,7 @@ describe("memory cli", () => {
);
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "weather plans",
nowMs,
results: [
@@ -1558,7 +1628,10 @@ describe("memory cli", () => {
await runMemoryCli(["rem-backfill", "--path", historyPath]);
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
const dreams = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf-8",
);
expect(dreams).toContain("openclaw:dreaming:backfill-entry");
expect(dreams).toContain(`source=${historyPath}`);
expect(dreams).toContain("January 1, 2025");
@@ -1602,7 +1675,10 @@ 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, "DREAMS.md"), "utf-8");
const dreams = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
"utf-8",
);
expect(dreams).toContain(`source=${sluggedPath}`);
expect(dreams).toContain(`source=${secondSluggedPath}`);
expect(dreams).toContain("Happy Together");
@@ -1651,7 +1727,7 @@ describe("memory cli", () => {
await runMemoryCli(["rem-backfill", "--path", historyPath, "--stage-short-term"]);
const entries = await readShortTermRecallEntries({ workspaceDir });
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
expect(entries).toHaveLength(1);
expect(entries[0]?.snippet).toContain("Happy Together");
expect(entries[0]?.groundedCount).toBe(3);
@@ -1688,7 +1764,7 @@ describe("memory cli", () => {
});
await runMemoryCli(["rem-backfill", "--rollback-short-term"]);
const entries = await readShortTermRecallEntries({ workspaceDir });
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
expect(entries).toHaveLength(0);
expect(close).toHaveBeenCalled();
});
@@ -1896,7 +1972,15 @@ describe("memory cli", () => {
it("rolls back grounded rem backfill entries from DREAMS.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
const dreamsPath = path.join(
workspaceDir,
"memory",
".dreams",
"agents",
"main",
"DREAMS.md",
);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
await fs.writeFile(
dreamsPath,
[
@@ -1959,6 +2043,7 @@ describe("memory cli", () => {
]);
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "network setup",
results: [
{
@@ -2007,6 +2092,7 @@ describe("memory cli", () => {
const nowMs = Date.now();
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "router vlan",
nowMs: nowMs - 2 * dayMs,
results: [
@@ -2022,6 +2108,7 @@ describe("memory cli", () => {
});
await recordShortTermRecalls({
workspaceDir,
agentId: "main",
query: "glacier backup",
nowMs: nowMs - dayMs,
results: [
@@ -2084,13 +2171,11 @@ describe("memory cli", () => {
},
]);
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
memory: {
extensions: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
dreaming: {
enabled: true,
},
},
},
@@ -2105,7 +2190,7 @@ describe("memory cli", () => {
await runMemoryCli(["search", "glacier", "--json"]);
const entries = await waitFor(async () => {
const recalled = await readShortTermRecallEntries({ workspaceDir });
const recalled = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
expect(recalled).toHaveLength(1);
return recalled;
});
@@ -2189,7 +2274,7 @@ describe("memory cli", () => {
}
expect(payload.results).toHaveLength(1);
expect(payload.results[0]?.path).toBe("memory/2026-04-03.md");
expect(await readShortTermRecallEntries({ workspaceDir })).toHaveLength(0);
expect(await readShortTermRecallEntries({ workspaceDir, agentId: "main" })).toHaveLength(0);
expect(close).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,91 @@
// 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

@@ -0,0 +1,159 @@
// 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,9 +16,21 @@ function asRecord(value: unknown): Record<string, unknown> | null {
}
function resolveStoredDreaming(config: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(config.plugins?.entries?.["memory-core"]);
const pluginConfig = asRecord(entry?.config);
return asRecord(pluginConfig?.dreaming) ?? {};
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) ?? {};
}
function createHarness(initialConfig: OpenClawConfig = {}) {
@@ -75,7 +87,7 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
function createCommandContext(
args?: string,
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
overrides?: Partial<Pick<PluginCommandContext, "agentId" | "gatewayClientScopes" | "sessionKey">>,
): PluginCommandContext {
return {
channel: "webchat",
@@ -83,7 +95,9 @@ 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,
@@ -110,20 +124,18 @@ describe("memory-core /dreaming command", () => {
);
});
it("persists global enablement under plugins.entries.memory-core.config.dreaming.enabled", async () => {
it("persists default-agent enablement under memory.extensions.memory-core", async () => {
const { command, runtime, getRuntimeConfig } = createHarness({
plugins: {
entries: {
memory: {
extensions: {
"memory-core": {
config: {
dreaming: {
phases: {
deep: {
minScore: 0.9,
},
dreaming: {
phases: {
deep: {
minScore: 0.9,
},
frequency: "0 */6 * * *",
},
frequency: "0 */6 * * *",
},
},
},
@@ -139,6 +151,82 @@ 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();
@@ -181,13 +269,11 @@ describe("memory-core /dreaming command", () => {
it("returns status without mutating config", async () => {
const { command, runtime } = createHarness({
plugins: {
entries: {
memory: {
extensions: {
"memory-core": {
config: {
dreaming: {
frequency: "15 */8 * * *",
},
dreaming: {
frequency: "15 */8 * * *",
},
},
},
@@ -195,6 +281,7 @@ describe("memory-core /dreaming command", () => {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
});

View File

@@ -1,38 +1,53 @@
// 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 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,
},
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,
},
};
const memory = { ...existingAgentMemory, extensions };
if (agentIndex >= 0) {
agentList[agentIndex] = { ...agentList[agentIndex], memory };
return {
...cfg,
agents: {
...cfg.agents,
list: agentList,
},
};
}
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
memory,
};
}
@@ -48,13 +63,12 @@ function formatPhaseGuide(): string {
].join("\n");
}
function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
function formatStatus(cfg: OpenClawConfig, agentId: string): string {
const dreaming = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
agentId,
});
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const deep = resolveShortTermPromotionDreamingConfig({ cfg, agentId });
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
return [
@@ -88,13 +102,18 @@ 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)) };
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
}
if (firstToken === "status") {
return { text: formatStatus(currentConfig) };
return { text: formatStatus(currentConfig, agentId) };
}
if (firstToken === "on" || firstToken === "off") {
@@ -102,10 +121,16 @@ 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, enabled);
const nextConfig = updateDreamingEnabledInConfig(draft, agentId, enabled);
if (!nextConfig) {
throw new Error(`Dreaming config target disappeared: ${agentId}`);
}
Object.assign(draft, nextConfig);
},
});
@@ -113,12 +138,12 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
text: [
`Dreaming ${enabled ? "enabled" : "disabled"}.`,
"",
formatStatus(committed.nextConfig),
formatStatus(committed.nextConfig, agentId),
].join("\n"),
};
}
return { text: formatUsage(formatStatus(currentConfig)) };
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
}
export function registerDreamingCommand(api: OpenClawPluginApi): void {

View File

@@ -5,7 +5,12 @@ 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 { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
assertNoSymlinkParents,
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 -->";
@@ -19,7 +24,17 @@ type DreamsFileLockEntry = {
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
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],
);
}
for (const name of DREAMS_FILENAMES) {
const target = path.join(workspaceDir, name);
try {
@@ -79,7 +94,66 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
}
}
async function writeDreamsFileAtomic(dreamsPath: string, content: 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 });
await assertSafeDreamsPath(dreamsPath);
await replaceFileAtomic({
filePath: dreamsPath,
@@ -93,6 +167,7 @@ async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promi
export async function updateDreamsFile<T>(params: {
workspaceDir: string;
agentId?: string;
updater: (
existing: string,
dreamsPath: string,
@@ -104,8 +179,7 @@ export async function updateDreamsFile<T>(params: {
shouldWrite?: boolean;
};
}): Promise<T> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const dreamsPath = await resolveDreamsPath(params.workspaceDir, params.agentId);
let lockEntry = dreamsFileLocks.get(dreamsPath);
if (!lockEntry) {
lockEntry = { withLock: createAsyncLock(), refs: 0 };
@@ -114,10 +188,18 @@ 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(dreamsPath, content.endsWith("\n") ? content : `${content}\n`);
await writeDreamsFileAtomic(
params.workspaceDir,
dreamsPath,
content.endsWith("\n") ? content : `${content}\n`,
);
}
return result;
});
@@ -131,11 +213,13 @@ 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,4 +273,51 @@ 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,7 +11,12 @@ import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { updateDeepDreamsFile } from "./dreaming-dreams-file.js";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
ensureDreamingArtifactDirectory,
updateDeepDreamsFile,
writeDreamingArtifactFile,
} from "./dreaming-dreams-file.js";
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
@@ -35,8 +40,24 @@ function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
};
}
function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
function resolveDailyMemoryPath(
workspaceDir: string,
epochMs: number,
timezone?: string,
agentId?: 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`);
}
@@ -45,8 +66,21 @@ 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`);
}
@@ -60,6 +94,7 @@ function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean {
export async function writeDailyDreamingPhaseBlock(params: {
workspaceDir: string;
agentId?: string;
phase: Exclude<MemoryDreamingPhaseName, "deep">;
bodyLines: string[];
nowMs?: number;
@@ -72,8 +107,16 @@ export async function writeDailyDreamingPhaseBlock(params: {
let reportPath: string | undefined;
if (shouldWriteInline(params.storage)) {
inlinePath = resolveDailyMemoryPath(params.workspaceDir, nowMs, params.timezone);
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
inlinePath = resolveDailyMemoryPath(
params.workspaceDir,
nowMs,
params.timezone,
params.agentId,
);
await ensureDreamingArtifactDirectory({
workspaceDir: params.workspaceDir,
filePath: inlinePath,
});
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
@@ -88,7 +131,11 @@ export async function writeDailyDreamingPhaseBlock(params: {
endMarker: markers.end,
body,
});
await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: inlinePath,
content: withTrailingNewline(updated),
});
}
if (shouldWriteSeparate(params.storage)) {
@@ -97,26 +144,34 @@ 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 fs.writeFile(reportPath, report, "utf-8");
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: reportPath,
content: report,
});
}
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,
});
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,
);
return {
...(inlinePath ? { inlinePath } : {}),
@@ -126,6 +181,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
export async function writeDeepDreamingReport(params: {
workspaceDir: string;
agentId?: string;
bodyLines: string[];
nowMs?: number;
timezone?: string;
@@ -135,22 +191,36 @@ 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);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
reportPath = resolveSeparateReportPath(
params.workspaceDir,
"deep",
nowMs,
params.timezone,
params.agentId,
);
await writeDreamingArtifactFile({
workspaceDir: params.workspaceDir,
filePath: reportPath,
content: `# Deep Sleep\n\n${body}\n`,
});
}
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
phase: "deep",
inlinePath,
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
});
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,
);
return reportPath;
}

View File

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

View File

@@ -20,6 +20,7 @@ import {
} from "./dreaming-phases.js";
import { previewRemHarness } from "./rem-harness.js";
import {
readShortTermRecallEntries,
rankShortTermPromotionCandidates,
recordShortTermRecalls,
testing as shortTermTesting,
@@ -117,6 +118,33 @@ 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,
@@ -127,35 +155,39 @@ function createHarness(
warn: vi.fn(),
error: vi.fn(),
};
const canonicalConfig = normalizeDreamingTestConfig(config);
const resolvedConfig = workspaceDir
? {
...config,
...canonicalConfig,
agents: {
...config.agents,
...canonicalConfig.agents,
defaults: {
...config.agents?.defaults,
...canonicalConfig.agents?.defaults,
workspace: workspaceDir,
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
},
},
}
: {
...config,
...canonicalConfig,
agents: {
...config.agents,
...canonicalConfig.agents,
defaults: {
...config.agents?.defaults,
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
...canonicalConfig.agents?.defaults,
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
},
},
};
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const resolvedPluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const beforeAgentReply = async (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => {
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: resolvedConfig });
const light = resolveMemoryLightDreamingConfig({
pluginConfig: resolvedPluginConfig,
cfg: resolvedConfig,
});
const lightResult = await testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
@@ -170,7 +202,10 @@ function createHarness(
if (lightResult) {
return lightResult;
}
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: resolvedConfig });
const rem = resolveMemoryRemDreamingConfig({
pluginConfig: resolvedPluginConfig,
cfg: resolvedConfig,
});
return await testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
@@ -323,11 +358,12 @@ 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: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
logger,
subagent,
nowMs,
@@ -389,12 +425,13 @@ describe("memory-core dreaming phases", () => {
warn: vi.fn(),
error: vi.fn(),
};
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
await expect(
runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
logger,
subagent,
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
@@ -559,11 +596,12 @@ describe("memory-core dreaming phases", () => {
warn: vi.fn(),
error: vi.fn(),
};
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
await runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
cfg: canonicalTestConfig,
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
logger,
subagent,
nowMs,
@@ -1048,6 +1086,168 @@ 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();
@@ -1093,7 +1293,10 @@ describe("memory-core dreaming phases", () => {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "agi-ceo", workspace: subagentWorkspaceDir }],
list: [
{ id: "main", workspace: workspaceDir },
{ id: "agi-ceo", workspace: subagentWorkspaceDir },
],
},
plugins: {
entries: {
@@ -2269,12 +2472,14 @@ describe("memory-core dreaming phases", () => {
const { beforeAgentReply } = createHarness(
{
memory: {
search: {
enabled: false,
},
},
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
enabled: false,
},
},
},
plugins: {

View File

@@ -19,6 +19,7 @@ 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";
@@ -103,7 +104,6 @@ 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,10 +494,14 @@ function normalizeMemoryDay(value: unknown): string | undefined {
return MEMORY_DAY_RE.test(day) ? day : undefined;
}
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
async function readDailyIngestionState(
workspaceDir: string,
agentId?: string,
): Promise<DailyIngestionState> {
const entries = await readMemoryCoreWorkspaceEntries<DailyIngestionFileState>({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
agentId,
});
return normalizeDailyIngestionState({
version: 1,
@@ -508,10 +512,12 @@ async function readDailyIngestionState(workspaceDir: string): Promise<DailyInges
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 })),
});
}
@@ -597,15 +603,20 @@ export function normalizeSessionIngestionState(raw: unknown): SessionIngestionSt
return { version: 3, files, seenMessages };
}
async function readSessionIngestionState(workspaceDir: string): Promise<SessionIngestionState> {
async function readSessionIngestionState(
workspaceDir: string,
agentId?: 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[]> = {};
@@ -634,6 +645,7 @@ async function readSessionIngestionState(workspaceDir: string): Promise<SessionI
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) => {
@@ -651,11 +663,13 @@ 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,
}),
]);
@@ -766,18 +780,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 relativePath = path.posix.join("memory", ".dreams", "session-corpus", `${params.day}.txt`);
const absolutePath = path.join(
params.workspaceDir,
SESSION_CORPUS_RELATIVE_DIR,
`${params.day}.txt`,
);
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("/"));
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
let existing = "";
try {
@@ -816,6 +830,7 @@ async function appendSessionCorpusLines(params: {
async function collectSessionIngestionBatches(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
lookbackDays: number;
nowMs: number;
@@ -831,11 +846,13 @@ async function collectSessionIngestionBatches(params: {
Object.keys(params.state.seenMessages).length > 0,
};
}
const agentIds = resolveSessionAgentsForWorkspace({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
primaryWorkspaceDir: params.primaryWorkspaceDir,
});
const agentIds = params.agentId
? [params.agentId]
: 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> = {};
@@ -1100,6 +1117,7 @@ async function collectSessionIngestionBatches(params: {
}
const results = await appendSessionCorpusLines({
workspaceDir: params.workspaceDir,
agentId: params.agentId ? normalizeAgentId(params.agentId) : undefined,
day,
lines,
});
@@ -1118,15 +1136,18 @@ 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 state = await readSessionIngestionState(params.workspaceDir);
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const state = await readSessionIngestionState(params.workspaceDir, agentId);
const collected = await collectSessionIngestionBatches({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.lookbackDays,
nowMs: params.nowMs,
@@ -1137,6 +1158,7 @@ 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",
@@ -1147,7 +1169,7 @@ async function ingestSessionTranscriptSignals(params: {
});
}
if (collected.changed) {
await writeSessionIngestionState(params.workspaceDir, collected.nextState);
await writeSessionIngestionState(params.workspaceDir, collected.nextState, agentId);
}
}
@@ -1288,12 +1310,14 @@ async function collectDailyIngestionBatches(params: {
async function ingestDailyMemorySignals(params: {
workspaceDir: string;
agentId?: string;
lookbackDays: number;
limit: number;
nowMs: number;
timezone?: string;
}): Promise<void> {
const state = await readDailyIngestionState(params.workspaceDir);
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const state = await readDailyIngestionState(params.workspaceDir, agentId);
const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone);
const collected = await collectDailyIngestionBatches({
workspaceDir: params.workspaceDir,
@@ -1306,6 +1330,7 @@ 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",
@@ -1316,7 +1341,7 @@ async function ingestDailyMemorySignals(params: {
});
}
if (collected.changed) {
await writeDailyIngestionState(params.workspaceDir, collected.nextState);
await writeDailyIngestionState(params.workspaceDir, collected.nextState, agentId);
}
}
@@ -1325,6 +1350,7 @@ export async function seedHistoricalDailyMemorySignals(params: {
filePaths: string[];
limit: number;
nowMs: number;
agentId?: string;
timezone?: string;
}): Promise<{
importedFileCount: number;
@@ -1418,6 +1444,7 @@ 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",
@@ -1672,6 +1699,7 @@ export function previewRemDreaming(params: {
async function runLightDreaming(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
config: LightDreamingConfig;
logger: Logger;
@@ -1680,8 +1708,10 @@ 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,
@@ -1690,6 +1720,7 @@ async function runLightDreaming(params: {
await ingestSessionTranscriptSignals({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.config.lookbackDays,
nowMs,
@@ -1698,7 +1729,11 @@ async function runLightDreaming(params: {
const recentEntries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
entries: await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
agentId,
nowMs,
}),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
@@ -1715,6 +1750,7 @@ async function runLightDreaming(params: {
);
const recentDiaryEntries = await readRecentDreamDiaryEntries({
workspaceDir: params.workspaceDir,
agentId,
limit: LIGHT_DIARY_HISTORY_LIMIT,
});
const entries = prioritizeLightEntriesByDiaryCoverage(rankedEntries, recentDiaryEntries);
@@ -1722,6 +1758,7 @@ async function runLightDreaming(params: {
const bodyLines = buildLightDreamingBody(capped);
await writeDailyDreamingPhaseBlock({
workspaceDir: params.workspaceDir,
agentId,
phase: "light",
bodyLines,
nowMs,
@@ -1730,6 +1767,7 @@ async function runLightDreaming(params: {
});
await recordDreamingPhaseSignals({
workspaceDir: params.workspaceDir,
agentId,
phase: "light",
keys: capped.map((entry) => entry.key),
nowMs,
@@ -1753,6 +1791,7 @@ async function runLightDreaming(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1763,6 +1802,7 @@ async function runLightDreaming(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1776,6 +1816,7 @@ async function runLightDreaming(params: {
async function runRemDreaming(params: {
workspaceDir: string;
cfg?: DreamingHostConfig;
agentId?: string;
primaryWorkspaceDir?: string;
config: RemDreamingConfig;
logger: Logger;
@@ -1784,8 +1825,10 @@ 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,
@@ -1794,6 +1837,7 @@ async function runRemDreaming(params: {
await ingestSessionTranscriptSignals({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
agentId,
primaryWorkspaceDir: params.primaryWorkspaceDir,
lookbackDays: params.config.lookbackDays,
nowMs,
@@ -1802,7 +1846,11 @@ async function runRemDreaming(params: {
const allEntries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
entries: await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
agentId,
nowMs,
}),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
@@ -1811,6 +1859,7 @@ 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 =
@@ -1823,6 +1872,7 @@ async function runRemDreaming(params: {
});
await writeDailyDreamingPhaseBlock({
workspaceDir: params.workspaceDir,
agentId,
phase: "rem",
bodyLines: preview.bodyLines,
nowMs,
@@ -1832,12 +1882,14 @@ 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,
@@ -1868,6 +1920,7 @@ async function runRemDreaming(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1878,6 +1931,7 @@ async function runRemDreaming(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
agentId,
data,
nowMs,
timezone: params.config.timezone,
@@ -1892,6 +1946,7 @@ 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;
@@ -1903,11 +1958,13 @@ 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,
@@ -1919,11 +1976,13 @@ 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,6 +216,102 @@ 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,11 +3,14 @@ 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";
@@ -61,7 +64,29 @@ function requireAbsoluteWorkspaceDir(rawWorkspaceDir: string): string {
return path.resolve(trimmed);
}
async function resolveExistingDreamsPath(workspaceDir: string): Promise<string | undefined> {
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;
}
}
for (const fileName of DREAMS_FILENAMES) {
const candidate = path.join(workspaceDir, fileName);
try {
@@ -117,41 +142,77 @@ 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 fs.mkdir(params.archiveDir, { recursive: true });
await ensureSafeArchiveDirectory(params.workspaceDir, params.archiveDir);
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): Promise<void> {
async function clearSessionIngestionState(workspaceDir: string, agentId?: 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 dreamsPath = await resolveExistingDreamsPath(workspaceDir);
const sessionCorpusDir = path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
const sessionIngestionPath = path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
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 issues: DreamingArtifactsAuditIssue[] = [];
let sessionCorpusFileCount = 0;
let suspiciousSessionCorpusFileCount = 0;
@@ -196,17 +257,19 @@ export async function auditDreamingArtifacts(params: {
}
}
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,
});
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,
});
}
}
}
@@ -222,6 +285,7 @@ export async function auditDreamingArtifacts(params: {
const entries = await readMemoryCoreWorkspaceEntries({
namespace,
workspaceDir,
agentId,
});
if (entries.length > 0) {
sessionIngestionExists = true;
@@ -256,10 +320,12 @@ 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;
@@ -278,7 +344,11 @@ export async function repairDreamingArtifacts(params: {
const archivePathIfPresent = async (targetPath: string): Promise<string | null> => {
try {
return await moveToArchive({ targetPath, archiveDir: ensureArchiveDir() });
return await moveToArchive({
workspaceDir,
targetPath,
archiveDir: ensureArchiveDir(),
});
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
return null;
@@ -286,16 +356,18 @@ export async function repairDreamingArtifacts(params: {
};
const sessionCorpusDestination = await archivePathIfPresent(
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
agentId
? path.join(workspaceDir, "memory", ".dreams", "agents", agentId, "session-corpus")
: path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
);
if (sessionCorpusDestination) {
archivedSessionCorpus = true;
archivedPaths.push(sessionCorpusDestination);
}
const sessionIngestionDestination = await archivePathIfPresent(
path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH),
);
const sessionIngestionDestination = agentId
? null
: await archivePathIfPresent(path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH));
if (sessionIngestionDestination) {
archivedSessionIngestion = true;
archivedPaths.push(sessionIngestionDestination);
@@ -303,7 +375,7 @@ export async function repairDreamingArtifacts(params: {
if (sessionCorpusDestination || sessionIngestionDestination) {
try {
await clearSessionIngestionState(workspaceDir);
await clearSessionIngestionState(workspaceDir, agentId);
} catch (err) {
warnings.push(
`Failed clearing dreaming session-ingestion SQLite state: ${
@@ -314,7 +386,7 @@ export async function repairDreamingArtifacts(params: {
}
if (params.archiveDiary) {
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
const dreamsPath = await resolveExistingDreamsPath(workspaceDir, agentId);
if (dreamsPath) {
const dreamsDestination = await archivePathIfPresent(dreamsPath);
if (dreamsDestination) {

View File

@@ -5,6 +5,7 @@ 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";
@@ -14,6 +15,7 @@ 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;
@@ -27,6 +29,7 @@ type WorkspaceValue<T> = {
version: 1;
workspaceKey: string;
workspaceDir: string;
agentId?: string;
key: string;
value: T;
};
@@ -36,6 +39,7 @@ export type MemoryCoreWorkspaceEntry<T> = { key: string; value: T };
type MemoryCoreWorkspaceParams = {
namespace: string;
workspaceDir: string;
agentId?: string;
};
type WriteMemoryCoreWorkspaceEntriesParams<T> = MemoryCoreWorkspaceParams & {
@@ -80,18 +84,34 @@ export function normalizeMemoryCoreWorkspaceKey(workspaceDir: string): string {
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
export function memoryCoreWorkspaceStateKey(workspaceDir: string): string {
return createHash("sha256").update(normalizeMemoryCoreWorkspaceKey(workspaceDir)).digest("hex");
function normalizeMemoryCoreAgentId(agentId: string | undefined): string | undefined {
return agentId?.trim() ? normalizeAgentId(agentId) : undefined;
}
export function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string {
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir);
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);
const itemKey = createHash("sha256").update(logicalKey).digest("hex");
return `${workspaceKey}:${itemKey}`;
}
export function memoryCoreStateReference(namespace: string, workspaceDir: string): string {
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir)}`;
export function memoryCoreStateReference(
namespace: string,
workspaceDir: string,
agentId?: string,
): string {
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir, agentId)}`;
}
function openWorkspaceStore<T>(namespace: string): PluginStateKeyedStore<WorkspaceValue<T>> {
@@ -108,7 +128,7 @@ export function readMemoryCoreWorkspaceEntries<T>(
export async function readMemoryCoreWorkspaceEntries(
params: MemoryCoreWorkspaceParams,
): Promise<Array<MemoryCoreWorkspaceEntry<unknown>>> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const prefix = `${workspaceKey}:`;
const entries = await openWorkspaceStore<unknown>(params.namespace).entries();
return entries
@@ -124,16 +144,18 @@ export async function writeMemoryCoreWorkspaceEntries(
params: WriteMemoryCoreWorkspaceEntriesParams<unknown>,
): Promise<void> {
const store = openWorkspaceStore<unknown>(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const agentId = normalizeMemoryCoreAgentId(params.agentId);
const prefix = `${workspaceKey}:`;
const replacementKeys = new Set<string>();
for (const entry of params.entries) {
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key);
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key, params.agentId);
replacementKeys.add(stateKey);
await store.register(stateKey, {
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
...(agentId ? { agentId } : {}),
key: entry.key,
value: entry.value,
});
@@ -152,13 +174,15 @@ export function writeMemoryCoreWorkspaceEntry<T>(
export async function writeMemoryCoreWorkspaceEntry(
params: WriteMemoryCoreWorkspaceEntryParams<unknown>,
): Promise<void> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const agentId = normalizeMemoryCoreAgentId(params.agentId);
await openWorkspaceStore<unknown>(params.namespace).register(
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key, params.agentId),
{
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
...(agentId ? { agentId } : {}),
key: params.key,
value: params.value,
},
@@ -168,9 +192,10 @@ 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);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
const prefix = `${workspaceKey}:`;
for (const entry of await store.entries()) {
if (entry.key.startsWith(prefix)) {
@@ -178,3 +203,45 @@ 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,6 +7,7 @@ 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,
@@ -89,6 +90,7 @@ 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,
@@ -110,6 +112,7 @@ 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 } : {}),
@@ -267,7 +270,14 @@ async function triggerGatewayStart(
onMock: ReturnType<typeof vi.fn>,
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
): Promise<void> {
await getGatewayStartHandler(onMock)({ port: 18789 }, ctx);
const migrated = ctx.config ? migrateMemoryCoreLegacyConfig(ctx.config)?.config : undefined;
await getGatewayStartHandler(onMock)(
{ port: 18789 },
{
...ctx,
...(migrated ? { config: migrated } : {}),
},
);
}
async function triggerGatewayStop(
@@ -278,6 +288,25 @@ 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);
}
@@ -547,7 +576,8 @@ describe("short-term dreaming cron reconciliation", () => {
expect(result.status).toBe("added");
expect(harness.addCalls).toHaveLength(1);
const addCall = requireAddCall(harness, 0);
expect(addCall.name).toBe(constants.MANAGED_DREAMING_CRON_NAME);
expect(addCall.agentId).toBe("main");
expect(addCall.name).toBe(`${constants.MANAGED_DREAMING_CRON_NAME} (main)`);
expect(addCall.sessionTarget).toBe("isolated");
expect(addCall.wakeMode).toBe("now");
expect(addCall.delivery?.mode).toBe("none");
@@ -557,6 +587,41 @@ 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,
@@ -1279,7 +1344,7 @@ describe("gateway startup reconciliation", () => {
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(1);
expect(harness.listCalls).toBe(2);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
@@ -1288,7 +1353,7 @@ describe("gateway startup reconciliation", () => {
{ trigger: "user", workspaceDir: "." },
);
expect(harness.listCalls).toBe(1);
expect(harness.listCalls).toBe(2);
} finally {
clearInternalHooks();
}
@@ -1330,7 +1395,7 @@ describe("gateway startup reconciliation", () => {
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(1);
expect(harness.listCalls).toBe(2);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply(
@@ -1342,7 +1407,7 @@ describe("gateway startup reconciliation", () => {
{ trigger: "heartbeat", workspaceDir: "." },
);
expect(harness.listCalls).toBe(2);
expect(harness.listCalls).toBe(4);
} finally {
nowSpy.mockRestore();
clearInternalHooks();
@@ -1691,7 +1756,7 @@ describe("gateway startup reconciliation", () => {
expect(harness.addCalls).toHaveLength(1);
const addCall = requireAddCall(harness, 0);
expect(addCall.name).toBe("Memory Dreaming Promotion");
expect(addCall.name).toBe("Memory Dreaming Promotion (main)");
expectCronSchedule(addCall.schedule, "15 4 * * *", "UTC");
expect(addCall.sessionTarget).toBe("isolated");
const payload = requireAgentTurnPayload(addCall.payload);
@@ -2775,12 +2840,19 @@ describe("short-term dreaming trigger", () => {
trigger: "heartbeat",
workspaceDir: mainWorkspace,
cfg: {
agents: {
defaults: {
memorySearch: {
enabled: true,
memory: {
search: {
enabled: true,
},
extensions: {
"memory-core": {
dreaming: {
enabled: true,
},
},
},
},
agents: {
list: [
{
id: "alpha",

View File

@@ -1,5 +1,9 @@
// 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,
@@ -46,6 +50,7 @@ type CronPayload =
| { kind: "systemEvent"; text: string }
| { kind: "agentTurn"; message: string; lightContext?: boolean };
type ManagedCronJobCreate = {
agentId?: string;
name: string;
description: string;
enabled: boolean;
@@ -59,6 +64,7 @@ type ManagedCronJobCreate = {
};
type ManagedCronJobPatch = {
agentId?: string;
name?: string;
description?: string;
enabled?: boolean;
@@ -73,6 +79,7 @@ type ManagedCronJobPatch = {
type ManagedCronJobLike = {
id: string;
agentId?: string;
name?: string;
description?: string;
enabled?: boolean;
@@ -155,18 +162,23 @@ function formatRepairSummary(repair: {
return actions.join(", ");
}
function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string {
function resolveManagedCronDescription(
config: ShortTermPromotionDreamingConfig,
agentId: string,
): 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 (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 for agent=${agentId} (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 {
name: MANAGED_DREAMING_CRON_NAME,
description: resolveManagedCronDescription(config),
agentId,
name: `${MANAGED_DREAMING_CRON_NAME} (${agentId})`,
description: resolveManagedCronDescription(config, agentId),
enabled: true,
schedule: {
kind: "cron",
@@ -207,7 +219,21 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
}
const name = normalizeTrimmedString(job.name);
const payloadToken = resolveManagedDreamingPayloadToken(job.payload);
return name === MANAGED_DREAMING_CRON_NAME && payloadToken === DREAMING_SYSTEM_EVENT_TEXT;
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"))
);
}
function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
@@ -272,6 +298,12 @@ 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;
}
@@ -388,6 +420,7 @@ function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean {
export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
agentId?: string;
}): ShortTermPromotionDreamingConfig {
const resolved = resolveMemoryDeepDreamingConfig(params);
return {
@@ -412,6 +445,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
cron: CronServiceLike | null;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
agentId?: string;
}): Promise<ReconcileResult> {
const cron = params.cron;
if (!cron) {
@@ -419,7 +453,8 @@ export async function reconcileShortTermDreamingCronJob(params: {
}
const allJobs = await cron.list({ includeDisabled: true });
const managed = allJobs.filter(isManagedDreamingJob);
const agentId = params.agentId ?? "main";
const managed = allJobs.filter((job) => isManagedDreamingJobForAgent(job, agentId));
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
if (!params.config.enabled) {
@@ -447,7 +482,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
return { status: "disabled", removed };
}
const desired = buildManagedDreamingCronJob(params.config);
const desired = buildManagedDreamingCronJob(params.config, agentId);
if (managed.length === 0) {
await cron.add(desired);
const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
@@ -498,6 +533,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
trigger?: string;
workspaceDir?: string;
cfg?: OpenClawConfig;
agentId?: string;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
subagent?: OpenClawPluginApi["runtime"]["subagent"];
@@ -518,7 +554,8 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
const workspaceCandidates = params.cfg
? resolveMemoryDreamingWorkspaces(params.cfg, {
primaryWorkspaceDir: fallbackWorkspaceDir,
primaryAgentId: "main",
primaryAgentId: params.agentId ?? resolveDefaultAgentId(params.cfg),
agentIds: params.agentId ? [params.agentId] : undefined,
}).map((entry) => entry.workspaceDir)
: [];
const seenWorkspaces = new Set<string>();
@@ -552,7 +589,10 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
let totalCandidates = 0;
let totalApplied = 0;
let failedWorkspaces = 0;
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
const pluginConfig =
params.cfg && params.agentId
? resolveMemoryCorePluginConfig(params.cfg, params.agentId)
: undefined;
const detachNarratives = params.trigger === "cron";
const [
{ writeDeepDreamingReport },
@@ -576,6 +616,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
workspaceDir,
pluginConfig,
cfg: params.cfg,
agentId: params.agentId,
logger: params.logger,
subagent: params.subagent,
detachNarratives,
@@ -583,7 +624,10 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
});
const reportLines: string[] = [];
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
const repair = await repairShortTermPromotionArtifacts({
workspaceDir,
agentId: params.agentId,
});
if (repair.changed) {
params.logger.info(
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}) [workspace=${workspaceDir}].`,
@@ -592,6 +636,7 @@ 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,
@@ -618,6 +663,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
const applied = await applyShortTermPromotions({
workspaceDir,
agentId: params.agentId,
candidates,
limit: params.config.limit,
minScore: params.config.minScore,
@@ -646,6 +692,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
await writeDeepDreamingReport({
workspaceDir,
agentId: params.agentId,
bodyLines: reportLines,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -661,6 +708,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
if (!params.subagent) {
await appendFallbackNarrativeEntry({
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -671,6 +719,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -681,6 +730,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir,
agentId: params.agentId,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
@@ -753,39 +803,44 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
resolveStartupCron = null;
};
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 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 reconcileManagedDreamingCron = async (params: {
reason: "startup" | "startup_retry" | "runtime";
startupConfig?: OpenClawConfig;
startupCron?: (() => CronServiceLike | null) | null;
}): Promise<ShortTermPromotionDreamingConfig> => {
}): Promise<void> => {
const startupCfg =
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const pluginConfig =
params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg);
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg: startupCfg,
});
const plans = listAgentIds(startupCfg).map((agentId) => ({
agentId,
config: resolveShortTermPromotionDreamingConfig({
cfg: startupCfg,
agentId,
}),
}));
if (params.reason === "startup") {
resolveStartupCron = params.startupCron ?? null;
}
@@ -805,8 +860,8 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// Ignore — fall through with cron = null
}
}
const configKey = runtimeConfigKey(config);
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
const configKey = runtimeConfigKey(plans);
if (!cron && plans.some((plan) => plan.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.
@@ -828,7 +883,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 config;
return;
}
if (params.reason === "runtime") {
const now = Date.now();
@@ -839,18 +894,29 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
lastRuntimeConfigKey === configKey &&
lastRuntimeCronRef === cron
) {
return config;
return;
}
lastRuntimeReconcileAtMs = now;
lastRuntimeConfigKey = configKey;
lastRuntimeCronRef = cron;
}
await reconcileShortTermDreamingCronJob({
cron,
config,
logger: api.logger,
});
return config;
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);
}
}
}
};
const scheduleStartupCronRetry = (): void => {
@@ -944,17 +1010,23 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
if (!shouldHandleManagedDreaming && !hasCronManagementContext()) {
return undefined;
}
const config = await reconcileManagedDreamingCron({
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,11 +71,20 @@ const readAgentMemoryFileMock = vi.fn(
vi.mock("./tools.runtime.js", () => ({
resolveMemoryBackendConfig: ({
cfg,
agentId,
}: {
cfg?: { memory?: { backend?: string; qmd?: unknown } };
cfg?: {
memory?: { qmd?: unknown };
agents?: {
list?: Array<{ id?: string; memory?: { qmd?: unknown } }>;
};
};
agentId?: string;
}) => ({
backend,
qmd: cfg?.memory?.qmd,
qmd:
cfg?.agents?.list?.find((agent) => agent.id === agentId)?.memory?.qmd ??
cfg?.memory?.qmd,
}),
getMemorySearchManager: getMemorySearchManagerMock,
readAgentMemoryFile: readAgentMemoryFileMock,

View File

@@ -385,34 +385,36 @@ 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,6 +11,7 @@ import {
createSubsystemLogger,
onSessionTranscriptUpdate,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveSessionTranscriptsDirForAgent,
resolveUserPath,
type OpenClawConfig,
@@ -155,6 +156,7 @@ 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",
@@ -932,7 +934,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 memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large extraPaths, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
(message) => log.warn(message),
);
}
@@ -1786,11 +1788,18 @@ export abstract class MemoryManagerSyncOps {
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
: null;
const files = await listMemoryFiles(
this.workspaceDir,
this.settings.extraPaths,
this.settings.multimodal,
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, {
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,21 +62,23 @@ describe("memory manager reindex recovery", () => {
sources?: Array<"memory" | "sessions">;
}): OpenClawConfig {
return {
memory: { backend: "builtin" },
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 },
},
},
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 }],
},
@@ -198,21 +200,6 @@ 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({
@@ -243,6 +230,26 @@ 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 });
@@ -340,4 +347,5 @@ describe("memory manager reindex recovery", () => {
expect(harness.dirty).toBe(false);
expect(harness.memoryFullRetryDirty).toBe(false);
});
});

View File

@@ -77,17 +77,19 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
? undefined
: { vector: { enabled: params.vectorEnabled } };
const cfg = {
memory: { backend: "builtin" },
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

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

View File

@@ -199,7 +199,6 @@ describe("memory watcher config", () => {
}
await closeAllMemorySearchManagers();
clearRegistry();
vi.unstubAllEnvs();
if (workspaceDir) {
await fs.rm(workspaceDir, { recursive: true, force: true });
workspaceDir = "";
@@ -209,7 +208,6 @@ 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 });
@@ -217,22 +215,23 @@ describe("memory watcher config", () => {
}
function createWatcherConfig(overrides?: Partial<MemorySearchConfig>): OpenClawConfig {
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" },
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,
},
},
agents: {
defaults,
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
@@ -290,6 +289,9 @@ 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,9 +175,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
process.env.OPENCLAW_STATE_DIR = stateDir;
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
@@ -186,6 +183,9 @@ 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,6 +97,7 @@ 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",
@@ -495,6 +496,7 @@ 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") {
@@ -630,6 +632,10 @@ 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: {
@@ -1162,6 +1168,12 @@ 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`;
}
@@ -1218,6 +1230,14 @@ 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})`);
}
@@ -1763,7 +1783,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 memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large collections, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
(message) => log.warn(message),
);
}

View File

@@ -153,27 +153,32 @@ 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" }],
},
@@ -498,7 +503,9 @@ 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,13 +38,17 @@ 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: [],
});
await appendMemoryHostEvent(
workspaceDir,
{
type: "memory.recall.recorded",
timestamp: "2026-04-06T12:00:00.000Z",
query: "alpha",
resultCount: 0,
results: [],
},
"main",
);
const cfg: OpenClawConfig = {
agents: {
@@ -80,8 +84,8 @@ describe("listMemoryCorePublicArtifacts", () => {
{
kind: "event-log",
workspaceDir,
relativePath: "memory/.dreams/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
relativePath: "memory/.dreams/agents/main/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir, "main"),
agentIds: ["main"],
contentType: "json",
},

View File

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

View File

@@ -27,6 +27,7 @@ type MemoryRemHarnessDeepConfig = ReturnType<typeof resolveMemoryDeepDreamingCon
export type PreviewRemHarnessOptions = {
workspaceDir: string;
cfg?: OpenClawConfig;
agentId?: string;
pluginConfig?: Record<string, unknown>;
grounded?: boolean;
groundedInputPaths?: string[];
@@ -123,13 +124,16 @@ 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({
@@ -171,6 +175,7 @@ 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,6 +1425,101 @@ 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,6 +24,7 @@ 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,
@@ -40,7 +41,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\/session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/;
/(?:^|\/)memory\/\.dreams\/(?:agents\/[^/]+\/)?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;
@@ -236,6 +237,7 @@ export type RepairShortTermPromotionArtifactsResult = {
type RankShortTermPromotionOptions = {
workspaceDir: string;
agentId?: string;
limit?: number;
minScore?: number;
minRecallCount?: number;
@@ -249,6 +251,7 @@ type RankShortTermPromotionOptions = {
type ApplyShortTermPromotionsOptions = {
workspaceDir: string;
agentId?: string;
candidates: PromotionCandidate[];
limit?: number;
minScore?: number;
@@ -801,16 +804,16 @@ function calculatePhaseSignalBoost(
);
}
function resolveStorePath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir);
function resolveStorePath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir, agentId);
}
function resolvePhaseSignalPath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir);
function resolvePhaseSignalPath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir, agentId);
}
function resolveLockPath(workspaceDir: string): string {
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir);
function resolveLockPath(workspaceDir: string, agentId?: string): string {
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir, agentId);
}
function parseLockOwnerPid(raw: string): number | null {
@@ -876,9 +879,13 @@ async function withInProcessShortTermLock<T>(lockPath: string, task: () => Promi
}
}
async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>): Promise<T> {
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockRef = resolveLockPath(workspaceDir);
async function withShortTermLock<T>(
workspaceDir: string,
agentId: string | undefined,
task: () => Promise<T>,
): Promise<T> {
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, agentId);
const lockRef = resolveLockPath(workspaceDir, agentId);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -921,15 +928,76 @@ async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>
});
}
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
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> {
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;
@@ -1011,15 +1079,18 @@ 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;
@@ -1036,34 +1107,43 @@ 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): Promise<void> {
async function writeStore(
workspaceDir: string,
store: ShortTermRecallStore,
agentId?: string,
): 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 },
}),
@@ -1192,16 +1272,17 @@ 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);
const store = await readStore(workspaceDir, nowIso, params.agentId);
let phaseSignalError: string | undefined;
let phaseStore: ShortTermPhaseSignalStore;
try {
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso);
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
} catch (err) {
phaseSignalError = formatErrorMessage(err);
phaseStore = emptyPhaseSignalStore(nowIso);
@@ -1302,8 +1383,8 @@ export async function loadShortTermPromotionDreamingStats(params: {
remPhaseHitCount,
promotedTotal,
promotedToday,
storePath: resolveStorePath(workspaceDir),
phaseSignalPath: resolvePhaseSignalPath(workspaceDir),
storePath: resolveStorePath(workspaceDir, params.agentId),
phaseSignalPath: resolvePhaseSignalPath(workspaceDir, params.agentId),
shortTermEntries: trimDreamingStatsEntries(
shortTermEntries,
compareDreamingStatsEntryByRecency,
@@ -1380,6 +1461,7 @@ function buildMemoryRecallSkippedEvent(params: {
export async function recordShortTermRecalls(params: {
workspaceDir?: string;
agentId?: string;
query: string;
results: MemorySearchResult[];
signalType?: "recall" | "daily";
@@ -1414,6 +1496,7 @@ export async function recordShortTermRecalls(params: {
eligibleResultCount: relevant.length,
skipped,
}),
params.agentId,
);
return;
}
@@ -1421,8 +1504,8 @@ export async function recordShortTermRecalls(params: {
const queryHash = hashQuery(query);
const todayBucket =
normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
await withShortTermLock(workspaceDir, params.agentId, async () => {
const store = await readStore(workspaceDir, nowIso, params.agentId);
for (const result of relevant) {
const normalizedPath = normalizeMemoryPath(result.path);
@@ -1488,19 +1571,23 @@ export async function recordShortTermRecalls(params: {
}
store.updatedAt = nowIso;
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),
})),
});
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,
);
if (skipped.length > 0) {
await appendMemoryHostEvent(
workspaceDir,
@@ -1510,6 +1597,7 @@ export async function recordShortTermRecalls(params: {
eligibleResultCount: relevant.length,
skipped,
}),
params.agentId,
);
}
});
@@ -1517,6 +1605,7 @@ export async function recordShortTermRecalls(params: {
export async function recordGroundedShortTermCandidates(params: {
workspaceDir?: string;
agentId?: string;
query: string;
items: Array<{
path: string;
@@ -1576,8 +1665,8 @@ export async function recordGroundedShortTermCandidates(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const fallbackDayBucket = formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
await withShortTermLock(workspaceDir, params.agentId, async () => {
const store = await readStore(workspaceDir, nowIso, params.agentId);
for (const item of relevant) {
const dayBucket = item.dayBucket ?? fallbackDayBucket;
@@ -1637,12 +1726,13 @@ export async function recordGroundedShortTermCandidates(params: {
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
await writeStore(workspaceDir, store, params.agentId);
});
}
export async function recordDreamingPhaseSignals(params: {
workspaceDir?: string;
agentId?: string;
phase: "light" | "rem";
keys: string[];
nowMs?: number;
@@ -1658,10 +1748,10 @@ export async function recordDreamingPhaseSignals(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
await withShortTermLock(workspaceDir, async () => {
await withShortTermLock(workspaceDir, params.agentId, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
]);
const knownKeys = new Set(Object.keys(store.entries));
@@ -1691,12 +1781,13 @@ export async function recordDreamingPhaseSignals(params: {
}
phaseSignals.updatedAt = nowIso;
await writePhaseSignalStore(workspaceDir, phaseSignals);
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
});
}
export async function recordRemConsideredPhaseSignals(params: {
workspaceDir?: string;
agentId?: string;
keys: string[];
nowMs?: number;
}): Promise<void> {
@@ -1711,10 +1802,10 @@ export async function recordRemConsideredPhaseSignals(params: {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
await withShortTermLock(workspaceDir, async () => {
await withShortTermLock(workspaceDir, params.agentId, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
]);
const knownKeys = new Set(Object.keys(store.entries));
@@ -1738,12 +1829,13 @@ export async function recordRemConsideredPhaseSignals(params: {
}
phaseSignals.updatedAt = nowIso;
await writePhaseSignalStore(workspaceDir, phaseSignals);
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
});
}
export async function readLightStagedKeys(params: {
workspaceDir: string;
agentId?: string;
nowMs?: number;
}): Promise<Set<string>> {
const workspaceDir = params.workspaceDir?.trim();
@@ -1752,7 +1844,7 @@ export async function readLightStagedKeys(params: {
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const store = await readPhaseSignalStore(workspaceDir, nowIso);
const store = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
const keys = new Set<string>();
for (const [key, entry] of Object.entries(store.entries)) {
if (entry.lightHits <= 0) {
@@ -1803,8 +1895,8 @@ export async function rankShortTermPromotionCandidates(
const weights = normalizeWeights(options.weights);
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
readStore(workspaceDir, nowIso, options.agentId),
readPhaseSignalStore(workspaceDir, nowIso, options.agentId),
]);
const candidates: PromotionCandidate[] = [];
@@ -1918,6 +2010,7 @@ export async function rankShortTermPromotionCandidates(
export async function readShortTermRecallEntries(params: {
workspaceDir: string;
agentId?: string;
nowMs?: number;
}): Promise<ShortTermRecallEntry[]> {
const workspaceDir = params.workspaceDir.trim();
@@ -1926,7 +2019,7 @@ export async function readShortTermRecallEntries(params: {
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const store = await readStore(workspaceDir, nowIso);
const store = await readStore(workspaceDir, nowIso, params.agentId);
return Object.values(store.entries).filter(
(entry): entry is ShortTermRecallEntry =>
Boolean(entry) && entry.source === "memory" && isShortTermMemoryPath(entry.path),
@@ -2384,162 +2477,174 @@ export async function applyShortTermPromotions(
const maxAgeDays = toFiniteNonNegativeInt(options.maxAgeDays, -1);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
return await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
const selected = options.candidates
.filter((candidate) => {
if (isContaminatedDreamingSnippet(candidate.snippet)) {
return false;
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);
}
}
if (candidate.promotedAt) {
return false;
if (rehydratedSelected.length === 0) {
return {
memoryPath,
applied: 0,
appended: 0,
reconciledExisting: 0,
appliedCandidates: [],
compactedSections: 0,
compactedDates: [],
};
}
if (candidate.score < minScore) {
return false;
}
const candidateSignalCount = Math.max(
0,
candidate.signalCount ??
totalSignalCountForEntry({
recallCount: candidate.recallCount,
dailyCount: candidate.dailyCount,
groundedCount: candidate.groundedCount,
}),
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),
);
if (candidateSignalCount < minRecallCount) {
return false;
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 (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
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;
}
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
return false;
}
const latest = store.entries[candidate.key];
if (latest?.promotedAt) {
return false;
}
return true;
})
.slice(0, limit);
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,
recallCount: candidate.recallCount,
})),
},
options.agentId,
);
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,
};
});
return {
memoryPath,
applied: rehydratedSelected.length,
appended: toAppend.length,
reconciledExisting: alreadyWritten.length,
appliedCandidates: rehydratedSelected,
compactedSections: compactedDates.length,
compactedDates,
};
}),
);
}
export function resolveShortTermRecallStorePath(workspaceDir: string): string {
return resolveStorePath(workspaceDir);
export function resolveShortTermRecallStorePath(workspaceDir: string, agentId?: string): string {
return resolveStorePath(workspaceDir, agentId);
}
export function resolveShortTermRecallLockPath(workspaceDir: string): string {
return resolveLockPath(workspaceDir);
export function resolveShortTermRecallLockPath(workspaceDir: string, agentId?: string): string {
return resolveLockPath(workspaceDir, agentId);
}
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);
const lockPath = resolveLockPath(workspaceDir);
const storePath = resolveStorePath(workspaceDir, params.agentId);
const lockPath = resolveLockPath(workspaceDir, params.agentId);
const issues: ShortTermAuditIssue[] = [];
let entryCount = 0;
let promotedCount = 0;
@@ -2553,6 +2658,7 @@ 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) {
@@ -2598,7 +2704,7 @@ export async function auditShortTermPromotionArtifacts(params: {
}
}
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -2681,6 +2787,7 @@ 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();
@@ -2689,7 +2796,7 @@ export async function repairShortTermPromotionArtifacts(params: {
let removedOverflowEntries = 0;
let removedStaleLock = false;
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
@@ -2702,10 +2809,11 @@ export async function repairShortTermPromotionArtifacts(params: {
}
}
await withShortTermLock(workspaceDir, async () => {
await withShortTermLock(workspaceDir, params.agentId, async () => {
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
agentId: params.agentId,
});
if (rawEntries.length > 0) {
const normalized = normalizeShortTermRecallStore(
@@ -2754,10 +2862,14 @@ export async function repairShortTermPromotionArtifacts(params: {
removedOverflowEntries > 0 ||
JSON.stringify(normalized.entries) !== JSON.stringify(comparableStore.entries);
if (needsRewrite) {
await writeStore(workspaceDir, {
...comparableStore,
updatedAt: nowIso,
});
await writeStore(
workspaceDir,
{
...comparableStore,
updatedAt: nowIso,
},
params.agentId,
);
rewroteStore = true;
}
}
@@ -2774,16 +2886,17 @@ 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);
const storePath = resolveStorePath(workspaceDir, params.agentId);
const nowIso = new Date().toISOString();
let removed = 0;
await withShortTermLock(workspaceDir, async () => {
await withShortTermLock(workspaceDir, params.agentId, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
readStore(workspaceDir, nowIso, params.agentId),
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
]);
for (const [key, entry] of Object.entries(store.entries)) {
@@ -2807,8 +2920,8 @@ export async function removeGroundedShortTermCandidates(params: {
store.updatedAt = nowIso;
phaseSignals.updatedAt = nowIso;
await Promise.all([
writeStore(workspaceDir, store),
writePhaseSignalStore(workspaceDir, phaseSignals),
writeStore(workspaceDir, store, params.agentId),
writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId),
]);
}
});
@@ -2822,18 +2935,20 @@ export const testing = {
isProcessLikelyAlive,
readRecallStore: readStore,
readPhaseSignalStore,
writeRawRecallStore: async (workspaceDir: string, raw: unknown) => {
writeRawRecallStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
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:
@@ -2844,18 +2959,20 @@ export const testing = {
}),
]);
},
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown) => {
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
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:
@@ -2866,17 +2983,17 @@ export const testing = {
}),
]);
},
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry) => {
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry, agentId?: string) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).register(memoryCoreWorkspaceStateKey(workspaceDir), entry);
}).register(memoryCoreWorkspaceStateKey(workspaceDir, agentId), entry);
},
deleteShortTermLock: async (workspaceDir: string) => {
deleteShortTermLock: async (workspaceDir: string, agentId?: string) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).delete(memoryCoreWorkspaceStateKey(workspaceDir));
}).delete(memoryCoreWorkspaceStateKey(workspaceDir, agentId));
},
deriveConceptTags,
calculateConsolidationComponent,

View File

@@ -91,7 +91,9 @@ 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" });
@@ -105,7 +107,9 @@ 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" });
@@ -119,7 +123,9 @@ 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" });
@@ -169,7 +175,9 @@ 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 }],
},
}),
});
@@ -189,7 +197,9 @@ 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,
});
@@ -310,18 +320,18 @@ describe("memory tools", () => {
const tool = createMemorySearchToolOrThrow({
config: asOpenClawConfig({
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
memory: {
extensions: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
dreaming: {
enabled: true,
},
},
},
},
agents: {
list: [{ id: "main", default: true }],
},
}),
});
await tool.execute("call_recall_persist", { query: "glacier backup" });
@@ -330,6 +340,7 @@ describe("memory tools", () => {
const store = await shortTermPromotionTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
"main",
);
const values = Object.values(store.entries);
expect(values).toHaveLength(1);
@@ -339,7 +350,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 });
const memoryEvents = await readMemoryHostEvents({ workspaceDir, agentId: "main" });
expect(memoryEvents).toHaveLength(1);
return memoryEvents;
});

View File

@@ -1,14 +1,18 @@
// 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): MemoryCitationsMode {
const mode = cfg.memory?.citations;
export function resolveMemoryCitationsMode(
cfg: OpenClawConfig,
agentId: string,
): MemoryCitationsMode {
const mode = resolveAgentMemoryConfig(cfg, agentId)?.citations;
if (mode === "on" || mode === "off" || mode === "auto") {
return mode;
}

View File

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

View File

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

View File

@@ -43,7 +43,9 @@ 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,8 +239,10 @@ 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" });
@@ -286,8 +288,10 @@ 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,
});
@@ -334,8 +338,10 @@ 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" });
@@ -362,8 +368,10 @@ 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" });
@@ -457,8 +465,8 @@ describe("memory_search corpus labels", () => {
config: asOpenClawConfig({
agents: {
list: [
{ id: "main", default: true, memorySearch: { enabled: false } },
{ id: "recall", memorySearch: { enabled: true } },
{ id: "main", default: true, memory: { search: { enabled: false } } },
{ id: "recall", memory: { search: { enabled: true } } },
],
},
}),
@@ -473,31 +481,27 @@ 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;
@@ -537,8 +541,10 @@ 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,7 +15,6 @@ import type {
MemorySearchRuntimeDebug,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
@@ -239,6 +238,7 @@ function resolveRecallTrackingResults(
function queueShortTermRecallTracking(params: {
workspaceDir?: string;
agentId?: string;
query: string;
rawResults: MemorySearchResult[];
surfacedResults: MemorySearchResult[];
@@ -247,6 +247,7 @@ 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,
@@ -293,6 +294,7 @@ async function getSupplementMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}) {
@@ -300,6 +302,7 @@ async function getSupplementMemoryReadResult(params: {
lookup: params.relPath,
fromLine: params.from,
lineCount: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
corpus: params.corpus,
});
@@ -319,6 +322,7 @@ async function resolveMemoryReadFailureResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
}) {
if (params.requestedCorpus === "all") {
@@ -326,6 +330,7 @@ async function resolveMemoryReadFailureResult(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
corpus: params.requestedCorpus,
});
@@ -343,6 +348,7 @@ async function executeMemoryReadResult<T>(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
}) {
try {
@@ -354,6 +360,7 @@ async function executeMemoryReadResult<T>(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
});
}
@@ -449,19 +456,18 @@ export function createMemorySearchTool(options: {
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
}
const citationsMode = resolveMemoryCitationsMode(cfg);
const citationsMode = resolveMemoryCitationsMode(cfg, agentId);
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[] = [];
@@ -570,6 +576,7 @@ export function createMemorySearchTool(options: {
if (dreamingEnabled) {
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
agentId,
query,
rawResults,
surfacedResults: memoryResults,
@@ -606,6 +613,7 @@ export function createMemorySearchTool(options: {
await searchMemoryCorpusSupplements({
query,
maxResults,
agentId,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
}),
@@ -680,6 +688,7 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
@@ -707,6 +716,7 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
});
}
@@ -729,6 +739,7 @@ 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