mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 12:52:06 +08:00
Compare commits
100 Commits
agent-memo
...
lint-perfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2cccbe8b | ||
|
|
0c03ce94e8 | ||
|
|
49e6f5a524 | ||
|
|
95c87e31e2 | ||
|
|
0d2102d247 | ||
|
|
55323103b9 | ||
|
|
239b4de6af | ||
|
|
a7b52ecad9 | ||
|
|
bb44c5326e | ||
|
|
4764258b3f | ||
|
|
6af1b97b1d | ||
|
|
4ca0e52d0e | ||
|
|
37eea55afa | ||
|
|
ea76a45917 | ||
|
|
84bcdaa983 | ||
|
|
4ac192deef | ||
|
|
3c9cf2d583 | ||
|
|
c4ae2be947 | ||
|
|
27310bfa34 | ||
|
|
fbc12e0879 | ||
|
|
73cdb78a1e | ||
|
|
0df60ad306 | ||
|
|
9928516a78 | ||
|
|
7845182410 | ||
|
|
aba6f7ad21 | ||
|
|
5570a10bf4 | ||
|
|
98857235d5 | ||
|
|
39e9336d40 | ||
|
|
392f5b75bf | ||
|
|
a98bfdb2b7 | ||
|
|
34d402f53c | ||
|
|
1faf8175e4 | ||
|
|
fdb042b9ce | ||
|
|
d5a27b0b96 | ||
|
|
9328f4a675 | ||
|
|
75df29c215 | ||
|
|
bf8ac0d96d | ||
|
|
bfb47a03b3 | ||
|
|
a93fc87e2c | ||
|
|
cc3d346c15 | ||
|
|
a8d60d352e | ||
|
|
1bfa2787b5 | ||
|
|
e385f6663a | ||
|
|
76bdb025d6 | ||
|
|
d2e36a176d | ||
|
|
b637414871 | ||
|
|
aa39600793 | ||
|
|
61b116d597 | ||
|
|
033162f209 | ||
|
|
dfd8a2220b | ||
|
|
74ad4f592a | ||
|
|
a14b1e05e5 | ||
|
|
8b63a3d551 | ||
|
|
f4e9a6e047 | ||
|
|
2ae84f75ef | ||
|
|
dca17477dc | ||
|
|
7f1fa65399 | ||
|
|
b6a06f0e49 | ||
|
|
9501d4dec2 | ||
|
|
d9397e5b9b | ||
|
|
d9d7766a41 | ||
|
|
0771ac8563 | ||
|
|
906174bff1 | ||
|
|
c677424edb | ||
|
|
ea4ddb2eb5 | ||
|
|
f19c5f6b2f | ||
|
|
1e83f42a64 | ||
|
|
4c9b4c32ef | ||
|
|
4fc5cf4579 | ||
|
|
8f8162704d | ||
|
|
f381dca15b | ||
|
|
9ab9469d04 | ||
|
|
a2f5ac82d5 | ||
|
|
7b7e40cb0e | ||
|
|
e1c2926628 | ||
|
|
cebe5cb94a | ||
|
|
0b14724c87 | ||
|
|
e879a67bf7 | ||
|
|
53bb55e023 | ||
|
|
317919ec52 | ||
|
|
1f71e92297 | ||
|
|
d2e847e8cf | ||
|
|
720c0ab372 | ||
|
|
70e39da00f | ||
|
|
9eb6e6d326 | ||
|
|
9de9562cb7 | ||
|
|
6b25ccc4b1 | ||
|
|
111018984c | ||
|
|
7c24de5c87 | ||
|
|
3125cdacb5 | ||
|
|
8151a547c5 | ||
|
|
940feee71b | ||
|
|
46d359237e | ||
|
|
ae655345c4 | ||
|
|
a48e5091cb | ||
|
|
e9229ab77e | ||
|
|
65e77b82f5 | ||
|
|
2c7fe6a39c | ||
|
|
7ef85bfb1d | ||
|
|
361320cd9f |
@@ -1,44 +1,34 @@
|
||||
---
|
||||
name: channel-message-flows
|
||||
description: "Use when previewing local channel message flow fixtures."
|
||||
description: "Use when running QA Lab channel message flow evidence."
|
||||
---
|
||||
|
||||
# Channel Message Flows
|
||||
|
||||
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
|
||||
Use this from the OpenClaw repo root to run the QA Lab evidence for Telegram
|
||||
draft/final delivery sequencing. This skill no longer launches a standalone
|
||||
script; the behavior is owned by the QA scenario and its Vitest-backed e2e test.
|
||||
|
||||
## Telegram
|
||||
## QA Scenario
|
||||
|
||||
Native Telegram `sendMessageDraft` tool progress, then a final answer:
|
||||
Run the scenario through QA Lab:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow working-final \
|
||||
--duration-ms 20000
|
||||
pnpm openclaw qa suite --scenario channel-message-flows
|
||||
```
|
||||
|
||||
Thinking preview, then a final answer:
|
||||
Run the focused e2e test directly in a Codex worktree:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow thinking-final
|
||||
node scripts/run-vitest.mjs extensions/telegram/src/channel-message-flows.qa.e2e.test.ts
|
||||
```
|
||||
|
||||
## Options
|
||||
## References
|
||||
|
||||
- `--account <accountId>`: Telegram account id when not using the default.
|
||||
- `--thread-id <id>`: Telegram forum topic/message thread id.
|
||||
- `--delay-ms <ms>`: Override preview update cadence.
|
||||
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
|
||||
- `--final-text <text>`: Override the durable final message.
|
||||
- `qa/scenarios/channels/channel-message-flows.yaml`
|
||||
- `extensions/telegram/src/channel-message-flows.qa.e2e.test.ts`
|
||||
- `extensions/telegram/src/test-support/channel-message-flows.ts`
|
||||
|
||||
## Notes
|
||||
|
||||
- `--target` is the numeric Telegram chat id.
|
||||
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
|
||||
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
|
||||
- Only `--channel telegram` is implemented for now.
|
||||
The scenario covers `channels.streaming` as primary evidence and records
|
||||
secondary coverage for thread preservation, delivery ordering, and reasoning
|
||||
preview visibility.
|
||||
|
||||
@@ -16,11 +16,8 @@ This skill owns the operational workflow for:
|
||||
|
||||
- `taxonomy.yaml`
|
||||
- `docs/maturity-scores.yaml`
|
||||
- `docs/maturity-scorecard.md`
|
||||
- `docs/taxonomy.md`
|
||||
- `docs/taxonomy-outline.md`
|
||||
- `scripts/render-maturity-docs.mjs`
|
||||
- `.github/workflows/maturity-scorecard.yml`
|
||||
- `docs/concepts/qa-e2e-automation.md`
|
||||
- `qa/scenarios/index.yaml`
|
||||
|
||||
Keep person-specific, maintainer-private, Discord archive, and discrawl facts
|
||||
out of this repo. If a score needs private evidence, use the redacted
|
||||
@@ -31,12 +28,18 @@ out of this repo. If a score needs private evidence, use the redacted
|
||||
- `taxonomy.yaml` is the hand-edited source of truth for surfaces, levels,
|
||||
QA profiles, categories, feature coverage IDs, docs refs, LTS overrides, and
|
||||
completeness-instruction paths.
|
||||
- Feature `coverageIds` are ANDed proof targets, not aliases. A feature may
|
||||
list multiple IDs when each ID proves part of one capability.
|
||||
- Keep categories and feature names unique, product-shaped, and broader than raw
|
||||
coverage IDs. Do not promote generic IDs into standalone feature names.
|
||||
- Avoid duplicate coverage-ID bundles under different feature names in one
|
||||
category.
|
||||
- `docs/maturity-scores.yaml` is the aggregate score source committed in this
|
||||
repo. It is the only committed score data; do not add generated inventory
|
||||
directories.
|
||||
- `docs/maturity-scorecard.md`, `docs/taxonomy.md`, and
|
||||
`docs/taxonomy-outline.md` are deterministic docs generated from the root
|
||||
taxonomy and aggregate score source.
|
||||
- There is no committed maturity-doc renderer or `pnpm maturity:*` script in
|
||||
this repo. Do not invent generated scorecard files; update the source YAML
|
||||
and current docs directly.
|
||||
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
|
||||
enrich generated artifact docs, but they are not committed as inventory.
|
||||
|
||||
@@ -44,22 +47,28 @@ out of this repo. If a score needs private evidence, use the redacted
|
||||
|
||||
Run from the openclaw repo root.
|
||||
|
||||
Render committed docs:
|
||||
Validate YAML structure after source edits:
|
||||
|
||||
```bash
|
||||
pnpm maturity:render
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const YAML = require("yaml");
|
||||
for (const file of ["taxonomy.yaml", "docs/maturity-scores.yaml", "qa/scenarios/index.yaml"]) {
|
||||
YAML.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
NODE
|
||||
```
|
||||
|
||||
Check generated docs are current:
|
||||
Check docs when touching docs prose:
|
||||
|
||||
```bash
|
||||
pnpm maturity:check
|
||||
pnpm check:docs
|
||||
```
|
||||
|
||||
Render an evidence-enriched docs artifact from downloaded QA artifacts:
|
||||
Run focused QA/profile checks when changing coverage IDs or profile membership:
|
||||
|
||||
```bash
|
||||
pnpm maturity:render -- --evidence-dir .artifacts/maturity-evidence --output-dir .artifacts/maturity-docs
|
||||
pnpm openclaw qa coverage --json
|
||||
```
|
||||
|
||||
## Scoring Workflow
|
||||
@@ -75,13 +84,13 @@ When asked to score or refresh a surface:
|
||||
discrawl or unredacted private archives.
|
||||
5. Update `docs/maturity-scores.yaml` only when the score change is backed by
|
||||
public or redacted artifact evidence.
|
||||
6. Run `pnpm maturity:render`.
|
||||
7. Run `pnpm maturity:check`.
|
||||
6. Run the YAML validation command from this skill.
|
||||
7. Run `pnpm check:docs` if docs prose changed, and focused QA coverage checks
|
||||
if coverage IDs or profile membership changed.
|
||||
|
||||
For subjective score changes, make the smallest defensible edit and leave the
|
||||
evidence path in the PR or task summary. The deterministic renderer owns
|
||||
Markdown structure; manual prose tweaks belong in taxonomy, score source, or
|
||||
the renderer rather than in generated docs.
|
||||
evidence path in the PR or task summary. Keep manual prose in current docs and
|
||||
keep score data in `docs/maturity-scores.yaml`.
|
||||
|
||||
## Default Completeness Process
|
||||
|
||||
@@ -158,13 +167,9 @@ Bands:
|
||||
- `Alpha`: 50-70
|
||||
- `Experimental`: 0-50
|
||||
|
||||
## GitHub Action
|
||||
|
||||
The `Maturity scorecard` workflow verifies committed generated docs on PRs and
|
||||
pushes. Manual dispatch can also download QA artifacts from another workflow run
|
||||
with `source_run_id` and `artifact_pattern`, render evidence-enriched docs into
|
||||
`.artifacts/maturity-docs`, and upload them as a GitHub artifact.
|
||||
## Artifacts
|
||||
|
||||
Do not add the maintainer repo's `docs/kevinslin/maturity-scorecard/inventory/`
|
||||
tree to openclaw. Those generated reports are intentionally replaced here by
|
||||
short-lived artifact docs and the committed aggregate scorecard pages.
|
||||
tree to openclaw. Evidence-enriched scorecard outputs belong in short-lived
|
||||
artifacts, not committed generated docs, unless this repo adds an explicit
|
||||
renderer/check workflow first.
|
||||
|
||||
15
.github/workflows/windows-testbox-probe.yml
vendored
15
.github/workflows/windows-testbox-probe.yml
vendored
@@ -85,12 +85,22 @@ jobs:
|
||||
env:
|
||||
ENABLE_WSL2_FEATURES: ${{ inputs.enable_wsl2_features }}
|
||||
IMPORT_UBUNTU_WSL2: ${{ inputs.import_ubuntu_wsl2 }}
|
||||
UBUNTU_WSL_ROOTFS_URL: https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz
|
||||
run: |
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ok = $false
|
||||
$restartRequired = $false
|
||||
|
||||
function Resolve-UbuntuWslRootfsUrl {
|
||||
$osArch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLowerInvariant()
|
||||
switch ($osArch) {
|
||||
"x64" { $wslArch = "amd64" }
|
||||
"arm64" { $wslArch = "arm64" }
|
||||
default { throw "Unsupported Windows architecture for Ubuntu WSL rootfs: $osArch" }
|
||||
}
|
||||
Write-Host "ubuntu_wsl_rootfs_arch=$wslArch"
|
||||
"https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-$wslArch-wsl.rootfs.tar.gz"
|
||||
}
|
||||
|
||||
function Invoke-WslText {
|
||||
param([string[]] $Arguments)
|
||||
$output = & wsl.exe @Arguments 2>&1
|
||||
@@ -143,8 +153,9 @@ jobs:
|
||||
Write-Host "import_ubuntu_wsl2=true"
|
||||
$wslRoot = "C:\wsl\UbuntuProbe"
|
||||
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
|
||||
$rootfsUrl = Resolve-UbuntuWslRootfsUrl
|
||||
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
|
||||
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
|
||||
Invoke-WebRequest -Uri $rootfsUrl -OutFile $rootfs -UseBasicParsing
|
||||
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
|
||||
Write-Host $import.Text
|
||||
Write-Host "wsl_import_exit=$($import.Code)"
|
||||
|
||||
@@ -117,11 +117,11 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
|
||||
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
|
||||
|
||||
## Validation
|
||||
|
||||
418
CHANGELOG.md
418
CHANGELOG.md
@@ -2,6 +2,415 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
|
||||
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
|
||||
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
|
||||
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
|
||||
|
||||
### Changes
|
||||
|
||||
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
|
||||
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
|
||||
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @vincentkoc, and @JPKay-AI.
|
||||
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
|
||||
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
|
||||
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
|
||||
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
|
||||
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
|
||||
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 373 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
|
||||
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
|
||||
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
|
||||
- **PR #92845** docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement. Related #92841. Thanks @liuhao1024 and @jeugregg.
|
||||
- **PR #82366** fix: use passive periodic sqlite wal checkpoints. Related #81715. Thanks @honor2030 and @KrasimirKralev.
|
||||
- **PR #92815** fix(google): route Gemini CLI OAuth through the env proxy (#46184). Thanks @yetval and @EvetteYoung.
|
||||
- **PR #91331** fix(mattermost): merge progress preview lines by identity. Related #89761. Thanks @iloveleon19 and @leonthe8th and @vincentkoc.
|
||||
- **PR #92909** fix(tui): keep spinner active when toggling tools. Related #49763. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @CrimsonDump.
|
||||
- **PR #92904** fix(elevenlabs): use current TTS model ids. Thanks @vortexopenclaw and @vincentkoc.
|
||||
- **PR #92642** fix #86872: Subagent run reports success but fails to write output file. Thanks @zhangguiping-xydt and @vincentkoc and @zapper35.
|
||||
- **PR #89122** refactor: route command session reads through seam. Thanks @jalehman.
|
||||
- **PR #90943** fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread. Thanks @sandieman2 and @vincentkoc.
|
||||
- **PR #92894** fix(skills): keep managed prompt paths readable. Related #92875. Thanks @kesslerio and @sallyom.
|
||||
- **PR #39617** fix: reload config in slash command routing so dmScope is respected. Related #39605. Thanks @Ciward.
|
||||
- **PR #92191** fix(agents): retry thinking-only errored turns. Related #91953. Thanks @ai-hpc and @lml2468.
|
||||
- **PR #92891** fix(memory): clean stale reindex temp files. Related #92874. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @potterdigital.
|
||||
- **PR #93005** Add OpenRouter Fusion guidance and prompt context. Related #92984. Thanks @sallyom.
|
||||
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
|
||||
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
|
||||
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
|
||||
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
|
||||
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
|
||||
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
|
||||
- **PR #92913** fix(opencode-go): register model catalog to fix context window detection. Related #92912. Thanks @kumaxs.
|
||||
- **PR #89129** refactor: route bundled plugin session callers through seam. Thanks @jalehman.
|
||||
- **PR #93084** fix(agents): preserve fresh usage after compaction. Related #50795. Thanks @Hollychou924 and @leno23 and @de1tydev and @425072024 and @vincentkoc and @wuwahe3.
|
||||
- **PR #92869** fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1. Thanks @zhangguiping-xydt and @vincentkoc and @chriskosys.
|
||||
- **PR #93011** fix(gateway): accept file-only input on /v1/responses (parity with image-only). Thanks @yetval and @vincentkoc.
|
||||
- **PR #92915** Convert QA scenarios to YAML files. Thanks @RomneyDa.
|
||||
- **PR #91767** Fix one-shot Codex app-server teardown. Thanks @aliahnaf2013-max.
|
||||
- **PR #92625** feat(codex): add auto plugin approvals. Thanks @kevinslin.
|
||||
- **PR #91587** test(qa): add qa run --qa-profile and unified output summary/evidence. Thanks @RomneyDa.
|
||||
- **PR #93104** test(reply): seed channel fixtures for dedupe tests. Thanks @RomneyDa.
|
||||
- **PR #93107** test(reply): preserve telegram dedupe fallback. Thanks @RomneyDa.
|
||||
- **PR #92954** fix(memory): accept local default model path migration. Thanks @mushuiyu886 and @vincentkoc.
|
||||
- **PR #90936** fix(agents): do not misclassify client-disconnect abort as run timeout. Related #90764. Thanks @openperf and @reginaldomarcilon.
|
||||
- **PR #90812** fix(voice-call): preserve live Twilio streams in stale reaper. Related #79121. Thanks @Takhoffman and @sahibzada-allahyar and @donkeykong91.
|
||||
- **PR #93094** fix(whatsapp): bound socket operations. Thanks @mcaxtr.
|
||||
- **PR #91629** fix(scripts): add database-first legacy store guard. Related #91628. Thanks @galiniliev.
|
||||
- **PR #93124** fix(telegram): render progress drafts as rich previews. Thanks @Marvinthebored.
|
||||
- **PR #93109** test(qa): embed profile scorecard evidence. Thanks @RomneyDa.
|
||||
- **PR #87298** test: add temp directory helper guidance. Thanks @hxy91819.
|
||||
- **PR #92318** fix(cron): require explicit message target proof. Thanks @hxy91819.
|
||||
- **PR #93137** fix(imessage): honor disabled reply actions. Related #92142. Thanks @omarshahine and @dprev.
|
||||
- **PR #93134** fix(feishu): pass card_msg_content_type to get full card content (fixes #78289). Thanks @liuhao1024 and @vincentkoc and @longdoubled7.
|
||||
- **PR #93138** fix(agents): preserve literal current session resolution. Thanks @liuhao1024 and @vincentkoc.
|
||||
- **PR #91225** fix #83830: [Bug]: Dreaming diary repeats "first day" narrative every sweep — same early memories dominate snippets. Thanks @mushuiyu886 and @YinLiuLiu66.
|
||||
- **PR #93153** simplify QA evidence profile and mappings/coverage shape. Thanks @RomneyDa.
|
||||
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
|
||||
- **PR #93119** fix: accept mixed source/dist bundled roots. Related #87730. Thanks @arkyu2077 and @vincentkoc and @jasonftl.
|
||||
- **PR #93130** fix(telegram): preserve sticker media paths. Related #83748. Thanks @goutamadwant and @vincentkoc and @aaajiao.
|
||||
- **PR #93073** fix(agents): retry empty post-tool final turns. Thanks @fuller-stack-dev.
|
||||
- **PR #91784** fix(voice-call): require realtime websocket path boundary. Thanks @jason-allen-oneal.
|
||||
- **PR #89133** Restore GPT-5.3 Codex Spark OAuth routing. Thanks @VACInc.
|
||||
- **PR #91996** refactor: prune unused iOS code. Thanks @zats.
|
||||
- **PR #90231** fix #69443: [Bug] Subagent RPC callback to WeChat session key routed to main session instead. Thanks @zhangguiping-xydt and @sliverp and @chen11221.
|
||||
- **PR #89920** fix(matrix): replace recovered command progress lines. Thanks @bdjben and @jesse-merhi.
|
||||
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
|
||||
- **PR #93201** fix(auto-reply): clear pending-final state before honoring post-send abort (#89115). Thanks @amknight and @danashburn.
|
||||
- **PR #93228** fix(agents): replace prose terminal classifiers. Thanks @fuller-stack-dev.
|
||||
- **PR #93231** fix(status): correct pinned model clear hint. Thanks @hxy91819.
|
||||
- **PR #92428** fix(qqbot): keep markdown table chunks valid. Thanks @sliverp.
|
||||
- **PR #93220** fix(status): avoid stale session context windows. Thanks @hxy91819.
|
||||
- **PR #91957** perf(sessions): share one enumeration across archive retention sweeps. Thanks @amknight.
|
||||
- **PR #93281** fix(telegram): recover pid-reused ingress claims. Thanks @obviyus.
|
||||
- **PR #93287** fix(codex): preserve terminal outcome ordering.
|
||||
- **PR #93182** fix(memory): clean rollback-journal reindex temp sidecar on NFS stores. Thanks @Alix-007.
|
||||
- **PR #93283** Persist ClawHub skill install provenance. Related #92077. Thanks @momothemage and @nmccready-tars.
|
||||
- **PR #88872** fix: attribute spawned task runs to child agent. Related #66670. Thanks @Alix-007 and @Neomail2.
|
||||
- **PR #92837** fix(android): show live chat context usage. Thanks @Tosko4.
|
||||
- **PR #93325** fix(cli): harden official plugin recovery. Thanks @vincentkoc.
|
||||
- **PR #93286** feat(telegram): send rich messages as rich html. Thanks @obviyus.
|
||||
- **PR #92910** fix(memory-core): safely refresh qmd index during collection repair.
|
||||
- **PR #93329** fix(cli): allow zero Discord timeout duration. Related #93327. Thanks @rohitjavvadi.
|
||||
- **PR #91625** fix(cron): add cron edit --clear-model to clear a job's model override. Thanks @ly-wang19.
|
||||
- **PR #91691** [AI] fix(memory): prevent empty-string expectedModel in resolveMemory…. Thanks @xydt-tanshanshan.
|
||||
- **PR #93006** fix(tui): keep stderr visible when local shell stdout fills the output cap. Thanks @Alix-007.
|
||||
- **PR #93001** fix(daemon): prefer stderr over stale stdout in gateway restart diagnostics. Thanks @Alix-007.
|
||||
- **PR #91117** refactor: remove dead code and improve string concatenation. Thanks @Pommelle.
|
||||
- **PR #90893** fix(models): mask paste-token input in CLI auth prompt. Thanks @anurag-bg-neu.
|
||||
- **PR #90571** fix(configure): mask gateway password input in CLI wizard prompt. Thanks @anurag-bg-neu.
|
||||
- **PR #91768** fix(ios): respect chat header safe area. Thanks @zats.
|
||||
- **PR #93245** fix(cron): resolve lastRunStatus in cron list/show human output. Thanks @ly-wang19.
|
||||
- **PR #78765** fix(tui): avoid inserting spaces into long CJK text. Thanks @hpt.
|
||||
- **PR #91776** fix(ios): refresh permission rows after grants. Thanks @zats.
|
||||
- **PR #92817** fix(cron): trust agent output when channel is unresolved without explicit delivery. Related #90664. Thanks @fsdwen and @dertbv.
|
||||
- **PR #93297** fix(control-ui): respect agents.defaults.timeFormat for timestamps. Related #58147. Thanks @ZengWen-DT and @Zeng-wen and @TommoT2.
|
||||
- **PR #93364** Fix Telegram rich progress command output. Thanks @obviyus.
|
||||
- **PR #91952** feat(status): surface plugin health. Thanks @jalehman.
|
||||
- **PR #75025** fix(heartbeat): refresh stale Current time line on every helper call (#44993). Thanks @MoerAI and @mclee1975.
|
||||
- **PR #90992** docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0 idle-termination. Thanks @spencer2211.
|
||||
- **PR #86544** fix(cli): show Gemini CLI runtime auth status. Related #79585. Thanks @giodl73-repo and @fabricefoy.
|
||||
- **PR #88945** fix(plugins): serialize binding approval saves. Related #64065. Thanks @Alix-007 and @lihaokun.
|
||||
- **PR #90115** fix(gateway): pass managed inbound PDFs through chat.send. Related #90097. Thanks @harjothkhara and @joeykrug.
|
||||
- **PR #74613** docs(cli): add agent selector to CLI backend quick start. Related #68940. Thanks @vyctorbrzezowski and @drmarcopapa.
|
||||
- **PR #89121** refactor: add transcript reader seam. Thanks @jalehman.
|
||||
- **PR #84434** fix(cli): disable ScheduleWakeup/CronCreate in --print claude runs. Thanks @SkyWolfDreamer.
|
||||
- **PR #66985** fix(agents): resolve requestedNode to canonical ID before boundNode comparison. Related #87213. Thanks @mujiannan.
|
||||
- **PR #91488** fix(reply): project preflight compaction gate by next-input size on fresh tokens. Thanks @yetval.
|
||||
- **PR #93353** fix(plugins): require owner for plugin writes. Thanks @eleqtrizit.
|
||||
- **PR #91499** fix(cron): preserve scheduled turn tool policy [AI]. Thanks @mmaps.
|
||||
- **PR #90412** fix(sessions): cache warm transcript reads to avoid per-turn re-parse. Related #83943. Thanks @Alix-007 and @yyds-xxxx.
|
||||
- **PR #93118** fix(gateway): guard fast-path startup migrations. Related #93032. Thanks @openperf and @Haderach-Ram.
|
||||
- **PR #93355** fix(ci): verify performance workflow downloads. Thanks @eleqtrizit.
|
||||
- **PR #93358** fix(outbound): guard cross-context message mutations. Thanks @eleqtrizit.
|
||||
- **PR #93362** fix(flock): bind allow-always to wrapped command. Thanks @eleqtrizit.
|
||||
- **PR #92578** refactor(whatsapp): add inbound admission foundation. Thanks @mcaxtr.
|
||||
- **PR #89547** Control Telegram group history context. Thanks @mmaps.
|
||||
- **PR #89201** refactor: add transcript runtime identity contract. Thanks @jalehman.
|
||||
- **PR #93357** fix(plugins): enforce install policy in wrappers. Thanks @eleqtrizit.
|
||||
- **PR #93156** fix(doctor): import default-agent auth profiles into sqlite. Related #93145. Thanks @Pick-cat and @sallyom and @Tazio7.
|
||||
- **PR #93179** Add slim evidence mode for QA profile evidence. Thanks @RomneyDa.
|
||||
- **PR #93349** fix(control-ui): keep workboard card titles visible in overflowing columns (fixes #91717). Thanks @Pick-cat and @NicoBoom13.
|
||||
- **PR #93324** fix(cli): accept --no-color after subcommands. Thanks @ooiuuii.
|
||||
- **PR #89621** Return Google Chat thread metadata from message sends. Thanks @franco-viotti.
|
||||
- **PR #82458** fix(infra): drop duplicated "restart" word in restart-sentinel summary. Thanks @jameswniu.
|
||||
- **PR #85471** Suppress cron announce control replies. Related #85421. Thanks @TurboTheTurtle and @leatherneck-33.
|
||||
- **PR #85316** fix(auth): keep alias-compatible auth-profile overrides instead of clearing them. Thanks @SkyWolfDreamer.
|
||||
- **PR #89260** fix(doctor): separate platform-incompatible skills from missing requirements. Related #89232. Thanks @Alix-007 and @CameronWeller.
|
||||
- **PR #90846** fix(media): stop pruning media on write; let the configured timer do it. Thanks @lundog.
|
||||
- **PR #88062** fix(logging): avoid stalled warnings for active model calls. Thanks @litang9.
|
||||
- **PR #93308** fix(discord): reject malformed realtime consult calls. Thanks @khoek.
|
||||
- **PR #93334** fix(whatsapp): notify user when trailing media send fails instead of silent drop. Thanks @rushindrasinha.
|
||||
- **PR #92575** fix(sessions): preserve user behavior overrides across daily/idle rollover (#92562) [AI-assisted]. Thanks @harjothkhara and @civiltox.
|
||||
- **PR #89124** refactor: route auto-reply sessions through session seam. Thanks @jalehman.
|
||||
- **PR #93431** fix: stabilize transcript cache and CLI env isolation. Thanks @shakkernerd.
|
||||
- **PR #93412** fix(discord): suppress tool progress for message-tool replies. Thanks @mgunnin and @vincentkoc.
|
||||
- **PR #93409** fix(whatsapp): stop markdownToWhatsApp dropping code spans followed by a digit. Thanks @rushindrasinha.
|
||||
- **PR #93295** fix(memory): swap rollback-journal sidecar during atomic reindex. Thanks @Alix-007.
|
||||
- **PR #93076** fix(whatsapp): preserve auth on terminal disconnects. Thanks @mcaxtr.
|
||||
- **PR #93435** fix(agents): bound autoreview scope. Thanks @vincentkoc.
|
||||
- **PR #93279** fix(telegram): restore readable default text sends. Related #93263. Thanks @NianJiuZst and @SweetSophia.
|
||||
- **PR #93429** fix(line): cap carousel column text at 60 chars when a title or image is set. Thanks @harjothkhara and @vincentkoc.
|
||||
- **PR #93428** fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419). Thanks @zenglingbiao and @vincentkoc and @danielgerlag.
|
||||
- **PR #93427** fix(tui): show activity indicator for system-injected runs. Related #51825. Thanks @ZengWen-DT and @vincentkoc and @Zeng-wen and @AlethiaQuizForge.
|
||||
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
|
||||
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
|
||||
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
|
||||
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt and @vincentkoc and @0pen7ech.
|
||||
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
|
||||
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
|
||||
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
|
||||
- **PR #93443** fix(gateway): block internal HTTP session overrides. Thanks @RichardCao.
|
||||
- **PR #93454** fix(sqlite): disable WAL on network filesystems. Thanks @vincentkoc.
|
||||
- **PR #90275** test: make install-safe-path symlink tests compatible with Windows. Thanks @aniruddhaadak80.
|
||||
- **PR #93464** fix(qa): suppress empty WhatsApp debug artifacts. Thanks @vincentkoc.
|
||||
- **PR #90861** fix(cli): preserve sessions_yield over MCP. Related #77426. Thanks @zhangguiping-xydt and @jarvisagimuspicard-hub.
|
||||
- **PR #90946** fix(infra): preserve inherited gateway PID across reparent during cleanup. Thanks @amittell.
|
||||
- **PR #92220** fix(media): extract large managed inbound PDFs via media-understanding. Related #90096, #90097. Thanks @amknight and @joeykrug.
|
||||
- **PR #91208** fix #91047: Plugin session-extension registry not pinned; sessions.pluginPatch fails after agent/subagent plugin-load churn. Thanks @mushuiyu886 and @teamadams.
|
||||
- **PR #92111** fix(update): restart managed gateway when update handoff fails after stop. Related #92088. Thanks @yetval and @ofan.
|
||||
- **PR #93238** fix(agents): honor disabled envelope timestamps at model boundary. Thanks @osolmaz.
|
||||
- **PR #93343** fix(codex): de-duplicate commentary notes across the raw response lane. Related #93296. Thanks @Marvinthebored and @Peetiegonzalez.
|
||||
- **PR #93361** fix(openshell): pin mirror remote mutations. Thanks @eleqtrizit.
|
||||
- **PR #93354** fix(discord): block cross-provider guild admin actions. Thanks @eleqtrizit.
|
||||
- **PR #92178** fix(gateway): normalize malformed paired access lists. Related #90654. Thanks @wangmiao0668000666 and @EmilioNicolas.
|
||||
- **PR #85254** perf(plugins): thread prepared manifestPlugins through runtime model-id normalize chain. Thanks @zeroaltitude.
|
||||
- **PR #93489** Add ClawHub content rights docs to sidebar. Thanks @Patrick-Erichsen.
|
||||
- **PR #93466** [AI] fix(feishu): guard against missing inbound in channelRuntime fallback. Thanks @xydt-tanshanshan.
|
||||
- **PR #93460** fix(cli): honor --log-level in route-first commands. Related #93457. Thanks @ooiuuii.
|
||||
- **PR #93495** fix(cron): clear delivery routing fields from cron edit. Thanks @ly-wang19 and @vincentkoc.
|
||||
- **PR #93494** docs: point PR landing at maintainer workflow. Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- **PR #93487** fix(ui): add agent selector to skills page. Related #78553. Thanks @goutamadwant and @vincentkoc and @xiaobu1112.
|
||||
- **PR #93488** fix(discord): apply tool status emojis immediately to avoid override by thinking reactions. Related #92715. Thanks @lzyyzznl and @vincentkoc and @darealgege.
|
||||
- **PR #93055** fix(ui): restore provider usage pill in desktop chat composer [AI]. Thanks @harjothkhara.
|
||||
- **PR #83156** fix(matrix): accept bracketed display-name mentions. Related #83142. Thanks @wdx-agent-io and @wdongxv.
|
||||
- **PR #93333** fix(auto-reply): redact secrets in /debug show and /debug set output. Thanks @Alix-007.
|
||||
- **PR #88496** fix(auto-reply): redact secrets in config show output. Related #65623. Thanks @jason-allen-oneal and @coygeek.
|
||||
- **PR #93105** fix(doctor): repair null agents.list[].workspace values. Related #77718. Thanks @xydigit-sj and @slideshow-dingo.
|
||||
- **PR #73923** fix(ui): preserve gateway token during safe websocket url edits. Related #41545. Thanks @wsyjh8.
|
||||
- **PR #88970** fix #85871: [Bug]: Heartbeat scheduler silently fails to fire on 5.20 and all 5.x versions (regression from 4.23). Thanks @zhangguiping-xydt and @vincentkoc and @carlbjson.
|
||||
- **PR #93511** fix(imessage): normalize leading NUL echo-cache prefixes. Thanks @vincentkoc and @drvoss.
|
||||
- **PR #92594** [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works. Related #92391. Thanks @zhangguiping-xydt and @vincentkoc and @kvzsolt.
|
||||
- **PR #93512** build(docs): finish PowerShell-safe docs formatting. Related #44293. Thanks @vincentkoc and @yil337 and @aniruddhaadak80.
|
||||
- **PR #93513** fix(skills): refresh persisted snapshots after restart. Thanks @vincentkoc and @fif911 and @skadauke.
|
||||
- **PR #93517** fix(skills): quote skill-creator template description. Thanks @vincentkoc and @parubets.
|
||||
- **PR #73976** fix(memory): use per-keyword FTS search in hybrid mode #39484. Thanks @joshuakeithpa-sudo.
|
||||
- **PR #93520** fix(workspace): store setup state outside workspace dot-dir. Thanks @vincentkoc and @1qh.
|
||||
- **PR #93521** fix(onboard): skip Homebrew prompt on unsupported platforms. Related #68893. Thanks @vincentkoc and @yurivict.
|
||||
- **PR #93522** fix(feishu): send post mentions as native at elements. Thanks @vincentkoc and @gavin-ali and @YizukiAme and @Panniantong.
|
||||
- **PR #93496** fix(gateway): rotate already-stale generated transcript filename on /reset. Thanks @harjothkhara and @vincentkoc.
|
||||
- **PR #93471** fix(cron): preserve aborted isolated-run failure. Thanks @BhargavSatya and @vincentkoc.
|
||||
- **PR #93473** fix(memory): report skipped QMD embedding probe. Related #77645. Thanks @TurboTheTurtle and @vincentkoc and @aderius.
|
||||
- **PR #93498** fix(ui): preserve CJK IME composition. Related #86035. Thanks @Zhaoqj2016 and @vincentkoc.
|
||||
- **PR #93088** fix(telegram): bind bot mentions to assistant identity. Thanks @kesslerio and @vincentkoc.
|
||||
- **PR #93499** fix(nodes): return screen snapshots as media. Related #90126. Thanks @zenglingbiao and @vincentkoc and @JeffSteinbok.
|
||||
- **PR #93506** fix(skills): trust verified ClawHub source provenance. Thanks @vincentkoc.
|
||||
- **PR #93525** agents: notify chat exec empty-success completions. Thanks @vincentkoc and @wenkang-xie.
|
||||
- **PR #93446** feat: add Codex hosted web search. Thanks @fuller-stack-dev.
|
||||
- **PR #92883** fix(security): audit open dm tool exposure. Related #55612. Thanks @yu-xin-c and @vincentkoc and @cjg20ss.
|
||||
- **PR #93476** fix(mattermost): preserve Codex progress preview. Related #88766. Thanks @goutamadwant and @vincentkoc and @KelTech-Services.
|
||||
- **PR #93395** feat(cron): add compact list responses. Related #93366. Thanks @yu-xin-c and @vincentkoc and @centralpc.
|
||||
- **PR #93527** fix(cron): preserve model overrides for text payloads. Thanks @vincentkoc and @liaoandi.
|
||||
- **PR #90487** fix: harden ChatGPT Responses missing content-type streams. Thanks @anyech and @vincentkoc.
|
||||
- **PR #93528** fix(gateway): tolerate transient pre-hello clean closes. Thanks @vincentkoc and @ruanrrn.
|
||||
- **PR #93529** fix(auto-reply): allow message tool for group attachments. Related #43146. Thanks @vincentkoc and @Robcis.
|
||||
- **PR #93291** fix(reply): preserve pending thread evidence when reconciling partial send results. Thanks @yetval and @vincentkoc.
|
||||
- **PR #90572** fix(feishu): drop self-authored receive echoes. Thanks @baskduf.
|
||||
- **PR #93455** fix(cli): accept --log-level after subcommands. Thanks @ooiuuii and @vincentkoc.
|
||||
- **PR #93452** fix(bedrock): strip inference profile prefix from model ID in embedding adapter. Related #79212. Thanks @LiuwqGit and @vincentkoc and @aleck31.
|
||||
- **PR #89799** fix(cli): skip compile cache on early Node 24.x to avoid startup deadlock. Related #86550. Thanks @zhangguiping-xydt and @vincentkoc and @renyuliang000.
|
||||
- **PR #93469** fix(agents): drop partialJson streaming artifacts from session history repair. Thanks @drvoss and @vincentkoc.
|
||||
- **PR #93463** fix(codex): log app-server compaction completion. Related #83932. Thanks @goutamadwant and @vincentkoc and @aounakram.
|
||||
- **PR #93562** fix(tui): refresh after external session reset. Related #38966. Thanks @vincentkoc and @wsyjh8 and @yizhanzjz.
|
||||
- **PR #93470** fix(plugins): load externally-installed channel plugins at gateway startup. Related #93219. Thanks @sunlit-deng and @vincentkoc and @cxdnicole.
|
||||
- **PR #88796** fix(discord): resolve guildId from session channel for search actions. Related #88790. Thanks @SebTardif and @vincentkoc and @mugabuga.
|
||||
- **PR #93194** fix(agents): preserve prompt-released session metadata. Related #93193. Thanks @snowzlm.
|
||||
- **PR #89483** fix(gateway): project failed agent turns in chat history. Related #89197. Thanks @IWhatsskill and @vincentkoc and @yangiit.
|
||||
- **PR #93434** fix: avoid parent group allowlist false positive. Related #92684. Thanks @kingrubic and @vincentkoc and @motteman.
|
||||
- **PR #93449** fix(feishu): dedupe redelivered text by stable retry identity. Related #46778. Thanks @ZengWen-DT and @vincentkoc and @kingcuty.
|
||||
- **PR #93407** AGT-80 AGT-81 Fix Discord ingress ack ordering. Thanks @mgunnin and @vincentkoc.
|
||||
- **PR #93439** fix(agents): honor embedded run default model. Related #93419. Thanks @harjothkhara and @vincentkoc and @danielgerlag.
|
||||
- **PR #93565** fix(cli): summarize cleanup dry-run by label. Related #76826. Thanks @AgentArcLab and @vincentkoc and @renatomaluhy.
|
||||
- **PR #93509** fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93274** Clarify plugin channel config additional-property errors. Thanks @zhangguiping-xydt and @vincentkoc.
|
||||
- **PR #93555** fix(read): route text decoding through shared Windows codepage fallba…. Thanks @zhanxingxin1998 and @vincentkoc.
|
||||
- **PR #93314** fix(skills): preserve ClawHub origin provenance on readback. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93573** fix(acp): keep bridge sessions out of stale ACP classification [AI-assisted]. Related #38907. Thanks @eldar702 and @vincentkoc and @ninaopenclaw.
|
||||
- **PR #93398** fix(cron): emit isolated model usage diagnostics. Related #92338. Thanks @849261680 and @vincentkoc and @niks999.
|
||||
- **PR #93367** Fix SSH sandbox remote directory args. Related #93344. Thanks @dmorn and @vincentkoc.
|
||||
- **PR #93574** fix(feishu): suppress log noise for bot_p2p_chat_entered_v1 event [AI-assisted]. Related #42351. Thanks @eldar702 and @vincentkoc and @sunking0223.
|
||||
- **PR #93269** Fix tokenjuice bash results without details. Thanks @moeedahmed and @vincentkoc.
|
||||
- **PR #93575** fix(telegram): hydrate group reply-chain media into model context [AI-assisted]. Thanks @eldar702 and @vincentkoc.
|
||||
- **PR #93261** fix(plugins): resolve provider policy surface for plugin-owned CLI backends. Related #93259. Thanks @BitmapAsset and @vincentkoc.
|
||||
- **PR #93303** fix(whatsapp): bound stalled read-receipt socket operations. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93242** fix(mattermost): keep bare @mention with empty body instead of dropping it. Related #93205. Thanks @iloveleon19 and @vincentkoc.
|
||||
- **PR #93606** fix(ui): clear stale Talk error when session transitions to non-error state (fixes #88176). Thanks @liuhao1024 and @vincentkoc and @BrianClaw1955.
|
||||
- **PR #93607** perf(tasks): memoize reconcileInspectableTasks for same-tick calls (fixes #73531). Thanks @liuhao1024 and @vincentkoc and @slideshow-dingo.
|
||||
- **PR #93612** fix(gateway): compute sessions.usage aggregate totals from all sessions, not just the limited page (fixes #76496). Thanks @liuhao1024 and @vincentkoc and @bobsahur-robot.
|
||||
- **PR #93615** fix(telegram): recover lone active spooled handler on timeout (#84158). Thanks @0xghost42 and @vincentkoc and @crash2kx.
|
||||
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
|
||||
- **PR #93298** fix #93044: control-ui webchat double-renders agent replies when dmScope=main. Thanks @zhangguiping-xydt and @vincentkoc and @cfmilam.
|
||||
- **PR #93618** fix(feishu): filter temporary card-action-c-\* IDs from reply target to prevent Invalid open_message_id errors (fixes #56818). Thanks @liuhao1024 and @vincentkoc and @SwordImmortal.
|
||||
- **PR #93387** feat(ios): add watch action surface. Thanks @Solvely-Colin and @joshavant.
|
||||
- **PR #93648** fix(doctor): archive superseded plugin install index conflicts. Related #90418. Thanks @vincentkoc and @ramitrkar-hash.
|
||||
- **PR #93649** fix(qwen): place DashScope image prompts in user content. Related #92688. Thanks @vincentkoc and @Yachiyo404.
|
||||
- **PR #93650** fix(update): avoid per-Node npm prefixes during self-update. Related #80387. Thanks @vincentkoc and @yaanfpv.
|
||||
- **PR #93653** fix(skill-workshop): skip helper sessions during auto-capture. Thanks @vincentkoc and @zhangguiping-xydt.
|
||||
- **PR #93654** fix(codex): expose remote node exec as a Codex dynamic tool. Related #92141. Thanks @vincentkoc and @JPKay-AI.
|
||||
- **PR #93662** fix(discord): protect mention aliases in code fences. Thanks @vincentkoc and @rohitjavvadi.
|
||||
- **PR #93663** fix(clawdock): open dashboard on published port without starting deps. Related #77344. Thanks @vincentkoc and @dhoman.
|
||||
- **PR #93670** fix(browser): recover stale managed Chrome CDP listener. Related #41750. Thanks @vincentkoc and @rohitjavvadi and @kissman911.
|
||||
- **PR #93672** fix(commands): preserve multiline slash skill args. Related #79155. Thanks @vincentkoc and @web3blind.
|
||||
- **PR #93674** fix(browser): accept top-level act fields with nested requests. Related #38762. Thanks @vincentkoc and @angelusbr and @Lumos-789.
|
||||
- **PR #93678** fix(plugins): allow Dreaming sidecar through restrictive memory allowlists. Related #92536. Thanks @vincentkoc and @pradeep7127 and @resYuto.
|
||||
- **PR #93306** fix(status): ignore stale context after model switch. Thanks @hxy91819.
|
||||
- **PR #93666** fix(control-ui): copy code blocks over plain HTTP via clipboard fallback. Related #93628. Thanks @Pick-cat and @pjq2926.
|
||||
- **PR #93629** fix(reply): preserve unsent text-only finals after block pipeline streamed partial content (fixes #81078). Thanks @liuhao1024 and @Jackten.
|
||||
- **PR #93690** fix(telegram): dispatch MEDIA directives as attachments. Related #77702. Thanks @vincentkoc and @butttersbot.
|
||||
- **PR #93693** fix(gateway): ignore stale sudo scope for root user services. Related #81410. Thanks @vincentkoc and @Ericksza.
|
||||
- **PR #93646** fix(agents): return string assistant content in getLastAssistantText. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93687** fix(i18n): retain Codex error tails in logs. Thanks @hxy91819.
|
||||
- **PR #93630** fix(heartbeat): bootstrap plugin session targets. Thanks @ZengWen-DT and @vincentkoc.
|
||||
- **PR #93658** fix(wizard): preserve existing default model during setup auth choice [AI-assisted]. Related #64129. Thanks @ml12580 and @vegapunk9527.
|
||||
- **PR #93671** fix(respawn): rewrite pnpm versioned entry paths to stable wrapper (fixes #52313). Thanks @liuhao1024 and @vincentkoc and @RichardCao.
|
||||
- **PR #93698** Fix Telegram rich progress detail updates. Thanks @obviyus.
|
||||
- **PR #93656** fix(gateway): send approval route notices with write scope. Related #93563. Thanks @mushuiyu886 and @vincentkoc and @clawbot247-commits.
|
||||
- **PR #93665** fix(gateway): surface codex app-server returned failures. Thanks @litang9 and @vincentkoc.
|
||||
- **PR #93727** fix(context-engine): avoid turn-maintenance lane livelock. Related #77340. Thanks @vincentkoc and @baghvn and @Veda-openclaw.
|
||||
- **PR #93681** fix(llm): handle string assistant content on the OpenAI-compatible completion path. Thanks @Alix-007.
|
||||
- **PR #93722** chore(release): update appcast for 2026.6.8. Thanks @vincentkoc.
|
||||
- **PR #93677** fix(google-meet): declare realtime provider secret inputs. Related #81891. Thanks @goutamadwant and @vincentkoc and @chachi-max.
|
||||
- **PR #92947** fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root. Related #92816. Thanks @ZengWen-DT and @Zeng-wen and @lewiswu1209.
|
||||
- **PR #93679** fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099). Thanks @liuhao1024 and @vincentkoc and @bugkill3r.
|
||||
- **PR #93688** fix(minimax): check base_resp envelope errors in TTS provider. Related #76904. Thanks @dwc1997 and @najef1979-code.
|
||||
- **PR #93714** fix: isolate async model resolution mock from sync mock in flaky test. Related #92117. Thanks @lsr911 and @wangwllu.
|
||||
- **PR #93705** test(macos): cover root command dispatch. Related #83879. Thanks @markoub and @vincentkoc and @davinci282828.
|
||||
- **PR #93711** Keep command text in progress drafts. Thanks @keshavbotagent and @vincentkoc.
|
||||
- **PR #93712** fix: scope assistant avatar override to agent ID. Related #90890. Thanks @lsr911 and @vincentkoc and @najef1979-code.
|
||||
- **PR #93725** fix(usage): prune stale usage cache temp files. Related #78939. Thanks @markoub and @Tramsrepus.
|
||||
- **PR #93726** fix(typing): start typing on reasoning deltas in thinking mode before visible text. Related #79681. Thanks @xialonglee and @novaflash82.
|
||||
- **PR #93716** fix(discord): propagate timeout through channel capabilities diagnostics. Related #77040. Thanks @xialonglee and @vincentkoc and @unicebondoc.
|
||||
- **PR #93729** fix(ollama): preserve configured API during discovery. Related #93710. Thanks @zhangguiping-xydt and @vincentkoc and @obnoxious2011-cmd.
|
||||
- **PR #93719** fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting. Related #90814. Thanks @lsr911 and @vincentkoc and @k-l-lambda.
|
||||
- **PR #93732** fix(agents): preserve re-sent user prompt during compaction transcript rotation. Thanks @yetval.
|
||||
- **PR #93738** fix: break plugin registry type import cycle. Thanks @giodl73-repo.
|
||||
- **PR #93740** fix(sessions): release retained locks after takeover. Thanks @TurboTheTurtle.
|
||||
- **PR #93745** fix(usage): reject invalid explicit dates in usage RPC date parsing. Thanks @harjothkhara and @vincentkoc.
|
||||
- **PR #93746** fix(ui): populate realtime talk provider and transport options from talk.catalog. Thanks @shushushv and @vincentkoc.
|
||||
- **PR #93751** fix(ios): fix quick setup sheet layout design. Thanks @zats.
|
||||
- **PR #93749** fix(compaction): ignore stale persisted totalTokens in preflight gate. Thanks @yetval.
|
||||
- **PR #93753** fix: correct tautological uppercase check in tool description summarizer. Thanks @GautamKumarOffical.
|
||||
- **PR #89123** refactor: route transcript writers through session seam. Thanks @jalehman.
|
||||
- **PR #93758** feat(memory): apply outputDimensionality truncation to local GGUF embeddings (fixes #58765). Thanks @liuhao1024 and @vincentkoc and @losz5000.
|
||||
- **PR #93754** feat(inbound-meta): expose per-turn source modality. Related #50482. Thanks @liuhao1024 and @vincentkoc and @JTOrca.
|
||||
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1 and @vincentkoc.
|
||||
- **PR #93772** fix(feishu): recover CJK filenames from JSON file_name field (fixes #81103). Thanks @liuhao1024 and @vincentkoc and @pjuneye.
|
||||
- **PR #93773** fix(ui): scope Skill Workshop proposals to selected agent. Related #93760. Thanks @TurboTheTurtle and @vincentkoc and @hannesrudolph.
|
||||
- **PR #88750** feat(context-engine): pass runtime settings into lifecycle. Thanks @ragesaq and @jalehman.
|
||||
- **PR #93763** fix(agents): use neutral billing copy for subscription auth. Related #80877. Thanks @eldar702 and @vincentkoc and @22kyasue.
|
||||
- **PR #93818** List all ClawHub docs in sidebar. Thanks @Patrick-Erichsen.
|
||||
- **PR #93779** fix(webchat): skip textarea resize during IME composition to eliminate typing lag. Related #90800. Thanks @joelnishanth and @vincentkoc and @w10497-create.
|
||||
- **PR #93786** fix(plugins): treat refreshable catalogs as requiring runtime discovery (fixes #93775). Thanks @liuhao1024 and @St0rmz1.
|
||||
- **PR #93791** fix(memory): await search-sync before returning results to prevent stale index (fixes #52115). Thanks @liuhao1024 and @vincentkoc and @FicheallADa.
|
||||
- **PR #93780** fix(google): keep parallel Gemini tool responses in the turn after the model. Thanks @yetval and @vincentkoc.
|
||||
- **PR #93789** fix(agents): make lane suspension consistent across cooldown-precheck and embedded-runner paths. Related #93036. Thanks @joelnishanth and @vincentkoc and @kumaxs.
|
||||
- **PR #93798** fix(status): show 0 (not ?) for fresh-session context tokens. Related #93771. Thanks @Alix-007 and @vincentkoc and @anarchia-99.
|
||||
- **PR #93810** fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass. Thanks @yetval.
|
||||
- **PR #93811** Strip UTF-8 BOM when reading SKILL.md in quick_validate. Thanks @HrachShah.
|
||||
- **PR #93803** fix(ui): preserve WebChat visible messages across session switches. Related #80855. Thanks @LiuwqGit and @vincentkoc and @viagarsuker.
|
||||
- **PR #93792** fix(android): wait for node capability approval before onboarding. Thanks @Solvely-Colin and @vincentkoc.
|
||||
- **PR #93796** fix(feishu): paginate wiki node and space listing (#37626). Thanks @ZengWen-DT and @vincentkoc and @ritou11.
|
||||
- **PR #93797** fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable (fixes #63343). Thanks @liuhao1024 and @vincentkoc and @OpenCodeEngineer.
|
||||
- **PR #93806** fix(reasoning-tags): strip MiniMax mm: tags on silent-reply and streaming paths missed by #93767. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93691** refactor: add gateway sessions.create lifecycle seam. Thanks @jalehman.
|
||||
- **PR #88748** fix(gemini): bridge OAuth profiles into CLI runtime. Related #88742. Thanks @jason-allen-oneal.
|
||||
- **PR #93857** fix(deps): remediate Dependabot alerts. Thanks @vincentkoc.
|
||||
- **PR #93874** fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview. Thanks @Alix-007.
|
||||
- **PR #93832** feat(providers): add ClawRouter managed proxy. Thanks @vincentkoc.
|
||||
- **PR #93880** fix(macos): preserve approvals migration data. Thanks @vincentkoc.
|
||||
- **PR #93903** fix(cron): reject invalid absolute timestamps. Thanks @Alix-007 and @vincentkoc.
|
||||
- **PR #93879** fix(update): use configured npm registry for update metadata. Related #79140. Thanks @vincentkoc and @sixerLiu.
|
||||
- **PR #93924** revert(providers): remove ClawRouter provider. Thanks @vincentkoc.
|
||||
- **PR #93955** fix(telegram): surface rich-message disabled state. Thanks @obviyus.
|
||||
- **PR #93881** fix(agents): route BTW through canonical Codex runtime. Related #88902. Thanks @vincentkoc and @TurboTheTurtle and @khalil-omer.
|
||||
- **PR #90192** fix(feishu): fetch quoted content before empty-message guard. Related #90177. Thanks @bladin and @sliverp and @lkxlaz.
|
||||
- **PR #93237** Fix Mattermost open DM validation. Thanks @amknight.
|
||||
- **PR #93945** feat(diagnostics): add SIEM security events. Thanks @vincentkoc.
|
||||
- **PR #87487** fix(cli): clarify mcp list registry scope. Related #65209. Thanks @Alix-007 and @slideshow-dingo.
|
||||
- **PR #24661** feat(cohere): add provider plugin. Thanks @vincentkoc.
|
||||
- **PR #93532** Expose verified ClawHub source in skill verify output. Thanks @momothemage.
|
||||
- **PR #93538** feat(codex): support app-server network proxy profiles. Thanks @vincentkoc.
|
||||
- **PR #93938** fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers. Related #93921. Thanks @Nas01010101 and @vincentkoc.
|
||||
- **PR #94104** feat(agents): trace compaction summarization model calls. Thanks @amknight.
|
||||
- **PR #94108** Fix package Telegram temp root. Thanks @obviyus.
|
||||
- **PR #94113** Fix Telegram package output mount. Thanks @obviyus.
|
||||
- **PR #89062** feat(docker): support offline setup reruns. Related #70443. Thanks @Alix-007 and @safrano9999.
|
||||
- **PR #93929** fix(secrets): explicitly pass BWS_SERVER_URL to resolver for self-hosted instances. Related #93851. Thanks @Pandah97 and @vincentkoc and @AdoShan.
|
||||
- **PR #90057** Polish Workboard operations view. Thanks @fuller-stack-dev.
|
||||
- **PR #89396** fix(doctor): drop inert legacy cron notify when cron.webhook is unset. Related #44460. Thanks @Alix-007.
|
||||
- **PR #94138** fix(session): prevent stale finalizer from recreating deleted session rows. Related #40840. Thanks @xialonglee and @vincentkoc and @AL-knows.
|
||||
- **PR #93739** refactor: add session patch projection seam. Thanks @jalehman.
|
||||
- **PR #94178** fix(workspace): skip optional bootstrap files when workspace setup is already completed. Related #83593. Thanks @dwc1997 and @jsompis.
|
||||
- **PR #93363** fix(feishu): enforce account tool family gates. Thanks @eleqtrizit.
|
||||
- **PR #93813** fix(codex): keep message registered for internal turns. Related #93750. Thanks @jalehman and @hannesrudolph.
|
||||
- **PR #93659** refactor: add session reset delete lifecycle seam. Thanks @jalehman.
|
||||
- **PR #93852** ci(release): harden release controls. Thanks @vincentkoc.
|
||||
- **PR #94203** feat(codex): support remote app-server plugins. Thanks @kevinslin.
|
||||
- **PR #94263** chore: migrate claw-score skill. Thanks @RomneyDa and @kevinslin.
|
||||
- **PR #93695** refactor: add compact trim lifecycle seam. Thanks @jalehman.
|
||||
- **PR #93114** test: fold lifecycle and package proof into QA Lab. Thanks @RomneyDa.
|
||||
- **PR #93181** test: fold otel smoke into qa e2e. Thanks @RomneyDa.
|
||||
- **PR #93178** test: fold gateway smoke into qa e2e. Thanks @RomneyDa.
|
||||
- **PR #94276** qa-lab: support script-backed evidence scenarios. Thanks @Solvely-Colin and @RomneyDa.
|
||||
- **PR #94282** Support owner-qualified ClawHub skill installs. Thanks @Patrick-Erichsen.
|
||||
- **PR #93704** refactor: add session cleanup lifecycle seam. Thanks @jalehman.
|
||||
- **PR #94296** fix: require all taxonomy coverage ids for a feature - AND not OR. Thanks @RomneyDa.
|
||||
- **PR #92016** fix(plugins): compose live hook registry view for tool-call hooks. Related #91918. Thanks @amknight and @vokaplok.
|
||||
- **PR #89596** fix(policy): recognize declared tool allowlists. Thanks @giodl73-repo.
|
||||
- **PR #93713** fix: route deleted-agent session purge through lifecycle seam. Thanks @jalehman.
|
||||
- **PR #84172** fix(exec): rebuild command authorization on the Tree-sitter command planner. Thanks @jesse-merhi.
|
||||
- **PR #94332** docs: add ClawHub namespace claims to sidebar. Thanks @Patrick-Erichsen.
|
||||
- **PR #86360** fix(codex): honor bound agent exec host policy. Thanks @jesse-merhi.
|
||||
- **PR #73162** fix(slack): remove socket reconnect attempt cap so gateway stays connected indefinitely. Related #72808. Thanks @suboss87 and @tleyden.
|
||||
- **PR #94156** fix: expose OpenAI image quality and moderation CLI options. Thanks @lastguru-net and @fuller-stack-dev.
|
||||
- **PR #94350** feat: externalize GMI provider plugin. Thanks @Patrick-Erichsen and @vincentkoc.
|
||||
- **PR #94543** fix(gateway): bound config.get middleware results. Related #94265. Thanks @vincentkoc and @v-s-gusev.
|
||||
- **PR #91409** fix(update): run plugin convergence after RPC git updates. Thanks @masatohoshino.
|
||||
- **PR #94556** chore(extensions): bump tokenjuice to 0.8.1. Thanks @vincentkoc.
|
||||
- **PR #94580** fix(ci): stabilize update run gates.
|
||||
- **PR #94394** fix(infra): probe 127.0.0.1 in ensurePortAvailable to detect IPv4-only occupants. Related #94379. Thanks @Pandah97 and @wangwllu.
|
||||
- **PR #94421** fix(agents): preserve active compaction retries. Related #94391. Thanks @dexiosmb.
|
||||
- **PR #94428** fix(feishu): preserve replies before error finals. Related #94360. Thanks @xunx33.
|
||||
- **PR #93735** refactor: add restart recovery lifecycle seam. Thanks @jalehman.
|
||||
- **PR #94591** docs(release): backfill complete contribution records. Thanks @vincentkoc.
|
||||
- **PR #94588** fix(cron): retry isolated setup timeouts. Thanks @aaroneden.
|
||||
- **PR #94082** fix(cron): prevent lane timeout during long tool execution. Related #94033. Thanks @ajwan8998 and @JingWang-Star996.
|
||||
- **PR #94551** feat(firecrawl): add keyless scrape support. Thanks @vincentkoc and @developersdigest.
|
||||
- **PR #94619** test(ci): stabilize timeout-sensitive shards. Thanks @vincentkoc.
|
||||
- **PR #94048** fix(telegram): set richMessages default to false explicitly in schema. Related #93770, #93794. Thanks @Monkey-wusky and @obviyus and @Nardoa375 and @laurenceputra.
|
||||
- **PR #94118** [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.
|
||||
- **PR #94646** refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.
|
||||
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
### Highlights
|
||||
@@ -234,6 +643,7 @@ This audited record covers the complete v2026.6.6..v2026.6.8 history: 192 merged
|
||||
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
|
||||
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
|
||||
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
|
||||
|
||||
## 2026.6.7
|
||||
|
||||
### Highlights
|
||||
@@ -320,6 +730,7 @@ This audited record covers the complete v2026.6.6..v2026.6.7-beta.1 history: 59
|
||||
- **PR #92605** fix(docs): pin Windows Hub download links to v2026.6.5. Related #92470. Thanks @lzyyzznl and @arjkul.
|
||||
- **PR #92593** #92589: fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters. Thanks @zhangqueping and @jovi2014-cyber.
|
||||
- **PR #92606** Run Vitest and Playwright scenarios from qa suite. Thanks @RomneyDa.
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
### Highlights
|
||||
@@ -557,6 +968,7 @@ This audited record covers the complete v2026.6.5..v2026.6.6 history: 198 merged
|
||||
- **PR #92150** fix(release): gate beta publish on plugin verification. Thanks @vincentkoc.
|
||||
- **PR #92158** fix(cli): validate gateway RPC timeout inputs. Thanks @ruanrrn and @comeran.
|
||||
- **PR #91911** fix(agents): retry same model across short rate-limit windows. Thanks @lanzhi-lee.
|
||||
|
||||
## 2026.6.5
|
||||
|
||||
### Highlights
|
||||
@@ -741,6 +1153,7 @@ This audited record covers the complete v2026.6.2-beta.1..v2026.6.5 history: 142
|
||||
- **PR #89659** fix(feishu): retry on send rate-limit errors (230020/230006). Related #70879. Thanks @ladygege and @marshallm-create and @sliverp and @AxelHu.
|
||||
- **PR #91547** Fix Docker store seed target packages. Related #91035. Thanks @sallyom and @laurenceputra.
|
||||
- **PR #91423** feat(qqbot): add /bot-group-allways command to toggle mention requirement. Thanks @cxyhhhhh.
|
||||
|
||||
## 2026.6.2
|
||||
|
||||
### Highlights
|
||||
@@ -833,6 +1246,7 @@ This audited record covers the complete v2026.6.1..v2026.6.2-beta.1 history: 57
|
||||
- **PR #89176** fix(browser): honor tab timeout for Chrome MCP. Related #88213. Thanks @MonkeyLeeT and @lamkan0210.
|
||||
- **PR #90043** fix: restore Skill Workshop current chat toggle. Thanks @shakkernerd.
|
||||
- **PR #81422** fix(update): surface plugin channel fallbacks. Thanks @BKF-Gitty.
|
||||
|
||||
## 2026.6.1
|
||||
|
||||
### Highlights
|
||||
@@ -1047,6 +1461,7 @@ This audited record covers the complete v2026.5.31-beta.4..v2026.6.1 history: 11
|
||||
- **PR #88288** fix(config): skip state-dir dotenv values that are unresolved shell references. Related #88274. Thanks @Alix-007 and @mathias15010.
|
||||
- **PR #88305** fix(browser): isolate Chrome MCP pending attach aborts. Related #88304. Thanks @rohitjavvadi.
|
||||
- **PR #74089** fix(openai/tts): handle [[tts:speed]] directive in OpenAI speech provider (#12163). Thanks @stainlu and @useramuser.
|
||||
|
||||
## 2026.5.31
|
||||
|
||||
### Highlights
|
||||
@@ -1177,7 +1592,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
|
||||
- **PR #88346** refactor: extract web content core package.
|
||||
- **PR #71280** test(gateway): avoid brittle shutdown timer assertion. Thanks @hansolo949.
|
||||
- **PR #80686** fix(agents): extend session-write-lock payload-less orphan grace from 5s to 30s. Thanks @wAngByg.
|
||||
- **PR #88067** fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019). Thanks @BSG2000.
|
||||
- **PR #88067** fix(responses): drop orphaned assistant msg\_\* id when reasoning is dropped (#88019). Thanks @BSG2000.
|
||||
- **PR #88417** [codex] Route denied exec approval followups to sessions. Related #88167. Thanks @brokemac79 and @jhartman00.
|
||||
- **PR #85996** fix #85782: surface terminal TUI lifecycle errors. Thanks @zhangguiping-xydt and @vincentkoc and @shakkernerd.
|
||||
- **PR #88445** refactor: source model catalog types from core.
|
||||
@@ -1476,6 +1891,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
|
||||
- **PR #88978** perf(ui): skip closed slash menu rerenders. Thanks @vincentkoc.
|
||||
- **PR #88982** fix(test): wait for telegram timer flushes. Thanks @vincentkoc.
|
||||
- **PR #88989** perf(ui): guard chat transcript rerenders. Thanks @vincentkoc.
|
||||
|
||||
## 2026.5.28
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
7b0d7f0a21c91718fd05151778bb8ff1f16b622599c4dd0a868d72459ad08559 plugin-sdk-api-baseline.json
|
||||
65e710ce7c379b49abf1f5d1b4ef7b4cbabf2820be87f7f300f2988f05f63ec5 plugin-sdk-api-baseline.jsonl
|
||||
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
|
||||
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -131,7 +131,7 @@ Dreaming is the background memory consolidation system with three cooperative
|
||||
phases: **light** (sort/stage short-term material), **deep** (promote durable
|
||||
facts into `MEMORY.md`), and **REM** (reflect and surface themes).
|
||||
|
||||
- Enable with `memory.extensions.memory-core.dreaming.enabled: true`.
|
||||
- Enable with `plugins.entries.memory-core.config.dreaming.enabled: true`.
|
||||
- Toggle from chat with `/dreaming on|off` (or inspect with `/dreaming status`).
|
||||
- Dreaming runs on one managed sweep schedule (`dreaming.frequency`) and executes phases in order: light, REM, deep.
|
||||
- Only the deep phase writes durable memory to `MEMORY.md`.
|
||||
@@ -167,7 +167,7 @@ Example:
|
||||
Notes:
|
||||
|
||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||
- `memory status` includes any extra paths configured via `memory.search.extraPaths`.
|
||||
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
|
||||
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
|
||||
- Tune scheduled sweep cadence with `dreaming.frequency`. Deep promotion policy is otherwise internal except for `dreaming.phases.deep.maxPromotedSnippetTokens`, which bounds promoted snippet length while keeping provenance visible. Use CLI flags on `memory promote` when you need one-off manual threshold overrides.
|
||||
|
||||
@@ -398,12 +398,12 @@ allowlist such as `["all"]`.
|
||||
|
||||
#### Data Handling
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
|
||||
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
|
||||
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
|
||||
| `dataHandling.memory.denySessionTranscriptIndexing` | `agents.*.memory.qmd.sessions.enabled` and `agents.*.memory.search.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
|
||||
| Policy field | Observed state | Use when |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
|
||||
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
|
||||
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
|
||||
| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
|
||||
|
||||
#### Secrets
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Notes:
|
||||
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Plain `openclaw status` stays on the fast read-only path and marks memory as `not checked` instead of unavailable when it skips memory inspection. Heavy security audit, plugin compatibility, and memory-vector probes are left to `openclaw status --all`, `openclaw status --deep`, `openclaw security audit`, and `openclaw memory status --deep`.
|
||||
- `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave built-in `memory.search.enabled` disabled and still report their own files, chunks, vector, and FTS state.
|
||||
- `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave built-in `agents.defaults.memorySearch.enabled` disabled and still report their own files, chunks, vector, and FTS state.
|
||||
- `--usage` prints normalized provider usage windows as `X% left`.
|
||||
- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. See [Agent runtimes](/concepts/agent-runtimes) for the provider/model/runtime distinction.
|
||||
- MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the window label from timestamps when needed, and include the model name in the plan label.
|
||||
|
||||
@@ -32,7 +32,6 @@ Use `openclaw wiki` when you want a compiled knowledge vault with:
|
||||
|
||||
```bash
|
||||
openclaw wiki status
|
||||
openclaw wiki --agent research status
|
||||
openclaw wiki doctor
|
||||
openclaw wiki init
|
||||
openclaw wiki ingest ./notes/alpha.md
|
||||
@@ -267,16 +266,15 @@ These require the official `obsidian` CLI on `PATH` when
|
||||
|
||||
## Configuration tie-ins
|
||||
|
||||
`openclaw wiki` resolves config for the selected `--agent` (or the configured
|
||||
default agent) from:
|
||||
`openclaw wiki` behavior is shaped by:
|
||||
|
||||
- `memory.extensions.memory-wiki.vaultMode`
|
||||
- `memory.extensions.memory-wiki.search.backend`
|
||||
- `memory.extensions.memory-wiki.search.corpus`
|
||||
- `memory.extensions.memory-wiki.bridge.*`
|
||||
- `memory.extensions.memory-wiki.obsidian.*`
|
||||
- `memory.extensions.memory-wiki.render.*`
|
||||
- `memory.extensions.memory-wiki.context.includeCompiledDigestPrompt`
|
||||
- `plugins.entries.memory-wiki.config.vaultMode`
|
||||
- `plugins.entries.memory-wiki.config.search.backend`
|
||||
- `plugins.entries.memory-wiki.config.search.corpus`
|
||||
- `plugins.entries.memory-wiki.config.bridge.*`
|
||||
- `plugins.entries.memory-wiki.config.obsidian.*`
|
||||
- `plugins.entries.memory-wiki.config.render.*`
|
||||
- `plugins.entries.memory-wiki.config.context.includeCompiledDigestPrompt`
|
||||
|
||||
See [Memory Wiki plugin](/plugins/memory-wiki) for the full config model.
|
||||
|
||||
|
||||
@@ -819,14 +819,14 @@ confirm `config.toolsAllow` names the tools that plugin actually registers.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Embedding provider switched or stopped working">
|
||||
If `memory.search.provider` is unset, OpenClaw uses OpenAI embeddings. Set
|
||||
`memory.search.provider` explicitly for local, Ollama, Gemini, Voyage,
|
||||
If `memorySearch.provider` is unset, OpenClaw uses OpenAI embeddings. Set
|
||||
`memorySearch.provider` explicitly for local, Ollama, Gemini, Voyage,
|
||||
Mistral, DeepInfra, Bedrock, GitHub Copilot, or OpenAI-compatible
|
||||
embeddings. If the configured provider cannot run, `memory_search` may
|
||||
degrade to lexical-only retrieval; runtime failures after a provider is
|
||||
already selected do not fall back automatically.
|
||||
|
||||
Set an optional `memory.search.fallback` only when you want a deliberate
|
||||
Set an optional `memorySearch.fallback` only when you want a deliberate
|
||||
single fallback. See [Memory Search](/concepts/memory-search) for the full
|
||||
list of providers and examples.
|
||||
|
||||
|
||||
@@ -18,10 +18,8 @@ Dreaming is **opt-in** and disabled by default.
|
||||
|
||||
Dreaming keeps two kinds of output:
|
||||
|
||||
- **Agent-private state and artifacts** under
|
||||
`memory/.dreams/agents/<agent-id>/` (recall journals, phase output, reports,
|
||||
and the Dream Diary). Normal memory search does not index this directory.
|
||||
- **Shared durable memory** in `MEMORY.md`.
|
||||
- **Machine state** in `memory/.dreams/` (recall store, phase signals, ingestion checkpoints, locks).
|
||||
- **Human-readable output** in `DREAMS.md` (or existing `dreams.md`) and optional phase report files under `memory/dreaming/<phase>/YYYY-MM-DD.md`.
|
||||
|
||||
Long-term promotion still writes only to `MEMORY.md`.
|
||||
|
||||
@@ -54,7 +52,7 @@ These phases are internal implementation details, not separate user-configured "
|
||||
- Requires `minScore`, `minRecallCount`, and `minUniqueQueries` to pass.
|
||||
- Rehydrates snippets from live daily files before writing, so stale/deleted snippets are skipped.
|
||||
- Appends promoted entries to `MEMORY.md`.
|
||||
- Writes a `## Deep Sleep` summary into the agent's `DREAMS.md` and optionally writes an agent-private report.
|
||||
- Writes a `## Deep Sleep` summary into `DREAMS.md` and optionally writes `memory/dreaming/deep/YYYY-MM-DD.md`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="REM phase">
|
||||
@@ -74,12 +72,7 @@ Dreaming can ingest redacted session transcripts into the dreaming corpus. When
|
||||
|
||||
## Dream Diary
|
||||
|
||||
Dreaming also keeps a narrative **Dream Diary** in
|
||||
`memory/.dreams/agents/<agent-id>/DREAMS.md`. After each phase has enough
|
||||
material, `memory-core` runs a best-effort background subagent turn and appends
|
||||
a short diary entry. It uses the default runtime model unless `dreaming.model`
|
||||
is configured. If the configured model is unavailable, Dream Diary retries once
|
||||
with the session default model.
|
||||
Dreaming also keeps a narrative **Dream Diary** in `DREAMS.md`. After each phase has enough material, `memory-core` runs a best-effort background subagent turn and appends a short diary entry. It uses the default runtime model unless `dreaming.model` is configured. If the configured model is unavailable, Dream Diary retries once with the session default model.
|
||||
|
||||
<Note>
|
||||
This diary is for human reading in the Dreams UI, not a promotion source. Dreaming-generated diary/report artifacts are excluded from short-term promotion. Only grounded memory snippets are eligible to promote into `MEMORY.md`.
|
||||
@@ -112,8 +105,7 @@ Deep ranking uses six weighted base signals plus phase reinforcement:
|
||||
| Consolidation | 0.10 | Multi-day recurrence strength |
|
||||
| Conceptual richness | 0.06 | Concept-tag density from snippet/path |
|
||||
|
||||
Light and REM phase hits add a small recency-decayed boost from agent-scoped
|
||||
dreaming state.
|
||||
Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/phase-signals.json`.
|
||||
|
||||
Shadow-trial results can be layered on top of that base score as a review
|
||||
signal before any durable write. A helpful trial gives the candidate a small
|
||||
@@ -144,18 +136,16 @@ harmful verdicts map to `reject`; none of those recommendations writes to
|
||||
|
||||
## Scheduling
|
||||
|
||||
When enabled, `memory-core` auto-manages one cron job per enabled agent. Each
|
||||
sweep runs phases in order: light → REM → deep.
|
||||
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.
|
||||
|
||||
Each cron job runs only that agent's workspace and memory state. Agents that set
|
||||
`agents.*.memory.extensions.memory-core.dreaming.enabled: false` receive no job.
|
||||
The sweep includes the primary runtime workspace and any configured agent workspaces, deduped by path, so subagent workspace fan-out does not exclude the main agent's `DREAMS.md` and memory state.
|
||||
|
||||
Default cadence behavior:
|
||||
|
||||
| Setting | Default |
|
||||
| -------------------------------------------------- | ------------- |
|
||||
| `memory.extensions.memory-core.dreaming.frequency` | `0 3 * * *` |
|
||||
| `memory.extensions.memory-core.dreaming.model` | default model |
|
||||
| Setting | Default |
|
||||
| -------------------- | ------------- |
|
||||
| `dreaming.frequency` | `0 3 * * *` |
|
||||
| `dreaming.model` | default model |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -163,14 +153,12 @@ Default cadence behavior:
|
||||
<Tab title="Enable dreaming">
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"memory": {
|
||||
"extensions": {
|
||||
"memory-core": {
|
||||
"dreaming": {
|
||||
"enabled": true
|
||||
}
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-core": {
|
||||
"config": {
|
||||
"dreaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,16 +170,14 @@ Default cadence behavior:
|
||||
<Tab title="Custom sweep cadence">
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"memory": {
|
||||
"extensions": {
|
||||
"memory-core": {
|
||||
"dreaming": {
|
||||
"enabled": true,
|
||||
"timezone": "America/Los_Angeles",
|
||||
"frequency": "0 */6 * * *"
|
||||
}
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-core": {
|
||||
"config": {
|
||||
"dreaming": {
|
||||
"enabled": true,
|
||||
"timezone": "America/Los_Angeles",
|
||||
"frequency": "0 */6 * * *"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +233,7 @@ Default cadence behavior:
|
||||
|
||||
## Key defaults
|
||||
|
||||
All settings live under `memory.extensions.memory-core.dreaming`.
|
||||
All settings live under `plugins.entries.memory-core.config.dreaming`.
|
||||
|
||||
<ParamField path="enabled" type="boolean" default="false">
|
||||
Enable or disable the dreaming sweep.
|
||||
@@ -263,7 +249,7 @@ All settings live under `memory.extensions.memory-core.dreaming`.
|
||||
</ParamField>
|
||||
|
||||
<Warning>
|
||||
`memory.extensions.memory-core.dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`. Trust or allowlist failures stay visible instead of falling back silently; the retry only covers model-unavailable errors.
|
||||
`dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`. Trust or allowlist failures stay visible instead of falling back silently; the retry only covers model-unavailable errors.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -24,7 +24,7 @@ Treat them differently from normal config:
|
||||
| Surface | Key | Use it when | More |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
|
||||
| Memory search | `memory.search.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
|
||||
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
|
||||
| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) |
|
||||
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ To set a provider explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -46,12 +48,14 @@ openclaw plugins install @openclaw/llama-cpp-provider
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "local",
|
||||
fallback: "none",
|
||||
local: {
|
||||
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "local",
|
||||
fallback: "none",
|
||||
local: {
|
||||
modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -73,7 +77,7 @@ openclaw plugins install @openclaw/llama-cpp-provider
|
||||
| OpenAI-compatible | `openai-compatible` | Generic `/v1/embeddings` endpoint |
|
||||
| Voyage | `voyage` | |
|
||||
|
||||
Set `memory.search.provider` to switch away from OpenAI.
|
||||
Set `memorySearch.provider` to switch away from OpenAI.
|
||||
|
||||
## How indexing works
|
||||
|
||||
@@ -91,7 +95,7 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
|
||||
|
||||
<Info>
|
||||
You can also index Markdown files outside the workspace with
|
||||
`memory.search.extraPaths`. See the
|
||||
`memorySearch.extraPaths`. See the
|
||||
[configuration reference](/reference/memory-config#additional-memory-paths).
|
||||
</Info>
|
||||
|
||||
@@ -123,7 +127,7 @@ openclaw memory index --force --agent main
|
||||
```
|
||||
|
||||
Both standalone CLI commands and the Gateway use the same `local` provider id.
|
||||
Set `memory.search.provider: "local"` when you want local embeddings.
|
||||
Set `memorySearch.provider: "local"` when you want local embeddings.
|
||||
|
||||
**Stale results?** Run `openclaw memory index --force` to rebuild. The watcher
|
||||
may miss changes in rare edge cases.
|
||||
|
||||
@@ -18,9 +18,11 @@ backend, set a provider explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai", // or "gemini", "local", "ollama", "openai-compatible", etc.
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -37,8 +39,8 @@ may still require native build approval: `pnpm approve-builds` then
|
||||
|
||||
Some OpenAI-compatible embedding endpoints require asymmetric labels such as
|
||||
`input_type: "query"` for searches and `input_type: "document"` or `"passage"`
|
||||
for indexed chunks. Configure those with `memory.search.queryInputType` and
|
||||
`memory.search.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
|
||||
for indexed chunks. Configure those with `memorySearch.queryInputType` and
|
||||
`memorySearch.documentInputType`; see the [Memory configuration reference](/reference/memory-config#provider-specific-config).
|
||||
|
||||
## Supported providers
|
||||
|
||||
@@ -80,7 +82,7 @@ If only one path is available, the other runs alone. Intentional FTS-only mode
|
||||
lexical ranking when embeddings are unavailable.
|
||||
|
||||
Explicit non-local embedding providers are different. If you set
|
||||
`memory.search.provider` to a concrete remote-backed provider and that provider
|
||||
`memorySearch.provider` to a concrete remote-backed provider and that provider
|
||||
is unavailable at runtime, `memory_search` reports memory as unavailable instead
|
||||
of silently using FTS-only results. This keeps a broken configured semantic
|
||||
provider visible. Set `provider: "none"` for deliberate FTS-only recall, or fix
|
||||
@@ -115,12 +117,14 @@ different daily notes.
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
query: {
|
||||
hybrid: {
|
||||
mmr: { enabled: true },
|
||||
temporalDecay: { enabled: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
query: {
|
||||
hybrid: {
|
||||
mmr: { enabled: true },
|
||||
temporalDecay: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -139,7 +143,7 @@ setup.
|
||||
|
||||
You can optionally index session transcripts so `memory_search` can recall
|
||||
earlier conversations. This is opt-in via
|
||||
`memory.search.experimental.sessionMemory`. See the
|
||||
`memorySearch.experimental.sessionMemory`. See the
|
||||
[configuration reference](/reference/memory-config) for details.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -152,7 +156,7 @@ earlier conversations. This is opt-in via
|
||||
|
||||
**Local embeddings time out?** `ollama`, `lmstudio`, and `local` use a longer
|
||||
inline batch timeout by default. If the host is simply slow, set
|
||||
`memory.search.sync.embeddingBatchTimeoutSeconds` and rerun
|
||||
`agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds` and rerun
|
||||
`openclaw memory index --force`.
|
||||
|
||||
**CJK text not found?** Rebuild the FTS index with
|
||||
|
||||
@@ -145,7 +145,7 @@ an API key for any supported provider.
|
||||
|
||||
<Info>
|
||||
OpenClaw uses OpenAI embeddings by default. Set
|
||||
`memory.search.provider` explicitly to use Gemini, Voyage,
|
||||
`agents.defaults.memorySearch.provider` explicitly to use Gemini, Voyage,
|
||||
Mistral, local, Ollama, Bedrock, GitHub Copilot, or OpenAI-compatible
|
||||
embeddings.
|
||||
</Info>
|
||||
@@ -238,9 +238,9 @@ For phase behavior, scoring signals, and Dream Diary details, see
|
||||
|
||||
The dreaming system now has two closely related review lanes:
|
||||
|
||||
- **Live dreaming** works from an agent-scoped short-term dreaming store under
|
||||
`memory/.dreams/agents/<agent-id>/` and is what the normal deep phase uses
|
||||
when deciding what can graduate into shared `MEMORY.md`.
|
||||
- **Live dreaming** works from the short-term dreaming store under
|
||||
`memory/.dreams/` and is what the normal deep phase uses when deciding what
|
||||
can graduate into `MEMORY.md`.
|
||||
- **Grounded backfill** reads historical `memory/YYYY-MM-DD.md` notes as
|
||||
standalone day files and writes structured review output into `DREAMS.md`.
|
||||
|
||||
|
||||
@@ -130,38 +130,36 @@ This lets **multiple people** share one Gateway server while keeping their AI "b
|
||||
|
||||
## Cross-agent QMD memory search
|
||||
|
||||
If one agent should search another agent's QMD session transcripts, add extra collections under `agents.list[].memory.search.qmd.extraCollections`. Use `memory.search.qmd.extraCollections` only when every agent should inherit the same shared transcript collections.
|
||||
If one agent should search another agent's QMD session transcripts, add extra collections under `agents.list[].memorySearch.qmd.extraCollections`. Use `agents.defaults.memorySearch.qmd.extraCollections` only when every agent should inherit the same shared transcript collections.
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: { includeDefaultMemory: false },
|
||||
search: {
|
||||
qmd: {
|
||||
extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "~/workspaces/main",
|
||||
memorySearch: {
|
||||
qmd: {
|
||||
extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace: "~/workspaces/main",
|
||||
memory: {
|
||||
search: {
|
||||
qmd: {
|
||||
extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main"
|
||||
},
|
||||
memorySearch: {
|
||||
qmd: {
|
||||
extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main"
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "family", workspace: "~/workspaces/family" },
|
||||
],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: { includeDefaultMemory: false },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ the resolved scenarios through `qa suite`. `--surface` and
|
||||
The resulting `qa-evidence.json` includes a profile scorecard summary with
|
||||
selected-category counts and missing coverage IDs; the individual evidence
|
||||
entries remain the source of truth for the tests, coverage roles, and results.
|
||||
Taxonomy feature coverage IDs are exact proof targets, not aliases. Primary
|
||||
scenario coverage fulfills matching IDs; secondary coverage stays advisory.
|
||||
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
|
||||
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:
|
||||
|
||||
|
||||
@@ -281,6 +281,14 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
prompt: "HEARTBEAT",
|
||||
ackMaxChars: 300,
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
remote: {
|
||||
apiKey: "${GEMINI_API_KEY}",
|
||||
},
|
||||
extraPaths: ["../team-docs", "/srv/shared-notes"],
|
||||
},
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
scope: "session", // preferred over legacy perSession: true
|
||||
@@ -324,17 +332,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
],
|
||||
},
|
||||
|
||||
memory: {
|
||||
search: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
remote: {
|
||||
apiKey: "${GEMINI_API_KEY}",
|
||||
},
|
||||
extraPaths: ["../team-docs", "/srv/shared-notes"],
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
allow: ["exec", "process", "read", "write", "edit", "apply_patch"],
|
||||
deny: ["browser", "canvas"],
|
||||
|
||||
@@ -23,7 +23,7 @@ for the broader field map, defaults, and links to subsystem references.
|
||||
|
||||
Dedicated deep references:
|
||||
|
||||
- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `memory.extensions.memory-core.dreaming`
|
||||
- [Memory configuration reference](/reference/memory-config) for `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`
|
||||
- [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog
|
||||
- owning channel/plugin pages for channel-specific command surfaces
|
||||
|
||||
@@ -348,17 +348,17 @@ restart after changing native plugin config.
|
||||
- `plugins.entries.xai.config.xSearch`: xAI X Search (Grok web search) settings.
|
||||
- `enabled`: enable the X Search provider.
|
||||
- `model`: Grok model to use for search (e.g. `"grok-4-1-fast"`).
|
||||
- `memory.extensions.memory-core.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
|
||||
- `plugins.entries.memory-core.config.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
|
||||
- `enabled`: master dreaming switch (default `false`).
|
||||
- `frequency`: cron cadence for each full dreaming sweep (`"0 3 * * *"` by default).
|
||||
- `model`: optional Dream Diary subagent model override. Requires `plugins.entries.memory-core.subagent.allowModelOverride: true`; pair with `allowedModels` to restrict targets. Model-unavailable errors retry once with the session default model; trust or allowlist failures do not fall back silently.
|
||||
- phase policy and thresholds are implementation details (not user-facing config keys).
|
||||
- Full memory config lives in [Memory configuration reference](/reference/memory-config):
|
||||
- `memory.search.*`
|
||||
- `agents.defaults.memorySearch.*`
|
||||
- `memory.backend`
|
||||
- `memory.citations`
|
||||
- `memory.qmd.*`
|
||||
- `memory.extensions.memory-core.dreaming`
|
||||
- `plugins.entries.memory-core.config.dreaming`
|
||||
- Enabled Claude bundle plugins can also contribute embedded OpenClaw defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
|
||||
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
|
||||
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.
|
||||
|
||||
@@ -519,7 +519,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- **QMD backend**: probes whether the `qmd` binary is available and startable. If not, prints fix guidance including the npm package and a manual binary path option.
|
||||
- **Explicit local provider**: checks for a local model file or a recognized remote/downloadable model URL. If missing, suggests switching to a remote provider.
|
||||
- **Explicit remote provider** (`openai`, `voyage`, etc.): verifies an API key is present in the environment or auth store. Prints actionable fix hints if missing.
|
||||
- **Legacy auto provider**: treats `memory.search.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
|
||||
- **Legacy auto provider**: treats `memorySearch.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
|
||||
|
||||
When a cached gateway probe result is available (gateway was healthy at the time of the check), doctor cross-references its result with the CLI-visible config and notes any discrepancy. Doctor does not start a fresh embedding ping on the default path; use the deep memory status command when you want a live provider check.
|
||||
|
||||
|
||||
@@ -549,14 +549,14 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
|
||||
|
||||
If you don't set a provider explicitly, OpenClaw uses OpenAI embeddings. Legacy
|
||||
configs that still say `memory.search.provider = "auto"` resolve to OpenAI too.
|
||||
configs that still say `memorySearch.provider = "auto"` resolve to OpenAI too.
|
||||
If no OpenAI API key is available, semantic memory search stays unavailable
|
||||
until you configure a key or choose another provider explicitly.
|
||||
|
||||
If you'd rather stay local, set `memory.search.provider = "local"` (and optionally
|
||||
`memory.search.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memory.search.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memory.search.remote.apiKey`). We support **OpenAI, OpenAI-compatible, Gemini,
|
||||
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, OpenAI-compatible, Gemini,
|
||||
Voyage, Mistral, Bedrock, Ollama, LM Studio, GitHub Copilot, DeepInfra, or local**
|
||||
embedding models - see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "Install the official llama.cpp provider for local GGUF memory embeddings"
|
||||
read_when:
|
||||
- You want memory search embeddings from a local GGUF model
|
||||
- You are configuring memory.search.provider = "local"
|
||||
- You are configuring memorySearch.provider = "local"
|
||||
- You need the OpenClaw plugin that owns the node-llama-cpp runtime
|
||||
title: "llama.cpp Provider"
|
||||
sidebarTitle: "llama.cpp Provider"
|
||||
@@ -10,7 +10,7 @@ sidebarTitle: "llama.cpp Provider"
|
||||
|
||||
`llama-cpp` is the official external provider plugin for local GGUF embeddings.
|
||||
It owns the `node-llama-cpp` runtime dependency used by
|
||||
`memory.search.provider: "local"`.
|
||||
`memorySearch.provider: "local"`.
|
||||
|
||||
Install it before using local memory embeddings:
|
||||
|
||||
@@ -28,11 +28,13 @@ Set the memory search provider to `local`:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "local",
|
||||
local: {
|
||||
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "local",
|
||||
local: {
|
||||
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -397,78 +397,54 @@ engines or legacy prompt assembly that explicitly consume memory supplements.
|
||||
|
||||
## Configuration
|
||||
|
||||
Put config under `memory.extensions.memory-wiki`. Agent entries
|
||||
can override the same object at `agents.list[].memory.extensions.memory-wiki`.
|
||||
Enable the plugin once under `plugins.entries`; runtime state remains agent-scoped.
|
||||
Put config under `plugins.entries.memory-wiki.config`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-wiki": { enabled: true },
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-wiki": {
|
||||
vaultMode: "isolated",
|
||||
vault: {
|
||||
renderMode: "obsidian",
|
||||
},
|
||||
obsidian: {
|
||||
enabled: true,
|
||||
useOfficialCli: true,
|
||||
vaultName: "OpenClaw Wiki",
|
||||
openAfterWrites: false,
|
||||
},
|
||||
bridge: {
|
||||
enabled: false,
|
||||
readMemoryArtifacts: true,
|
||||
indexDreamReports: true,
|
||||
indexDailyNotes: true,
|
||||
indexMemoryRoot: true,
|
||||
followMemoryEvents: true,
|
||||
},
|
||||
ingest: {
|
||||
autoCompile: true,
|
||||
maxConcurrentJobs: 1,
|
||||
allowUrlIngest: true,
|
||||
},
|
||||
search: {
|
||||
backend: "shared",
|
||||
corpus: "wiki",
|
||||
},
|
||||
context: {
|
||||
includeCompiledDigestPrompt: false,
|
||||
},
|
||||
render: {
|
||||
preserveHumanBlocks: true,
|
||||
createBacklinks: true,
|
||||
createDashboards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Agent entries can override the same `memory.extensions.memory-wiki` object:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "research",
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-wiki": {
|
||||
vaultMode: "isolated",
|
||||
},
|
||||
enabled: true,
|
||||
config: {
|
||||
vaultMode: "isolated",
|
||||
vault: {
|
||||
path: "~/.openclaw/wiki/main",
|
||||
renderMode: "obsidian",
|
||||
},
|
||||
obsidian: {
|
||||
enabled: true,
|
||||
useOfficialCli: true,
|
||||
vaultName: "OpenClaw Wiki",
|
||||
openAfterWrites: false,
|
||||
},
|
||||
bridge: {
|
||||
enabled: false,
|
||||
readMemoryArtifacts: true,
|
||||
indexDreamReports: true,
|
||||
indexDailyNotes: true,
|
||||
indexMemoryRoot: true,
|
||||
followMemoryEvents: true,
|
||||
},
|
||||
ingest: {
|
||||
autoCompile: true,
|
||||
maxConcurrentJobs: 1,
|
||||
allowUrlIngest: true,
|
||||
},
|
||||
search: {
|
||||
backend: "shared",
|
||||
corpus: "wiki",
|
||||
},
|
||||
context: {
|
||||
includeCompiledDigestPrompt: false,
|
||||
},
|
||||
render: {
|
||||
preserveHumanBlocks: true,
|
||||
createBacklinks: true,
|
||||
createDashboards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -492,30 +468,30 @@ knowledge layer:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-wiki": { enabled: true },
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
extensions: {
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-wiki": {
|
||||
vaultMode: "bridge",
|
||||
bridge: {
|
||||
enabled: true,
|
||||
readMemoryArtifacts: true,
|
||||
indexDreamReports: true,
|
||||
indexDailyNotes: true,
|
||||
indexMemoryRoot: true,
|
||||
followMemoryEvents: true,
|
||||
},
|
||||
search: {
|
||||
backend: "shared",
|
||||
corpus: "all",
|
||||
},
|
||||
context: {
|
||||
includeCompiledDigestPrompt: false,
|
||||
enabled: true,
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: {
|
||||
enabled: true,
|
||||
readMemoryArtifacts: true,
|
||||
indexDreamReports: true,
|
||||
indexDailyNotes: true,
|
||||
indexMemoryRoot: true,
|
||||
followMemoryEvents: true,
|
||||
},
|
||||
search: {
|
||||
backend: "shared",
|
||||
corpus: "all",
|
||||
},
|
||||
context: {
|
||||
includeCompiledDigestPrompt: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -291,7 +291,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
|
||||
|
||||
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
|
||||
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm. Adds StepFun, StepFun Plan model provider support to OpenClaw.
|
||||
|
||||
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds StepFun, StepFun Plan model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/stepfun-provider`
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/stepfun-provider`
|
||||
- Install route: npm
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -371,14 +371,16 @@ openclaw models list
|
||||
<Accordion title="Embeddings for memory search">
|
||||
Bedrock can also serve as the embedding provider for
|
||||
[memory search](/concepts/memory-search). This is configured separately from the
|
||||
inference provider -- set `memory.search.provider` to `"bedrock"`:
|
||||
inference provider -- set `agents.defaults.memorySearch.provider` to `"bedrock"`:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0", // default
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0", // default
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -386,7 +388,7 @@ openclaw models list
|
||||
|
||||
Bedrock embeddings use the same AWS SDK credential chain as inference (instance
|
||||
roles, SSO, access keys, shared config, and web identity). No API key is
|
||||
needed. Set `memory.search.provider: "bedrock"` explicitly to use Bedrock
|
||||
needed. Set `memorySearch.provider: "bedrock"` explicitly to use Bedrock
|
||||
embeddings.
|
||||
|
||||
Supported embedding models include Amazon Titan Embed (v1, v2), Amazon Nova
|
||||
|
||||
@@ -65,7 +65,7 @@ static defaults below.
|
||||
| Speech-to-text | `openai/whisper-large-v3-turbo` | inbound audio transcription |
|
||||
| Text-to-speech | `hexgrad/Kokoro-82M` | `messages.tts.provider: "deepinfra"` |
|
||||
| Video generation | first `video-gen`-tagged entry from live catalog (static fallback `Pixverse/Pixverse-T2V`) | `video_generate`, `agents.defaults.videoGenerationModel` |
|
||||
| Memory embeddings | `BAAI/bge-m3` | `memory.search.provider: "deepinfra"` |
|
||||
| Memory embeddings | `BAAI/bge-m3` | `agents.defaults.memorySearch.provider: "deepinfra"` |
|
||||
|
||||
DeepInfra also exposes reranking, classification, object-detection, and other
|
||||
native model types. OpenClaw does not currently have first-class provider
|
||||
|
||||
@@ -216,17 +216,19 @@ have logged in, OpenClaw can use it for embeddings without a separate API key.
|
||||
|
||||
### Config
|
||||
|
||||
Set `memory.search.provider` explicitly to use GitHub Copilot embeddings. If a
|
||||
Set `memorySearch.provider` explicitly to use GitHub Copilot embeddings. If a
|
||||
GitHub token is available, OpenClaw discovers available embedding models from
|
||||
the Copilot API and picks the best one automatically.
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "github-copilot",
|
||||
// Optional: override the auto-discovered model
|
||||
model: "text-embedding-3-small",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "github-copilot",
|
||||
// Optional: override the auto-discovered model
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -208,9 +208,7 @@ matching `sampleRate` only if your upstream stream is already raw PCM.
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: { provider: "mistral" },
|
||||
},
|
||||
memorySearch: { provider: "mistral" },
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept
|
||||
Remote public hosts and Ollama Cloud (`https://ollama.com`) require a real credential through `OLLAMA_API_KEY`, an auth profile, or the provider's `apiKey`. For direct hosted use, prefer provider `ollama-cloud`.
|
||||
</Accordion>
|
||||
<Accordion title="Custom provider ids">
|
||||
Custom provider ids that set `api: "ollama"` follow the same rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential. Memory search can also set `memory.search.provider` to that custom provider id so embeddings use the matching Ollama endpoint.
|
||||
Custom provider ids that set `api: "ollama"` follow the same rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential. Memory search can also set `agents.defaults.memorySearch.provider` to that custom provider id so embeddings use the matching Ollama endpoint.
|
||||
</Accordion>
|
||||
<Accordion title="Auth profiles">
|
||||
`auth-profiles.json` stores the credential for a provider id. Put endpoint settings (`baseUrl`, `api`, model ids, headers, timeouts) in `models.providers.<id>`. Older flat auth-profile files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` are not a runtime format; run `openclaw doctor --fix` to rewrite them to the canonical `ollama-windows:default` API-key profile with a backup. `baseUrl` in that file is compatibility noise and should be moved to provider config.
|
||||
@@ -40,7 +40,7 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept
|
||||
When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared:
|
||||
|
||||
- A provider-level key is sent only to that provider's Ollama host.
|
||||
- `agents.*.memory.search.remote.apiKey` is sent only to its remote embedding host.
|
||||
- `agents.*.memorySearch.remote.apiKey` is sent only to its remote embedding host.
|
||||
- A pure `OLLAMA_API_KEY` env value is treated as the Ollama Cloud convention, not sent to local or self-hosted hosts by default.
|
||||
|
||||
</Accordion>
|
||||
@@ -972,12 +972,14 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "ollama",
|
||||
remote: {
|
||||
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
|
||||
nonBatchConcurrency: 1,
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "ollama",
|
||||
remote: {
|
||||
// Default for Ollama. Raise on larger hosts if reindexing is too slow.
|
||||
nonBatchConcurrency: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -988,14 +990,16 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
remote: {
|
||||
baseUrl: "http://gpu-box.local:11434",
|
||||
apiKey: "ollama-local",
|
||||
nonBatchConcurrency: 2,
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
remote: {
|
||||
baseUrl: "http://gpu-box.local:11434",
|
||||
apiKey: "ollama-local",
|
||||
nonBatchConcurrency: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -123,17 +123,19 @@ OpenClaw can use OpenAI, or an OpenAI-compatible embedding endpoint, for
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For OpenAI-compatible endpoints that require asymmetric embedding labels, set
|
||||
`queryInputType` and `documentInputType` under `memory.search`. OpenClaw forwards
|
||||
`queryInputType` and `documentInputType` under `memorySearch`. OpenClaw forwards
|
||||
those as provider-specific `input_type` request fields: query embeddings use
|
||||
`queryInputType`; indexed memory chunks and batch indexing use
|
||||
`documentInputType`. See the [Memory configuration reference](/reference/memory-config#provider-specific-config) for the full example.
|
||||
|
||||
@@ -89,7 +89,7 @@ This migration has one canonical runtime shape:
|
||||
indexing helpers live on `memory-core-host-engine-session-transcripts`; any
|
||||
QMD re-export is compatibility only and must not be used by runtime code.
|
||||
- Built-in memory indexes live in the owning agent database. Runtime config and
|
||||
resolved runtime contracts must not expose `memory.search.store.path`; doctor
|
||||
resolved runtime contracts must not expose `memorySearch.store.path`; doctor
|
||||
deletes that legacy config key and current code passes the agent
|
||||
`databasePath` internally.
|
||||
|
||||
@@ -1557,7 +1557,7 @@ Move these into the global database:
|
||||
`plugin-state/state.sqlite` sidecar importer is deleted.
|
||||
- Builtin memory search no longer defaults to `memory/<agentId>.sqlite`; its
|
||||
index tables live in the owning agent database, and the explicit
|
||||
`memory.search.store.path` sidecar opt-in has been retired to doctor config
|
||||
`memorySearch.store.path` sidecar opt-in has been retired to doctor config
|
||||
migration.
|
||||
- Builtin memory reindex resets only memory-owned tables in the agent database.
|
||||
It must not replace the whole SQLite file, because the same database owns
|
||||
@@ -1890,7 +1890,7 @@ verified extracted payload.
|
||||
- Move Task Flow tables into the global database. Done for runtime writes;
|
||||
the unshipped legacy sidecar importer is deleted.
|
||||
- Move builtin memory-search tables into each agent database. Done; explicit
|
||||
custom `memory.search.store.path` is now removed by doctor config migration.
|
||||
custom `memorySearch.store.path` is now removed by doctor config migration.
|
||||
Full reindex runs in place against memory tables only; the old whole-file
|
||||
swap path and sidecar index swap helper are deleted.
|
||||
- Delete duplicate database openers, WAL setup, permission helpers, and
|
||||
|
||||
@@ -66,7 +66,7 @@ OpenClaw can pick up credentials from:
|
||||
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
|
||||
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
|
||||
- **Config** (`models.providers.*.apiKey`, `plugins.entries.*.config.webSearch.apiKey`,
|
||||
`plugins.entries.firecrawl.config.webFetch.apiKey`, `memory.search.*`,
|
||||
`plugins.entries.firecrawl.config.webFetch.apiKey`, `memorySearch.*`,
|
||||
`talk.providers.*.apiKey`).
|
||||
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
|
||||
|
||||
@@ -113,16 +113,16 @@ and [Models](/concepts/models).
|
||||
|
||||
Semantic memory search uses **embedding APIs** when configured for remote providers:
|
||||
|
||||
- `memory.search.provider = "openai"` → OpenAI embeddings
|
||||
- `memory.search.provider = "gemini"` → Gemini embeddings
|
||||
- `memory.search.provider = "voyage"` → Voyage embeddings
|
||||
- `memory.search.provider = "mistral"` → Mistral embeddings
|
||||
- `memory.search.provider = "deepinfra"` → DeepInfra embeddings
|
||||
- `memory.search.provider = "lmstudio"` → LM Studio embeddings (local/self-hosted)
|
||||
- `memory.search.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
|
||||
- `memorySearch.provider = "openai"` → OpenAI embeddings
|
||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||
- `memorySearch.provider = "voyage"` → Voyage embeddings
|
||||
- `memorySearch.provider = "mistral"` → Mistral embeddings
|
||||
- `memorySearch.provider = "deepinfra"` → DeepInfra embeddings
|
||||
- `memorySearch.provider = "lmstudio"` → LM Studio embeddings (local/self-hosted)
|
||||
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
|
||||
- Optional fallback to a remote provider if local embeddings fail
|
||||
|
||||
You can keep it local with `memory.search.provider = "local"` (no API usage).
|
||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
See [Memory](/concepts/memory).
|
||||
|
||||
|
||||
@@ -29,45 +29,10 @@ This page lists every configuration knob for OpenClaw memory search. For concept
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Memory settings live under `memory` in `openclaw.json`. It is the global
|
||||
baseline. Agent entries can override the same shape at `agents.list[].memory`.
|
||||
|
||||
When OpenClaw resolves memory for an agent, it deep-merges the global baseline
|
||||
with that agent's override. Scalar values and ordinary arrays in the agent
|
||||
override replace the global value. `memory.qmd.paths`,
|
||||
`memory.search.extraPaths`, and `memory.search.qmd.extraCollections` append and
|
||||
deduplicate so an agent can add sources without repeating the global list.
|
||||
|
||||
Global configuration is not shared memory state. Each agent still owns its own
|
||||
SQLite database, workspace roots, QMD home, dreaming artifacts, and wiki state
|
||||
unless you explicitly configure a shared path.
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
search: {
|
||||
provider: "openai",
|
||||
extraPaths: ["~/team-notes"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "research",
|
||||
memory: {
|
||||
search: {
|
||||
extraPaths: ["~/research-notes"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` unless noted otherwise.
|
||||
|
||||
<Note>
|
||||
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memory.search`.
|
||||
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memorySearch`.
|
||||
|
||||
Active memory uses a two-gate model:
|
||||
|
||||
@@ -106,8 +71,7 @@ When `provider` is unset, legacy `provider: "auto"` is present, or
|
||||
`provider: "none"` intentionally selects FTS-only mode, memory recall can still
|
||||
use lexical FTS ranking when embeddings are unavailable.
|
||||
|
||||
Explicit non-local providers fail closed. If you set
|
||||
`memory.search.provider` to
|
||||
Explicit non-local providers fail closed. If you set `memorySearch.provider` to
|
||||
a concrete remote-backed provider such as OpenAI, Gemini, Voyage, Mistral,
|
||||
Bedrock, GitHub Copilot, DeepInfra, Ollama, LM Studio, or an OpenAI-compatible
|
||||
custom provider, and that provider is unavailable at runtime, `memory_search`
|
||||
@@ -117,13 +81,7 @@ provider/auth configuration, switch to a reachable provider, or set
|
||||
|
||||
### Custom provider ids
|
||||
|
||||
`memory.search.provider` can point at a custom
|
||||
`models.providers.<id>` entry for memory-specific provider adapters such as
|
||||
`ollama`, or for OpenAI-compatible model APIs such as `openai-responses` /
|
||||
`openai-completions`. OpenClaw resolves that provider's `api` owner for the
|
||||
embedding adapter while preserving the custom provider id for endpoint, auth,
|
||||
and model-prefix handling. This lets multi-GPU or multi-host setups dedicate
|
||||
memory embeddings to a specific local endpoint:
|
||||
`memorySearch.provider` can point at a custom `models.providers.<id>` entry for memory-specific provider adapters such as `ollama`, or for OpenAI-compatible model APIs such as `openai-responses` / `openai-completions`. OpenClaw resolves that provider's `api` owner for the embedding adapter while preserving the custom provider id for endpoint, auth, and model-prefix handling. This lets multi-GPU or multi-host setups dedicate memory embeddings to a specific local endpoint:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -137,10 +95,12 @@ memory embeddings to a specific local endpoint:
|
||||
},
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
search: {
|
||||
provider: "ollama-5080",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "ollama-5080",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -184,13 +144,15 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai-compatible",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_KEY",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai-compatible",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,16 +187,18 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai-compatible",
|
||||
remote: {
|
||||
baseUrl: "https://embeddings.example/v1",
|
||||
apiKey: "${EMBEDDINGS_API_KEY}",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai-compatible",
|
||||
remote: {
|
||||
baseUrl: "https://embeddings.example/v1",
|
||||
apiKey: "${EMBEDDINGS_API_KEY}",
|
||||
},
|
||||
model: "asymmetric-embedder",
|
||||
queryInputType: "query",
|
||||
documentInputType: "passage",
|
||||
},
|
||||
model: "asymmetric-embedder",
|
||||
queryInputType: "query",
|
||||
documentInputType: "passage",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -250,10 +214,12 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0",
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -342,7 +308,7 @@ Unset uses the provider default: 600 seconds for local/self-hosted providers suc
|
||||
|
||||
## Hybrid search config
|
||||
|
||||
All under `memory.search.query.hybrid`:
|
||||
All under `memorySearch.query.hybrid`:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --------------------- | --------- | ------- | ---------------------------------- |
|
||||
@@ -373,14 +339,16 @@ All under `memory.search.query.hybrid`:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
query: {
|
||||
hybrid: {
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
mmr: { enabled: true, lambda: 0.7 },
|
||||
temporalDecay: { enabled: true, halfLifeDays: 30 },
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
query: {
|
||||
hybrid: {
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
mmr: { enabled: true, lambda: 0.7 },
|
||||
temporalDecay: { enabled: true, halfLifeDays: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -398,9 +366,11 @@ All under `memory.search.query.hybrid`:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
extraPaths: ["../team-docs", "/srv/shared-notes"],
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
extraPaths: ["../team-docs", "/srv/shared-notes"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -408,14 +378,7 @@ All under `memory.search.query.hybrid`:
|
||||
|
||||
Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine ignores symlinks, while QMD follows the underlying QMD scanner behavior.
|
||||
|
||||
For agent-scoped cross-agent transcript search, use
|
||||
`agents.list[].memory.search.qmd.extraCollections` instead of
|
||||
`memory.qmd.paths`. Those extra collections follow the same
|
||||
`{ path, name, pattern? }` shape, but they are merged per agent and can preserve
|
||||
explicit shared names when the path points outside the current workspace. If the
|
||||
same resolved path appears in both `memory.qmd.paths` and
|
||||
`memory.search.qmd.extraCollections`, QMD keeps the first entry
|
||||
and skips the duplicate.
|
||||
For agent-scoped cross-agent transcript search, use `agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate.
|
||||
|
||||
---
|
||||
|
||||
@@ -614,10 +577,9 @@ When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for e
|
||||
|
||||
## Dreaming
|
||||
|
||||
Dreaming is configured under `memory.extensions.memory-core.dreaming`, not under `memory.search`.
|
||||
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`.
|
||||
|
||||
Each enabled agent gets its own scheduled dreaming sweep. The sweep uses
|
||||
internal light/deep/REM phases as an implementation detail.
|
||||
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail.
|
||||
|
||||
For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
|
||||
|
||||
@@ -630,50 +592,10 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
|
||||
| `model` | `string` | default model | Optional Dream Diary subagent model override |
|
||||
| `phases.deep.maxPromotedSnippetTokens` | `number` | `160` | Maximum estimated tokens kept from each short-term recall snippet promoted into `MEMORY.md`; provenance metadata remains visible |
|
||||
|
||||
### Per-agent dreaming control
|
||||
|
||||
Dreaming is resolved per agent. An agent can opt out with
|
||||
`agents.list[].memory.extensions.memory-core.dreaming.enabled = false`:
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", memory: { extensions: { "memory-core": { dreaming: { enabled: false } } } } },
|
||||
{ id: "oracle", memory: { extensions: { "memory-core": { dreaming: { enabled: false } } } } },
|
||||
{ id: "librarian" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
In this example, `main` and `oracle` will not get cron jobs, while `librarian`
|
||||
inherits the global enabled setting.
|
||||
|
||||
### Example
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 3 * * *",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
@@ -681,6 +603,13 @@ inherits the global enabled setting.
|
||||
allowModelOverride: true,
|
||||
allowedModels: ["anthropic/claude-sonnet-4-6"],
|
||||
},
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 3 * * *",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -688,11 +617,8 @@ inherits the global enabled setting.
|
||||
```
|
||||
|
||||
<Note>
|
||||
- Dreaming writes agent-private state and artifacts to
|
||||
`memory/.dreams/agents/<agent-id>/`; normal memory search does not index
|
||||
this directory.
|
||||
- Dreaming writes each agent's human-readable narrative output to
|
||||
`memory/.dreams/agents/<agent-id>/DREAMS.md`.
|
||||
- Dreaming writes machine state to `memory/.dreams/`.
|
||||
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
|
||||
- `dreaming.model` uses the existing plugin subagent trust gate; set `plugins.entries.memory-core.subagent.allowModelOverride: true` before enabling it.
|
||||
- Dream Diary retries once with the session default model when the configured model is unavailable. Trust or allowlist failures are logged and are not silently retried.
|
||||
- The light/deep/REM phase policy and thresholds are internal behavior, not user-facing config.
|
||||
|
||||
@@ -34,9 +34,9 @@ Scope intent:
|
||||
- `models.providers.*.request.tls.key`
|
||||
- `models.providers.*.request.tls.passphrase`
|
||||
- `skills.entries.*.apiKey`
|
||||
- `memory.search.remote.apiKey`
|
||||
- `agents.defaults.memorySearch.remote.apiKey`
|
||||
- `agents.list[].tts.providers.*.apiKey`
|
||||
- `agents.list[].memory.search.remote.apiKey`
|
||||
- `agents.list[].memorySearch.remote.apiKey`
|
||||
- `talk.providers.*.apiKey`
|
||||
- `talk.realtime.providers.*.apiKey`
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"id": "memory.search.remote.apiKey",
|
||||
"id": "agents.defaults.memorySearch.remote.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "memory.search.remote.apiKey",
|
||||
"path": "agents.defaults.memorySearch.remote.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].memory.search.remote.apiKey",
|
||||
"id": "agents.list[].memorySearch.remote.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.list[].memory.search.remote.apiKey",
|
||||
"path": "agents.list[].memorySearch.remote.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
|
||||
@@ -1398,12 +1398,6 @@ describe("active-memory plugin", () => {
|
||||
|
||||
it("lets active memory inherit the main QMD search mode when configured", async () => {
|
||||
api.config = {
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
@@ -1411,6 +1405,12 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
},
|
||||
},
|
||||
};
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
@@ -1434,8 +1434,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
const config = embeddedRunConfig();
|
||||
const agents = requireRecord(config.agents, "expected agents config");
|
||||
expect(requireRecord(agents.defaults, "expected agent defaults").memory).toEqual({
|
||||
expect(config.memory).toEqual({
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
|
||||
@@ -28,7 +28,7 @@ export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapt
|
||||
"AWS credentials are not available. " +
|
||||
"Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " +
|
||||
"configure an EC2/ECS/EKS role, " +
|
||||
"or set memory.search.provider to another provider.",
|
||||
"or set agents.defaults.memorySearch.provider to another provider.",
|
||||
);
|
||||
}
|
||||
const { provider, client } = await createBedrockEmbeddingProvider({
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
import { resolveAgentMemoryConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
|
||||
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
|
||||
@@ -257,13 +256,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
|
||||
files: memoryReferenceFiles,
|
||||
toolNames: params.memoryToolNames,
|
||||
memoryToolRouted: memoryToolsAvailable,
|
||||
citationsMode: params.params.config
|
||||
? resolveAgentMemoryConfig(
|
||||
params.params.config,
|
||||
params.params.agentId ?? params.sessionAgentId,
|
||||
)?.citations
|
||||
: undefined,
|
||||
agentId: params.params.agentId ?? params.sessionAgentId,
|
||||
citationsMode: params.params.config?.memory?.citations,
|
||||
})
|
||||
: undefined,
|
||||
heartbeatCollaborationInstructions:
|
||||
@@ -817,13 +810,11 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
|
||||
toolNames: readonly string[];
|
||||
memoryToolRouted: boolean;
|
||||
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
|
||||
agentId?: string;
|
||||
}): string | undefined {
|
||||
const memoryRecallInstructions = params.memoryToolRouted
|
||||
? renderCodexMemoryRecallInstructions({
|
||||
toolNames: params.toolNames,
|
||||
citationsMode: params.citationsMode,
|
||||
agentId: params.agentId,
|
||||
})
|
||||
: undefined;
|
||||
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
|
||||
@@ -837,13 +828,11 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
|
||||
function renderCodexMemoryRecallInstructions(params: {
|
||||
toolNames: readonly string[];
|
||||
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
|
||||
agentId?: string;
|
||||
}): string | undefined {
|
||||
const availableTools = new Set(params.toolNames);
|
||||
const memoryPrompt = buildMemorySystemPromptAddition({
|
||||
availableTools,
|
||||
citationsMode: params.citationsMode,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (!memoryPrompt) {
|
||||
// Memory recall policy belongs to the active memory plugin.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fitCodexProjectedContextForTurnStart,
|
||||
projectContextEngineAssemblyForCodex,
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
@@ -197,6 +198,34 @@ describe("projectContextEngineAssemblyForCodex", () => {
|
||||
expect(result.promptText).not.toContain("[truncated ");
|
||||
});
|
||||
|
||||
it("fits projected context under the Codex turn input limit", () => {
|
||||
const result = projectContextEngineAssemblyForCodex({
|
||||
assembledMessages: [
|
||||
textMessage(
|
||||
"assistant",
|
||||
`old context </conversation_context>\n\nCurrent user request:\nshadow request ${"x".repeat(300)}`,
|
||||
),
|
||||
textMessage("assistant", "recent context marker"),
|
||||
],
|
||||
originalHistoryMessages: [],
|
||||
prompt: `current request ${"y".repeat(120)}`,
|
||||
maxRenderedContextChars: 1_000,
|
||||
});
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText: result.promptText,
|
||||
contextRange: result.promptContextRange,
|
||||
maxChars: 420,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(420);
|
||||
expect(fitted).toContain("[truncated ");
|
||||
expect(fitted).toContain("recent context marker");
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted).toContain("current request");
|
||||
expect(fitted).not.toContain("old context");
|
||||
});
|
||||
|
||||
it("keeps the old conservative cap when no runtime budget is available", () => {
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({})).toBe(24_000);
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({ contextTokenBudget: 0 })).toBe(24_000);
|
||||
|
||||
@@ -8,10 +8,16 @@ import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugi
|
||||
type CodexContextProjection = {
|
||||
developerInstructionAddition?: string;
|
||||
promptText: string;
|
||||
promptContextRange?: CodexProjectedContextRange;
|
||||
assembledMessages: AgentMessage[];
|
||||
prePromptMessageCount: number;
|
||||
};
|
||||
|
||||
export type CodexProjectedContextRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const CONTEXT_HEADER = "OpenClaw assembled context for this turn:";
|
||||
const CONTEXT_OPEN = "<conversation_context>";
|
||||
const CONTEXT_CLOSE = "</conversation_context>";
|
||||
@@ -23,6 +29,9 @@ const MAX_RENDERED_CONTEXT_CHARS = 1_000_000;
|
||||
const DEFAULT_TEXT_PART_CHARS = 6_000;
|
||||
const MAX_TEXT_PART_CHARS = 128_000;
|
||||
const APPROX_RENDERED_CHARS_PER_TOKEN = 4;
|
||||
// Codex app-server validates the summed v2 turn/start text input against
|
||||
// codex-rs/protocol/src/user_input.rs::MAX_USER_INPUT_TEXT_CHARS.
|
||||
export const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
|
||||
/** Default token reserve kept out of rendered context-engine prompt text. */
|
||||
export const DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS = 20_000;
|
||||
const MIN_PROMPT_BUDGET_RATIO = 0.5;
|
||||
@@ -44,25 +53,25 @@ export function projectContextEngineAssemblyForCodex(params: {
|
||||
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
|
||||
toolPayloadMode: params.toolPayloadMode ?? "elide",
|
||||
});
|
||||
const promptText = renderedContext
|
||||
? [
|
||||
CONTEXT_HEADER,
|
||||
CONTEXT_SAFETY_NOTE,
|
||||
"",
|
||||
CONTEXT_OPEN,
|
||||
truncateOlderContext(renderedContext, maxRenderedContextChars),
|
||||
CONTEXT_CLOSE,
|
||||
"",
|
||||
REQUEST_HEADER,
|
||||
prompt,
|
||||
].join("\n")
|
||||
: prompt;
|
||||
const boundedContext = renderedContext
|
||||
? truncateOlderContext(renderedContext, maxRenderedContextChars)
|
||||
: undefined;
|
||||
const promptPrefix = boundedContext
|
||||
? [CONTEXT_HEADER, CONTEXT_SAFETY_NOTE, "", CONTEXT_OPEN].join("\n") + "\n"
|
||||
: undefined;
|
||||
const promptSuffix = boundedContext ? `\n${CONTEXT_CLOSE}\n\n${REQUEST_HEADER}\n${prompt}` : "";
|
||||
const promptText = boundedContext ? `${promptPrefix}${boundedContext}${promptSuffix}` : prompt;
|
||||
const promptContextRange =
|
||||
promptPrefix && boundedContext
|
||||
? { start: promptPrefix.length, end: promptPrefix.length + boundedContext.length }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(params.systemPromptAddition?.trim()
|
||||
? { developerInstructionAddition: params.systemPromptAddition.trim() }
|
||||
: {}),
|
||||
promptText,
|
||||
...(promptContextRange ? { promptContextRange } : {}),
|
||||
assembledMessages: params.assembledMessages,
|
||||
prePromptMessageCount: params.originalHistoryMessages.length,
|
||||
};
|
||||
@@ -108,6 +117,50 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Fits projected context prompts under Codex app-server turn/start text limits. */
|
||||
export function fitCodexProjectedContextForTurnStart(params: {
|
||||
promptText: string;
|
||||
contextRange?: CodexProjectedContextRange;
|
||||
maxChars?: number;
|
||||
}): string {
|
||||
const maxChars =
|
||||
typeof params.maxChars === "number" && Number.isFinite(params.maxChars)
|
||||
? Math.max(0, Math.floor(params.maxChars))
|
||||
: CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
|
||||
if (params.promptText.length <= maxChars) {
|
||||
return params.promptText;
|
||||
}
|
||||
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
|
||||
if (!range) {
|
||||
return params.promptText;
|
||||
}
|
||||
|
||||
const beforeContext = params.promptText.slice(0, range.start);
|
||||
const context = params.promptText.slice(range.start, range.end);
|
||||
const afterContext = params.promptText.slice(range.end);
|
||||
const contextBudget = maxChars - beforeContext.length - afterContext.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
|
||||
function normalizeProjectedContextRange(
|
||||
range: CodexProjectedContextRange | undefined,
|
||||
textLength: number,
|
||||
): CodexProjectedContextRange | undefined {
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
const start = Math.floor(range.start);
|
||||
const end = Math.floor(range.end);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
||||
return undefined;
|
||||
}
|
||||
if (end > textLength) {
|
||||
return undefined;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function resolveProjectionPromptBudgetTokens(params: {
|
||||
contextTokenBudget: number;
|
||||
reserveTokens?: number;
|
||||
|
||||
@@ -314,6 +314,102 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers child agent-message completion when a native subagent becomes idle", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome: "/tmp/codex-home",
|
||||
});
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-final",
|
||||
phase: "final_answer",
|
||||
text: "child final result",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
await client.notify({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
terminalSummary: "child final result",
|
||||
}),
|
||||
);
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
status: "succeeded",
|
||||
statusLabel: "agent_message",
|
||||
result: "child final result",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("does not deliver commentary-only child messages as native subagent completion", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome: "/tmp/codex-home",
|
||||
});
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-commentary",
|
||||
phase: "commentary",
|
||||
text: "checking now",
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("keeps late idle lifecycle updates from overwriting native completion results", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
|
||||
@@ -55,6 +55,10 @@ type ChildState = {
|
||||
transcriptPollAttempt: number;
|
||||
transcriptPollTimer?: ReturnType<typeof setTimeout>;
|
||||
transcriptTerminal: boolean;
|
||||
idle: boolean;
|
||||
lastAgentMessage?: string;
|
||||
lastAgentMessageAt?: number;
|
||||
agentMessageCompletionDelivered: boolean;
|
||||
pendingCompletion?: CodexNativeSubagentCompletion;
|
||||
pendingCompletionEventAt?: number;
|
||||
completionDeliveryAttempt: number;
|
||||
@@ -211,7 +215,10 @@ export class CodexNativeSubagentMonitor {
|
||||
});
|
||||
}
|
||||
}
|
||||
const childThreadId = this.recordChildAgentMessage(notification);
|
||||
const idleChildThreadId = this.recordChildIdle(notification);
|
||||
await this.handleCompletionNotification(notification);
|
||||
await this.processChildAgentMessageCompletion(childThreadId ?? idleChildThreadId);
|
||||
}
|
||||
|
||||
private ensureParentTaskRuntime(state: ParentState): void {
|
||||
@@ -552,6 +559,8 @@ export class CodexNativeSubagentMonitor {
|
||||
parentThreadId: normalizedParentThreadId,
|
||||
transcriptPollAttempt: 0,
|
||||
transcriptTerminal: false,
|
||||
idle: false,
|
||||
agentMessageCompletionDelivered: false,
|
||||
completionDeliveryAttempt: 0,
|
||||
};
|
||||
this.childStates.set(normalizedChildThreadId, childState);
|
||||
@@ -561,6 +570,83 @@ export class CodexNativeSubagentMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
private recordChildAgentMessage(notification: CodexServerNotification): string | undefined {
|
||||
if (notification.method !== "item/completed") {
|
||||
return undefined;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const item = isJsonObject(params?.item) ? params.item : undefined;
|
||||
if (!params || !item || readString(item, "type") !== "agentMessage") {
|
||||
return undefined;
|
||||
}
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.transcriptTerminal) {
|
||||
return undefined;
|
||||
}
|
||||
// Codex app-server can report the child final answer as the child thread's
|
||||
// own agentMessage without also emitting a parent subagent notification.
|
||||
// Pair it with idle below so commentary does not become a false terminal.
|
||||
const phase = readString(item, "phase");
|
||||
if (phase === "commentary") {
|
||||
return undefined;
|
||||
}
|
||||
const text = readString(item, "text")?.trim();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
childState.lastAgentMessage = text;
|
||||
childState.lastAgentMessageAt = Date.now();
|
||||
return childState.childThreadId;
|
||||
}
|
||||
|
||||
private recordChildIdle(notification: CodexServerNotification): string | undefined {
|
||||
if (notification.method !== "thread/status/changed") {
|
||||
return undefined;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || !isJsonObject(params.status) || readString(params.status, "type") !== "idle") {
|
||||
return undefined;
|
||||
}
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.transcriptTerminal) {
|
||||
return undefined;
|
||||
}
|
||||
childState.idle = true;
|
||||
return childState.childThreadId;
|
||||
}
|
||||
|
||||
private async processChildAgentMessageCompletion(
|
||||
childThreadId: string | undefined,
|
||||
): Promise<void> {
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (
|
||||
!childState ||
|
||||
!childState.idle ||
|
||||
childState.transcriptTerminal ||
|
||||
childState.agentMessageCompletionDelivered ||
|
||||
!childState.lastAgentMessage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const state = this.parentStates.get(childState.parentThreadId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
childState.agentMessageCompletionDelivered = true;
|
||||
await this.processCompletion(
|
||||
state,
|
||||
{
|
||||
childThreadId: childState.childThreadId,
|
||||
status: "succeeded",
|
||||
statusLabel: "agent_message",
|
||||
result: childState.lastAgentMessage,
|
||||
},
|
||||
childState.lastAgentMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
private ensureChildState(parentThreadId: string, childThreadId: string): ChildState {
|
||||
this.registerChildThread(parentThreadId, childThreadId);
|
||||
return this.childStates.get(childThreadId.trim())!;
|
||||
|
||||
@@ -71,7 +71,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -366,9 +368,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
params.requestedModelId = "gpt-5.4-codex-primary";
|
||||
params.fallbackReason = "provider_unavailable";
|
||||
params.degradedReason = "context_overflow";
|
||||
params.config = {
|
||||
memory: { citations: "on" },
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
params.config = { memory: { citations: "on" } } as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
|
||||
import {
|
||||
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
createCodexDynamicToolBridge,
|
||||
@@ -2165,8 +2166,22 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
Date.now(),
|
||||
),
|
||||
);
|
||||
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
|
||||
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage(
|
||||
`continuity filler ${index}: ${"x".repeat(4_000)}`,
|
||||
Date.now() + 2 + index,
|
||||
),
|
||||
);
|
||||
}
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "make the default webpage openclaw";
|
||||
@@ -2185,12 +2200,57 @@ describe("runCodexAppServerAttempt", () => {
|
||||
"";
|
||||
|
||||
expect(inputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(inputText).toContain("older next-step anchor: keep the handoff checklist");
|
||||
expect(inputText).toContain("we are fixing the Opik default project");
|
||||
expect(inputText).toContain("Opik default project context");
|
||||
expect(inputText).toContain("Current user request:");
|
||||
expect(inputText).toContain("make the default webpage openclaw");
|
||||
});
|
||||
|
||||
it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
Date.now(),
|
||||
),
|
||||
);
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage(
|
||||
`continuity block ${index}: ${"x".repeat(128_000)}`,
|
||||
Date.now() + 1 + index,
|
||||
),
|
||||
);
|
||||
}
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage("recent continuity anchor: resume the database migration", Date.now() + 20),
|
||||
);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextTokenBudget = 300_000;
|
||||
params.prompt = `current prompt survives ${"p".repeat(80_000)}`;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const inputText =
|
||||
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
|
||||
"";
|
||||
|
||||
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
|
||||
expect(inputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(inputText).toContain("recent continuity anchor: resume the database migration");
|
||||
expect(inputText).toContain("Current user request:");
|
||||
expect(inputText).toContain("current prompt survives");
|
||||
expect(inputText).not.toContain("older next-step anchor: keep the handoff checklist");
|
||||
});
|
||||
|
||||
it("keeps thread-start developer instructions stable when adding fresh-thread continuity", async () => {
|
||||
let hookCalls = 0;
|
||||
const beforePromptBuild = vi.fn(async () => {
|
||||
@@ -4787,11 +4847,28 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("post-binding user context", bindingUpdatedAt + 1_000),
|
||||
userMessage(
|
||||
"pre-binding native-owned context: keep the original plan",
|
||||
bindingUpdatedAt - 2_000,
|
||||
),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"post-binding user context: resume the release checklist",
|
||||
bindingUpdatedAt + 1_000,
|
||||
),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage("post-binding assistant context", bindingUpdatedAt + 2_000),
|
||||
);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage(
|
||||
`post-binding continuity filler ${index}: ${"x".repeat(4_000)}`,
|
||||
bindingUpdatedAt + 3_000 + index,
|
||||
),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
@@ -4835,7 +4912,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const inputText =
|
||||
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
|
||||
"";
|
||||
expect(inputText).toContain("post-binding user context");
|
||||
expect(inputText).toContain("pre-binding native-owned context: keep the original plan");
|
||||
expect(inputText).toContain("post-binding user context: resume the release checklist");
|
||||
expect(inputText).toContain("post-binding assistant context");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-1");
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
resolveDiagnosticModelContentCapturePolicy,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolveAgentMemoryConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
resolveCodexAppServerForModelProvider,
|
||||
@@ -140,6 +139,8 @@ import {
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
import {
|
||||
type CodexProjectedContextRange,
|
||||
fitCodexProjectedContextForTurnStart,
|
||||
projectContextEngineAssemblyForCodex,
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
@@ -897,8 +898,15 @@ export async function runCodexAppServerAttempt(
|
||||
skillsPrompt: params.skillsSnapshot?.prompt,
|
||||
});
|
||||
let promptText = params.prompt;
|
||||
let promptContextRange: CodexProjectedContextRange | undefined;
|
||||
let developerInstructions = baseDeveloperInstructions;
|
||||
let prePromptMessageCount = historyMessages.length;
|
||||
const codexContextProjectionMaxChars = resolveCodexContextEngineProjectionMaxChars({
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
|
||||
config: params.config,
|
||||
}),
|
||||
});
|
||||
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
|
||||
let precomputedStaleBindingContinuityProjectionApplied = false;
|
||||
let staleBindingContinuityForcedFreshStart = false;
|
||||
@@ -909,8 +917,10 @@ export async function runCodexAppServerAttempt(
|
||||
assembledMessages: historyMessages,
|
||||
originalHistoryMessages: historyMessages,
|
||||
prompt: params.prompt,
|
||||
maxRenderedContextChars: codexContextProjectionMaxChars,
|
||||
});
|
||||
promptText = projection.promptText;
|
||||
promptContextRange = projection.promptContextRange;
|
||||
prePromptMessageCount = projection.prePromptMessageCount;
|
||||
};
|
||||
const applyActiveContextEngineProjection = async (
|
||||
@@ -930,10 +940,7 @@ export async function runCodexAppServerAttempt(
|
||||
.map((tool) => tool.name)
|
||||
.filter(isNonEmptyString),
|
||||
),
|
||||
citationsMode: params.config
|
||||
? resolveAgentMemoryConfig(params.config, sessionAgentId)?.citations
|
||||
: undefined,
|
||||
agentId: sessionAgentId,
|
||||
citationsMode: params.config?.memory?.citations,
|
||||
modelId: params.modelId,
|
||||
contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
|
||||
providerId: params.provider,
|
||||
@@ -953,12 +960,7 @@ export async function runCodexAppServerAttempt(
|
||||
originalHistoryMessages: historyMessages,
|
||||
prompt: params.prompt,
|
||||
systemPromptAddition: assembled.systemPromptAddition,
|
||||
maxRenderedContextChars: resolveCodexContextEngineProjectionMaxChars({
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
|
||||
config: params.config,
|
||||
}),
|
||||
}),
|
||||
maxRenderedContextChars: codexContextProjectionMaxChars,
|
||||
toolPayloadMode: contextEngineProjection ? "preserve" : "elide",
|
||||
});
|
||||
const projectionDecision = contextEngineProjection
|
||||
@@ -967,7 +969,6 @@ export async function runCodexAppServerAttempt(
|
||||
expectedBinding: buildContextEngineBinding(
|
||||
buildActiveRunAttemptParams(),
|
||||
contextEngineProjection,
|
||||
{ agentId: sessionAgentId },
|
||||
),
|
||||
projection: contextEngineProjection,
|
||||
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
|
||||
@@ -991,6 +992,7 @@ export async function runCodexAppServerAttempt(
|
||||
developerInstructionAdditionChars: projection.developerInstructionAddition?.length ?? 0,
|
||||
});
|
||||
promptText = projectionDecision.project ? projection.promptText : params.prompt;
|
||||
promptContextRange = projectionDecision.project ? projection.promptContextRange : undefined;
|
||||
developerInstructions = joinPresentSections(
|
||||
baseDeveloperInstructions,
|
||||
projection.developerInstructionAddition,
|
||||
@@ -1019,12 +1021,31 @@ export async function runCodexAppServerAttempt(
|
||||
messages: codexModelInputHistoryMessages,
|
||||
ctx: hookContext,
|
||||
});
|
||||
const resolveShiftedPromptContextRange = (
|
||||
prompt: string,
|
||||
turnPromptText: string,
|
||||
): CodexProjectedContextRange | undefined => {
|
||||
if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) {
|
||||
return undefined;
|
||||
}
|
||||
const promptTextOffset = prompt.length - promptText.length;
|
||||
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
|
||||
return {
|
||||
start: turnPromptOffset + promptContextRange.start,
|
||||
end: turnPromptOffset + promptContextRange.end,
|
||||
};
|
||||
};
|
||||
let promptBuild = await buildPromptFromCurrentInputs();
|
||||
const decorateCodexTurnPromptText = (prompt: string) =>
|
||||
prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
|
||||
const decorateCodexTurnPromptText = (prompt: string) => {
|
||||
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
|
||||
preservePromptWithoutContext:
|
||||
params.bootstrapContextMode === "lightweight" && params.bootstrapContextRunKind === "cron",
|
||||
});
|
||||
return fitCodexProjectedContextForTurnStart({
|
||||
promptText: turnPromptText,
|
||||
contextRange: resolveShiftedPromptContextRange(prompt, turnPromptText),
|
||||
});
|
||||
};
|
||||
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
|
||||
const buildCodexTurnCollaborationDeveloperInstructions = () =>
|
||||
buildTurnCollaborationMode(params, {
|
||||
@@ -1093,8 +1114,10 @@ export async function runCodexAppServerAttempt(
|
||||
assembledMessages: newerVisibleMessages,
|
||||
originalHistoryMessages: historyMessages,
|
||||
prompt: params.prompt,
|
||||
maxRenderedContextChars: codexContextProjectionMaxChars,
|
||||
});
|
||||
promptText = projection.promptText;
|
||||
promptContextRange = projection.promptContextRange;
|
||||
prePromptMessageCount = projection.prePromptMessageCount;
|
||||
return true;
|
||||
};
|
||||
@@ -1171,6 +1194,11 @@ export async function runCodexAppServerAttempt(
|
||||
staleBindingContinuityForcedFreshStart =
|
||||
precomputedStaleBindingContinuityProjectionApplied &&
|
||||
!inactiveThreadBootstrapBindingForcedFreshStart;
|
||||
if (staleBindingContinuityForcedFreshStart) {
|
||||
// Once the native thread id is discarded, Codex no longer owns the
|
||||
// pre-binding history; rebuild from the mirrored transcript.
|
||||
applyFreshThreadContinuityProjection();
|
||||
}
|
||||
if (activeContextEngine) {
|
||||
contextEngineProjection = undefined;
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Codex tests cover thread lifecycle.binding plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
|
||||
import {
|
||||
createParams as createRunAttemptParams,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import {
|
||||
shouldRotateCodexAppServerBindingForRuntime,
|
||||
startOrResumeThread,
|
||||
@@ -1296,8 +1296,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
assemble: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
} as never;
|
||||
params.config = { memory: { citations: "on" },
|
||||
} as never;
|
||||
params.config = { memory: { citations: "inline" } } as never;
|
||||
params.contextTokenBudget = 400_000;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
@@ -1326,44 +1325,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
|
||||
'"turnMaintenanceMode":"foreground"',
|
||||
);
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"on"');
|
||||
});
|
||||
|
||||
it("binds context-engine citations to the lifecycle agent when attempt params omit it", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentId = undefined;
|
||||
params.sessionKey = "agent:writer:session-1";
|
||||
params.contextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
} as never;
|
||||
params.config = {
|
||||
memory: { citations: "on" },
|
||||
agents: {
|
||||
list: [{ id: "writer", memory: { citations: "off" } }],
|
||||
},
|
||||
} as never;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-writer");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
agentId: "writer",
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"off"');
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
|
||||
});
|
||||
|
||||
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
|
||||
import {
|
||||
resolveAgentMemoryConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { listRegisteredPluginAgentPromptGuidance } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY } from "../../prompt-overlay.js";
|
||||
import { isModernCodexModel } from "../../provider.js";
|
||||
@@ -345,9 +341,7 @@ export async function startOrResumeThread(params: {
|
||||
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
|
||||
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection, {
|
||||
agentId: params.agentId,
|
||||
}),
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
const userMcpServersConfigPatch =
|
||||
params.userMcpServersEnabled === false
|
||||
@@ -970,7 +964,6 @@ function isTransientWebSearchRestriction(
|
||||
export function buildContextEngineBinding(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
projection?: CodexContextEngineThreadBootstrapProjection,
|
||||
options?: { agentId?: string },
|
||||
): CodexAppServerContextEngineBinding | undefined {
|
||||
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
@@ -988,10 +981,7 @@ export function buildContextEngineBinding(
|
||||
engineVersion: contextEngine.info.version,
|
||||
ownsCompaction: contextEngine.info.ownsCompaction === true,
|
||||
turnMaintenanceMode: contextEngine.info.turnMaintenanceMode,
|
||||
citationsMode: resolveContextEngineCitationsMode(
|
||||
params.config,
|
||||
options?.agentId ?? params.agentId,
|
||||
),
|
||||
citationsMode: resolveContextEngineCitationsMode(params.config),
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
@@ -1042,14 +1032,10 @@ function areContextEngineProjectionBindingsCompatible(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveContextEngineCitationsMode(
|
||||
config: unknown,
|
||||
agentId?: string,
|
||||
): JsonValue | undefined {
|
||||
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
|
||||
const rootConfig = isUnknownRecord(config) ? config : undefined;
|
||||
const citations = rootConfig
|
||||
? resolveAgentMemoryConfig(rootConfig as OpenClawConfig, agentId ?? "main")?.citations
|
||||
: undefined;
|
||||
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
|
||||
const citations = memoryConfig?.citations;
|
||||
return isJsonConfigValue(citations) ? citations : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
|
||||
config: options.config,
|
||||
env: process.env,
|
||||
value: options.remote?.apiKey,
|
||||
path: "agents.*.memory.search.remote.apiKey",
|
||||
path: "agents.*.memorySearch.remote.apiKey",
|
||||
});
|
||||
const { githubToken: profileGithubToken } = await resolveFirstGithubToken({
|
||||
agentDir: options.agentDir,
|
||||
|
||||
@@ -210,7 +210,7 @@ export function resolveGeminiOutputDimensionality(
|
||||
function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined {
|
||||
const trimmed = resolveMemorySecretInputString({
|
||||
value: remoteApiKey,
|
||||
path: "agents.*.memory.search.remote.apiKey",
|
||||
path: "agents.*.memorySearch.remote.apiKey",
|
||||
});
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
|
||||
@@ -15,9 +15,9 @@ native installs and updates.
|
||||
|
||||
## Configure
|
||||
|
||||
Set `memory.search.provider` to `local`. By default, the plugin
|
||||
Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin
|
||||
downloads and uses the EmbeddingGemma GGUF model. Configure
|
||||
`memory.search.local.modelPath` to use another local path, Hugging
|
||||
`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging
|
||||
Face model URI, or HTTPS model URL.
|
||||
|
||||
## Package
|
||||
|
||||
@@ -147,7 +147,7 @@ export function formatLlamaCppSetupError(err: unknown): string {
|
||||
"1) Install the official provider plugin: openclaw plugins install @openclaw/llama-cpp-provider",
|
||||
"2) Use Node 24 for native installs/updates.",
|
||||
"3) If you use pnpm from source: pnpm approve-builds, then pnpm rebuild node-llama-cpp.",
|
||||
'Or set memory.search.provider to a remote embedding provider such as "openai", "ollama", "lmstudio", or "voyage".',
|
||||
'Or set agents.defaults.memorySearch.provider to a remote embedding provider such as "openai", "ollama", "lmstudio", or "voyage".',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function createLmstudioEmbeddingProvider(
|
||||
const remoteApiKey = !isFallbackActivation
|
||||
? resolveMemorySecretInputString({
|
||||
value: options.remote?.apiKey,
|
||||
path: "agents.*.memory.search.remote.apiKey",
|
||||
path: "agents.*.memorySearch.remote.apiKey",
|
||||
})
|
||||
: undefined;
|
||||
// memorySearch.remote is shared across primary + fallback providers.
|
||||
|
||||
@@ -16,11 +16,7 @@ import { stateMigrations } from "./doctor-contract-api.js";
|
||||
import { testing as dreamingTesting } from "./src/dreaming-phases.js";
|
||||
import {
|
||||
configureMemoryCoreDreamingState,
|
||||
DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
memoryCoreWorkspaceStateKey,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
readMemoryCoreWorkspaceEntries,
|
||||
writeMemoryCoreWorkspaceEntries,
|
||||
} from "./src/dreaming-state.js";
|
||||
import { testing as shortTermTesting } from "./src/short-term-promotion.js";
|
||||
|
||||
@@ -73,14 +69,6 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function migrationById(id: string) {
|
||||
const migration = stateMigrations.find((entry) => entry.id === id);
|
||||
if (!migration) {
|
||||
throw new Error(`Missing migration ${id}`);
|
||||
}
|
||||
return migration;
|
||||
}
|
||||
|
||||
it("imports persistent legacy dreaming state and ignores transient locks", async () => {
|
||||
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
|
||||
const dailyPath = path.join(dreamsDir, "daily-ingestion.json");
|
||||
@@ -167,7 +155,7 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
);
|
||||
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf8");
|
||||
|
||||
const migration = migrationById("memory-core-dreams-json-to-sqlite");
|
||||
const migration = stateMigrations[0];
|
||||
const preview = await migration.detectLegacyState(migrationParams());
|
||||
expect(preview?.preview).toEqual([
|
||||
expect.stringContaining("Memory Core daily ingestion"),
|
||||
@@ -197,21 +185,16 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
await expect(fs.access(`${phasePath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
|
||||
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir, "main");
|
||||
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir);
|
||||
expect(daily.files["memory/2026-04-05.md"]?.mtimeMs).toBe(1);
|
||||
const session = await dreamingTesting.readSessionIngestionState(workspaceDir, "main");
|
||||
const session = await dreamingTesting.readSessionIngestionState(workspaceDir);
|
||||
expect(session.files["main/session.jsonl"]?.contentHash).toBe("session-hash");
|
||||
expect(session.seenMessages["main/session.jsonl"]).toEqual(["seen-a", "seen-b"]);
|
||||
const recall = await shortTermTesting.readRecallStore(
|
||||
workspaceDir,
|
||||
"2026-04-05T12:00:00.000Z",
|
||||
"main",
|
||||
);
|
||||
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
|
||||
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
|
||||
const phase = await shortTermTesting.readPhaseSignalStore(
|
||||
workspaceDir,
|
||||
"2026-04-05T13:00:00.000Z",
|
||||
"main",
|
||||
);
|
||||
expect(phase.entries["memory:memory/2026-04-05.md:1:1"]?.remHits).toBe(2);
|
||||
});
|
||||
@@ -220,9 +203,7 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
const recallPath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.writeFile(recallPath, "{", "utf8");
|
||||
|
||||
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
|
||||
migrationParams(),
|
||||
);
|
||||
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings).toEqual([
|
||||
@@ -231,11 +212,7 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
await expect(fs.access(recallPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${recallPath}.migrated`)).rejects.toThrow();
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
const recall = await shortTermTesting.readRecallStore(
|
||||
workspaceDir,
|
||||
new Date().toISOString(),
|
||||
"main",
|
||||
);
|
||||
const recall = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
|
||||
expect(recall.entries).toEqual({});
|
||||
});
|
||||
|
||||
@@ -268,14 +245,10 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
);
|
||||
const config = { agents: { list: [{ id: "main", default: true }] } };
|
||||
|
||||
const preview = await migrationById("memory-core-dreams-json-to-sqlite").detectLegacyState(
|
||||
migrationParams(config),
|
||||
);
|
||||
const preview = await stateMigrations[0].detectLegacyState(migrationParams(config));
|
||||
expect(preview?.preview).toEqual([expect.stringContaining("Memory Core short-term recall")]);
|
||||
|
||||
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
|
||||
migrationParams(config),
|
||||
);
|
||||
const result = await stateMigrations[0].migrateLegacyState(migrationParams(config));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
@@ -283,493 +256,7 @@ describe("memory-core doctor dreaming migration", () => {
|
||||
expect.stringContaining("Archived Memory Core short-term recall legacy source"),
|
||||
]);
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
const recall = await shortTermTesting.readRecallStore(
|
||||
workspaceDir,
|
||||
"2026-04-05T12:00:00.000Z",
|
||||
"main",
|
||||
);
|
||||
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
|
||||
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
|
||||
});
|
||||
|
||||
it("moves unscoped SQLite state and the legacy diary to the default agent", async () => {
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-05.md",
|
||||
value: {
|
||||
size: 42,
|
||||
mtimeMs: 1,
|
||||
contentHash: "daily-hash",
|
||||
ingestedAt: "2026-04-05T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(legacyDiaryPath, "# Dream Diary\n\nA remembered dream.\n", "utf8");
|
||||
|
||||
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
|
||||
const preview = await migration.detectLegacyState(migrationParams());
|
||||
expect(preview?.preview).toEqual([
|
||||
expect.stringContaining("Memory Core daily ingestion"),
|
||||
expect.stringContaining("Memory Core dream diary"),
|
||||
]);
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams());
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
|
||||
);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core dream diary -> agent-scoped path (main)",
|
||||
);
|
||||
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toContain("A remembered dream.");
|
||||
});
|
||||
|
||||
it("preserves a legacy diary when its destination contains only a substring", async () => {
|
||||
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
|
||||
const agentDiaryPath = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"main",
|
||||
"DREAMS.md",
|
||||
);
|
||||
await fs.writeFile(legacyDiaryPath, "A remembered dream\n", "utf8");
|
||||
await fs.mkdir(path.dirname(agentDiaryPath), { recursive: true });
|
||||
await fs.writeFile(agentDiaryPath, "A remembered dream, continued.\n", "utf8");
|
||||
|
||||
const result = await migrationById(
|
||||
"memory-core-workspace-state-to-agent-scope",
|
||||
).migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core dream diary -> agent-scoped path (main)",
|
||||
);
|
||||
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(agentDiaryPath, "utf8")).resolves.toBe(
|
||||
"A remembered dream\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->\n\nA remembered dream, continued.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes agent ids at the SQLite state boundary", async () => {
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
expect(memoryCoreWorkspaceStateKey(workspaceDir, "Team Ops")).toBe(
|
||||
memoryCoreWorkspaceStateKey(workspaceDir, "team-ops"),
|
||||
);
|
||||
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "Team Ops",
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-06.md",
|
||||
value: {
|
||||
size: 18,
|
||||
mtimeMs: 2,
|
||||
contentHash: "team-daily-hash",
|
||||
ingestedAt: "2026-04-06T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "team-ops",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("migrates a legacy workspace to that workspace's configured agent", async () => {
|
||||
const researchWorkspaceDir = path.join(rootDir, "research");
|
||||
const researchDreamsDir = path.join(researchWorkspaceDir, "memory", ".dreams");
|
||||
await fs.mkdir(researchDreamsDir, { recursive: true });
|
||||
const dailyPath = path.join(researchDreamsDir, "daily-ingestion.json");
|
||||
await fs.writeFile(
|
||||
dailyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
files: {
|
||||
"memory/2026-04-06.md": {
|
||||
size: 18,
|
||||
mtimeMs: 2,
|
||||
contentHash: "research-daily-hash",
|
||||
ingestedAt: "2026-04-06T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: workspaceDir },
|
||||
{ id: "research", workspace: researchWorkspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const migration = migrationById("memory-core-dreams-json-to-sqlite");
|
||||
const result = await migration.migrateLegacyState(migrationParams(config));
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core daily ingestion -> SQLite plugin state (1 row(s))",
|
||||
);
|
||||
expect({
|
||||
main: await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: researchWorkspaceDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
research: await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: researchWorkspaceDir,
|
||||
agentId: "research",
|
||||
}),
|
||||
unscoped: await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: researchWorkspaceDir,
|
||||
}),
|
||||
}).toEqual({
|
||||
main: [],
|
||||
research: [
|
||||
expect.objectContaining({
|
||||
key: "memory/2026-04-06.md",
|
||||
value: expect.objectContaining({
|
||||
size: 18,
|
||||
mtimeMs: 2,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
unscoped: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("imports shared legacy JSON into the configured default agent", async () => {
|
||||
const dailyPath = path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json");
|
||||
await fs.writeFile(
|
||||
dailyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
files: {
|
||||
"memory/2026-04-07.md": {
|
||||
size: 22,
|
||||
mtimeMs: 3,
|
||||
contentHash: "shared-daily-hash",
|
||||
ingestedAt: "2026-04-07T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: workspaceDir },
|
||||
{ id: "research", workspace: workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await migrationById("memory-core-dreams-json-to-sqlite").migrateLegacyState(
|
||||
migrationParams(config),
|
||||
);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core daily ingestion -> SQLite plugin state (1 row(s))",
|
||||
);
|
||||
await expect(fs.access(`${dailyPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "research",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("moves shared legacy state and diaries to the configured default agent", async () => {
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-07.md",
|
||||
value: {
|
||||
size: 22,
|
||||
mtimeMs: 3,
|
||||
contentHash: "shared-daily-hash",
|
||||
ingestedAt: "2026-04-07T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const legacyDiaryPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(legacyDiaryPath, "# Shared dream diary\n", "utf8");
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: workspaceDir },
|
||||
{ id: "research", workspace: workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
|
||||
|
||||
const preview = await migration.detectLegacyState(migrationParams(config));
|
||||
expect(preview?.preview.join("\n")).toContain("-> agent main");
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams(config));
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
|
||||
);
|
||||
expect(result.changes).toContain(
|
||||
"Migrated Memory Core dream diary -> agent-scoped path (main)",
|
||||
);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "research",
|
||||
}),
|
||||
).toEqual([]);
|
||||
await expect(fs.access(`${legacyDiaryPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toContain("Shared dream diary");
|
||||
});
|
||||
|
||||
it("leaves shared legacy state in place when the default agent has another workspace", async () => {
|
||||
const defaultWorkspaceDir = path.join(rootDir, "default-workspace");
|
||||
await fs.mkdir(defaultWorkspaceDir);
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-07.md",
|
||||
value: {
|
||||
size: 22,
|
||||
mtimeMs: 3,
|
||||
contentHash: "shared-daily-hash",
|
||||
ingestedAt: "2026-04-07T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: defaultWorkspaceDir },
|
||||
{ id: "research", workspace: workspaceDir },
|
||||
{ id: "writer", workspace: workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
|
||||
const preview = await migration.detectLegacyState(migrationParams(config));
|
||||
expect(preview?.preview.join("\n")).toContain("resolved default agent does not share");
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams(config));
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining("resolved default agent does not share"),
|
||||
]);
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves symlinked shared workspace aliases to the configured default agent", async () => {
|
||||
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
|
||||
await fs.symlink(workspaceDir, workspaceAliasDir);
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: workspaceAliasDir,
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-07.md",
|
||||
value: {
|
||||
size: 22,
|
||||
mtimeMs: 3,
|
||||
contentHash: "shared-daily-hash",
|
||||
ingestedAt: "2026-04-07T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "research", workspace: workspaceAliasDir },
|
||||
{ id: "main", default: true, workspace: workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
|
||||
|
||||
const preview = await migration.detectLegacyState(migrationParams(config));
|
||||
expect(preview?.preview.join("\n")).toContain("-> agent main");
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams(config));
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
|
||||
]);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: workspaceAliasDir,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves legacy alias state into the configured workspace scope", async () => {
|
||||
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
|
||||
const workspaceCanonicalDir = await fs.realpath(workspaceDir);
|
||||
await fs.symlink(workspaceDir, workspaceAliasDir);
|
||||
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: workspaceCanonicalDir,
|
||||
entries: [
|
||||
{
|
||||
key: "memory/2026-04-08.md",
|
||||
value: {
|
||||
size: 23,
|
||||
mtimeMs: 4,
|
||||
contentHash: "alias-daily-hash",
|
||||
ingestedAt: "2026-04-08T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: workspaceAliasDir }],
|
||||
},
|
||||
};
|
||||
const migration = migrationById("memory-core-workspace-state-to-agent-scope");
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams(config));
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
"Migrated Memory Core daily ingestion -> agent-scoped SQLite state (1 row(s), 0 existing agent row(s) retained)",
|
||||
]);
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: workspaceCanonicalDir,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: workspaceAliasDir,
|
||||
agentId: "main",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not migrate legacy sources through symlinked dream directories", async () => {
|
||||
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
|
||||
const outsideDreamsDir = path.join(rootDir, "outside-dreams");
|
||||
const outsideLegacyPath = path.join(outsideDreamsDir, "short-term-recall.json");
|
||||
await fs.rm(dreamsDir, { recursive: true, force: true });
|
||||
await fs.mkdir(outsideDreamsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
outsideLegacyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updatedAt: "2026-04-07T10:00:00.000Z",
|
||||
entries: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(outsideDreamsDir, dreamsDir);
|
||||
|
||||
const migration = migrationById("memory-core-dreams-json-to-sqlite");
|
||||
expect(await migration.detectLegacyState(migrationParams())).toBeNull();
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams());
|
||||
expect(result).toEqual({ changes: [], warnings: [] });
|
||||
await expect(fs.access(outsideLegacyPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${outsideLegacyPath}.migrated`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// Memory Core doctor contract migrates shipped workspace dreaming state.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveDefaultAgentId,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { updateDreamsFile } from "./src/dreaming-dreams-file.js";
|
||||
import {
|
||||
DAILY_INGESTION_STATE_RELATIVE_PATH,
|
||||
SESSION_INGESTION_STATE_RELATIVE_PATH,
|
||||
@@ -24,7 +18,6 @@ import {
|
||||
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
SHORT_TERM_RECALL_NAMESPACE,
|
||||
configureMemoryCoreDreamingState,
|
||||
migrateMemoryCoreWorkspaceNamespaceToAgent,
|
||||
readMemoryCoreWorkspaceEntries,
|
||||
writeMemoryCoreWorkspaceEntries,
|
||||
writeMemoryCoreWorkspaceEntry,
|
||||
@@ -38,223 +31,49 @@ import {
|
||||
|
||||
type LegacySource = {
|
||||
workspaceDir: string;
|
||||
stateWorkspaceDir: string;
|
||||
agentId: string;
|
||||
label: string;
|
||||
relativePath: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type WorkspaceTarget = {
|
||||
workspaceDir: string;
|
||||
stateWorkspaceDir: string;
|
||||
stateWorkspaceDirs: string[];
|
||||
agentIds: string[];
|
||||
agentWorkspaceDirs: Record<string, string>;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
const LEGACY_JSON_CANDIDATES = [
|
||||
{ label: "daily ingestion", relativePath: DAILY_INGESTION_STATE_RELATIVE_PATH },
|
||||
{ label: "session ingestion", relativePath: SESSION_INGESTION_STATE_RELATIVE_PATH },
|
||||
{ label: "short-term recall", relativePath: SHORT_TERM_STORE_RELATIVE_PATH },
|
||||
{ label: "phase signals", relativePath: SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH },
|
||||
] as const;
|
||||
|
||||
const SCOPED_STATE_NAMESPACES = [
|
||||
{ namespace: DREAMING_DAILY_INGESTION_NAMESPACE, label: "daily ingestion" },
|
||||
{ namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE, label: "session ingestion files" },
|
||||
{ namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE, label: "session ingestion seen state" },
|
||||
{ namespace: SHORT_TERM_RECALL_NAMESPACE, label: "short-term recall" },
|
||||
{ namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE, label: "phase signals" },
|
||||
{ namespace: SHORT_TERM_META_NAMESPACE, label: "short-term metadata" },
|
||||
] as const;
|
||||
|
||||
async function resolveConfiguredWorkspaces(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<WorkspaceTarget[]> {
|
||||
const cfg = config as Parameters<typeof listAgentIds>[0];
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const targets = new Map<string, WorkspaceTarget>();
|
||||
for (const configuredAgentId of listAgentIds(cfg)) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, configuredAgentId, env);
|
||||
const workspaceRoot = await fs.realpath(workspaceDir).catch(() => path.resolve(workspaceDir));
|
||||
const existing = targets.get(workspaceRoot);
|
||||
if (existing) {
|
||||
existing.agentIds.push(configuredAgentId);
|
||||
existing.agentWorkspaceDirs[configuredAgentId] = workspaceDir;
|
||||
if (!existing.stateWorkspaceDirs.includes(workspaceDir)) {
|
||||
existing.stateWorkspaceDirs.push(workspaceDir);
|
||||
}
|
||||
} else {
|
||||
targets.set(workspaceRoot, {
|
||||
workspaceDir: workspaceRoot,
|
||||
stateWorkspaceDir: workspaceDir,
|
||||
stateWorkspaceDirs: [...new Set([workspaceDir, workspaceRoot])],
|
||||
agentIds: [configuredAgentId],
|
||||
agentWorkspaceDirs: { [configuredAgentId]: workspaceDir },
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...targets.values()].map((target) => {
|
||||
const agentId =
|
||||
target.agentIds.length === 1
|
||||
? target.agentIds[0]
|
||||
: target.agentIds.includes(defaultAgentId)
|
||||
? defaultAgentId
|
||||
: undefined;
|
||||
const resolved: WorkspaceTarget = {
|
||||
workspaceDir: target.workspaceDir,
|
||||
stateWorkspaceDir: agentId
|
||||
? (target.agentWorkspaceDirs[agentId] ?? target.stateWorkspaceDir)
|
||||
: target.stateWorkspaceDir,
|
||||
stateWorkspaceDirs: target.stateWorkspaceDirs,
|
||||
agentIds: target.agentIds,
|
||||
agentWorkspaceDirs: target.agentWorkspaceDirs,
|
||||
};
|
||||
if (agentId) {
|
||||
// Legacy workspace-scoped dreaming state was shared. Preserve it under
|
||||
// the resolved default agent rather than copying private state to peers.
|
||||
resolved.agentId = agentId;
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
function resolveConfiguredWorkspaces(config: unknown, env: NodeJS.ProcessEnv): string[] {
|
||||
return resolveMemoryDreamingWorkspaces(
|
||||
config as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
|
||||
{ env },
|
||||
).map((entry) => entry.workspaceDir);
|
||||
}
|
||||
|
||||
function isAgentScopedWorkspaceTarget(
|
||||
target: WorkspaceTarget,
|
||||
): target is WorkspaceTarget & { agentId: string } {
|
||||
return typeof target.agentId === "string";
|
||||
}
|
||||
|
||||
async function resolveAgentScopedWorkspaces(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<Array<WorkspaceTarget & { agentId: string }>> {
|
||||
return (await resolveConfiguredWorkspaces(config, env)).filter(isAgentScopedWorkspaceTarget);
|
||||
}
|
||||
|
||||
function formatSharedWorkspaceMigrationWarning(target: WorkspaceTarget): string {
|
||||
return `Skipped automatic Memory Core dreaming migration for shared workspace ${target.workspaceDir}; its resolved default agent does not share the workspace with ${target.agentIds.join(", ")}`;
|
||||
}
|
||||
|
||||
async function collectSharedJsonMigrationWarnings(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
for (const target of await resolveConfiguredWorkspaces(config, env)) {
|
||||
if (isAgentScopedWorkspaceTarget(target)) {
|
||||
continue;
|
||||
}
|
||||
for (const candidate of LEGACY_JSON_CANDIDATES) {
|
||||
if (await workspaceFileExists(target.workspaceDir, candidate.relativePath)) {
|
||||
warnings.push(formatSharedWorkspaceMigrationWarning(target));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
async function collectSharedAgentScopeMigrationWarnings(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
for (const target of await resolveConfiguredWorkspaces(config, env)) {
|
||||
if (isAgentScopedWorkspaceTarget(target)) {
|
||||
continue;
|
||||
}
|
||||
const hasUnscopedState = (
|
||||
await Promise.all(
|
||||
SCOPED_STATE_NAMESPACES.map(async (candidate) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
target.stateWorkspaceDirs.map(async (workspaceDir) => {
|
||||
const entries = await readMemoryCoreWorkspaceEntries({
|
||||
namespace: candidate.namespace,
|
||||
workspaceDir,
|
||||
});
|
||||
return entries.length > 0;
|
||||
}),
|
||||
)
|
||||
).some(Boolean);
|
||||
}),
|
||||
)
|
||||
).some(Boolean);
|
||||
const hasLegacyDiary = (
|
||||
await Promise.all(
|
||||
["DREAMS.md", "dreams.md"].map((relativePath) =>
|
||||
workspaceFileExists(target.workspaceDir, relativePath),
|
||||
),
|
||||
)
|
||||
).some(Boolean);
|
||||
if (hasUnscopedState || hasLegacyDiary) {
|
||||
warnings.push(formatSharedWorkspaceMigrationWarning(target));
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
async function workspaceFileExists(workspaceDir: string, relativePath: string): Promise<boolean> {
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const workspace = await fsRoot(workspaceDir);
|
||||
const opened = await workspace.open(relativePath);
|
||||
try {
|
||||
return opened.stat.isFile();
|
||||
} finally {
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
}
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkspaceRegularFile(
|
||||
workspaceDir: string,
|
||||
relativePath: string,
|
||||
): Promise<Buffer> {
|
||||
const workspace = await fsRoot(workspaceDir);
|
||||
const opened = await workspace.open(relativePath);
|
||||
try {
|
||||
if (!opened.stat.isFile()) {
|
||||
throw new Error("source is not a regular file");
|
||||
}
|
||||
return await opened.handle.readFile();
|
||||
} finally {
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile(workspaceDir: string, relativePath: string): Promise<unknown> {
|
||||
return JSON.parse((await readWorkspaceRegularFile(workspaceDir, relativePath)).toString("utf8"));
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
async function archiveLegacySource(params: {
|
||||
workspaceDir: string;
|
||||
relativePath: string;
|
||||
filePath: string;
|
||||
label: string;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
}): Promise<void> {
|
||||
const workspace = await fsRoot(params.workspaceDir);
|
||||
const archivedRelativePath = `${params.relativePath}.migrated`;
|
||||
const filePath = path.join(params.workspaceDir, params.relativePath);
|
||||
const archivedPath = path.join(params.workspaceDir, archivedRelativePath);
|
||||
if (await workspaceFileExists(params.workspaceDir, archivedRelativePath)) {
|
||||
const archivedPath = `${params.filePath}.migrated`;
|
||||
if (await fileExists(archivedPath)) {
|
||||
params.warnings.push(
|
||||
`Left migrated Memory Core ${params.label} source in place because ${archivedPath} already exists`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await workspace.move(params.relativePath, archivedRelativePath);
|
||||
await fs.rename(params.filePath, archivedPath);
|
||||
params.changes.push(`Archived Memory Core ${params.label} legacy source -> ${archivedPath}`);
|
||||
} catch (err) {
|
||||
params.warnings.push(
|
||||
`Failed archiving Memory Core ${params.label} source ${filePath}: ${String(err)}`,
|
||||
`Failed archiving Memory Core ${params.label} legacy source: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -264,53 +83,39 @@ async function collectLegacySources(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<LegacySource[]> {
|
||||
const sources: LegacySource[] = [];
|
||||
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
|
||||
const { workspaceDir, stateWorkspaceDir, agentId } = target;
|
||||
for (const candidate of LEGACY_JSON_CANDIDATES) {
|
||||
for (const workspaceDir of resolveConfiguredWorkspaces(config, env)) {
|
||||
const candidates = [
|
||||
{ label: "daily ingestion", relativePath: DAILY_INGESTION_STATE_RELATIVE_PATH },
|
||||
{ label: "session ingestion", relativePath: SESSION_INGESTION_STATE_RELATIVE_PATH },
|
||||
{ label: "short-term recall", relativePath: SHORT_TERM_STORE_RELATIVE_PATH },
|
||||
{ label: "phase signals", relativePath: SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const filePath = path.join(workspaceDir, candidate.relativePath);
|
||||
if (await workspaceFileExists(workspaceDir, candidate.relativePath)) {
|
||||
sources.push({
|
||||
workspaceDir,
|
||||
stateWorkspaceDir,
|
||||
agentId,
|
||||
label: candidate.label,
|
||||
relativePath: candidate.relativePath,
|
||||
filePath,
|
||||
});
|
||||
if (await fileExists(filePath)) {
|
||||
sources.push({ workspaceDir, label: candidate.label, filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function workspaceHasRows(
|
||||
namespace: string,
|
||||
stateWorkspaceDir: string,
|
||||
agentId: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir: stateWorkspaceDir, agentId }))
|
||||
.length > 0
|
||||
);
|
||||
async function workspaceHasRows(namespace: string, workspaceDir: string): Promise<boolean> {
|
||||
return (await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir })).length > 0;
|
||||
}
|
||||
|
||||
async function migrateDailyIngestion(source: LegacySource): Promise<number> {
|
||||
const state = normalizeDailyIngestionState(
|
||||
await readJsonFile(source.workspaceDir, source.relativePath),
|
||||
);
|
||||
const state = normalizeDailyIngestionState(await readJsonFile(source.filePath));
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
|
||||
});
|
||||
return Object.keys(state.files).length;
|
||||
}
|
||||
|
||||
async function migrateSessionIngestion(source: LegacySource): Promise<number> {
|
||||
const state = normalizeSessionIngestionState(
|
||||
await readJsonFile(source.workspaceDir, source.relativePath),
|
||||
);
|
||||
const state = normalizeSessionIngestionState(await readJsonFile(source.filePath));
|
||||
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
|
||||
Array.from(
|
||||
{ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) },
|
||||
@@ -330,14 +135,12 @@ async function migrateSessionIngestion(source: LegacySource): Promise<number> {
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
entries: seenEntries,
|
||||
}),
|
||||
]);
|
||||
@@ -346,21 +149,16 @@ async function migrateSessionIngestion(source: LegacySource): Promise<number> {
|
||||
|
||||
async function migrateShortTermRecall(source: LegacySource): Promise<number> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const state = normalizeShortTermRecallStore(
|
||||
await readJsonFile(source.workspaceDir, source.relativePath),
|
||||
nowIso,
|
||||
);
|
||||
const state = normalizeShortTermRecallStore(await readJsonFile(source.filePath), nowIso);
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
key: "recall",
|
||||
value: { updatedAt: state.updatedAt },
|
||||
}),
|
||||
@@ -370,21 +168,16 @@ async function migrateShortTermRecall(source: LegacySource): Promise<number> {
|
||||
|
||||
async function migratePhaseSignals(source: LegacySource): Promise<number> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const state = normalizeShortTermPhaseSignalStore(
|
||||
await readJsonFile(source.workspaceDir, source.relativePath),
|
||||
nowIso,
|
||||
);
|
||||
const state = normalizeShortTermPhaseSignalStore(await readJsonFile(source.filePath), nowIso);
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
agentId: source.agentId,
|
||||
workspaceDir: source.workspaceDir,
|
||||
key: "phase",
|
||||
value: { updatedAt: state.updatedAt },
|
||||
}),
|
||||
@@ -418,159 +211,31 @@ async function migrateSource(source: LegacySource): Promise<number> {
|
||||
return await migratePhaseSignals(source);
|
||||
}
|
||||
|
||||
async function collectUnscopedStateTargets(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<
|
||||
Array<
|
||||
WorkspaceTarget & {
|
||||
agentId: string;
|
||||
sourceStateWorkspaceDir: string;
|
||||
namespace: string;
|
||||
label: string;
|
||||
entryCount: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const sources: Array<
|
||||
WorkspaceTarget & {
|
||||
agentId: string;
|
||||
sourceStateWorkspaceDir: string;
|
||||
namespace: string;
|
||||
label: string;
|
||||
entryCount: number;
|
||||
}
|
||||
> = [];
|
||||
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
|
||||
for (const sourceStateWorkspaceDir of target.stateWorkspaceDirs) {
|
||||
for (const candidate of SCOPED_STATE_NAMESPACES) {
|
||||
const entryCount = (
|
||||
await readMemoryCoreWorkspaceEntries({
|
||||
namespace: candidate.namespace,
|
||||
workspaceDir: sourceStateWorkspaceDir,
|
||||
})
|
||||
).length;
|
||||
if (entryCount > 0) {
|
||||
sources.push({ ...target, sourceStateWorkspaceDir, ...candidate, entryCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function collectLegacyDreamDiarySources(
|
||||
config: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<Array<WorkspaceTarget & { agentId: string; relativePath: string; filePath: string }>> {
|
||||
const sources: Array<
|
||||
WorkspaceTarget & { agentId: string; relativePath: string; filePath: string }
|
||||
> = [];
|
||||
for (const target of await resolveAgentScopedWorkspaces(config, env)) {
|
||||
const seenPaths = new Set<string>();
|
||||
for (const filename of ["DREAMS.md", "dreams.md"]) {
|
||||
const filePath = path.join(target.workspaceDir, filename);
|
||||
if (await workspaceFileExists(target.workspaceDir, filename)) {
|
||||
const realPath = await fs.realpath(filePath).catch(() => filePath);
|
||||
if (!seenPaths.has(realPath)) {
|
||||
seenPaths.add(realPath);
|
||||
sources.push({ ...target, relativePath: filename, filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function migrateLegacyDreamDiary(params: {
|
||||
workspaceDir: string;
|
||||
agentId: string;
|
||||
relativePath: string;
|
||||
filePath: string;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
}): Promise<void> {
|
||||
let legacyContent: string;
|
||||
try {
|
||||
legacyContent = (
|
||||
await readWorkspaceRegularFile(params.workspaceDir, params.relativePath)
|
||||
).toString("utf8");
|
||||
} catch {
|
||||
params.warnings.push(
|
||||
`Skipped Memory Core dream diary migration for ${params.filePath} because it is not a regular file`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing) => {
|
||||
const normalizedLegacy = legacyContent.trim();
|
||||
const migratedPrefix = `${legacyContent.trimEnd()}\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->`;
|
||||
if (
|
||||
!normalizedLegacy ||
|
||||
existing.trim() === normalizedLegacy ||
|
||||
existing.includes(migratedPrefix)
|
||||
) {
|
||||
return { content: existing, result: undefined, shouldWrite: false };
|
||||
}
|
||||
if (!existing.trim()) {
|
||||
return { content: legacyContent, result: undefined };
|
||||
}
|
||||
return {
|
||||
content: `${legacyContent.trimEnd()}\n\n<!-- openclaw:dreaming:legacy-diary-migrated -->\n\n${existing.trimStart()}`,
|
||||
result: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
params.warnings.push(
|
||||
`Skipped Memory Core dream diary migration for ${params.filePath}: ${String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await archiveLegacySource({
|
||||
workspaceDir: params.workspaceDir,
|
||||
relativePath: params.relativePath,
|
||||
label: "dream diary",
|
||||
changes: params.changes,
|
||||
warnings: params.warnings,
|
||||
});
|
||||
params.changes.push(`Migrated Memory Core dream diary -> agent-scoped path (${params.agentId})`);
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "memory-core-dreams-json-to-sqlite",
|
||||
label: "Memory Core dreaming state",
|
||||
async detectLegacyState(params) {
|
||||
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
|
||||
const [sources, sharedWarnings] = await Promise.all([
|
||||
collectLegacySources(params.config, params.env),
|
||||
collectSharedJsonMigrationWarnings(params.config, params.env),
|
||||
]);
|
||||
if (sources.length === 0 && sharedWarnings.length === 0) {
|
||||
const sources = await collectLegacySources(params.config, params.env);
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
...sources.map(
|
||||
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
|
||||
),
|
||||
...sharedWarnings.map((warning) => `- ${warning}`),
|
||||
],
|
||||
preview: sources.map(
|
||||
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
|
||||
),
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
|
||||
const changes: string[] = [];
|
||||
const warnings = await collectSharedJsonMigrationWarnings(params.config, params.env);
|
||||
const warnings: string[] = [];
|
||||
for (const source of await collectLegacySources(params.config, params.env)) {
|
||||
const targetHasRows = (
|
||||
await Promise.all(
|
||||
targetNamespacesForSource(source.label).map((namespace) =>
|
||||
workspaceHasRows(namespace, source.stateWorkspaceDir, source.agentId),
|
||||
workspaceHasRows(namespace, source.workspaceDir),
|
||||
),
|
||||
)
|
||||
).some(Boolean);
|
||||
@@ -593,8 +258,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
`Migrated Memory Core ${source.label} -> SQLite plugin state (${imported} row(s))`,
|
||||
);
|
||||
await archiveLegacySource({
|
||||
workspaceDir: source.workspaceDir,
|
||||
relativePath: source.relativePath,
|
||||
filePath: source.filePath,
|
||||
label: source.label,
|
||||
changes,
|
||||
warnings,
|
||||
@@ -603,53 +267,4 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memory-core-workspace-state-to-agent-scope",
|
||||
label: "Memory Core agent-scoped dreaming state",
|
||||
async detectLegacyState(params) {
|
||||
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
|
||||
const [stateSources, diarySources, sharedWarnings] = await Promise.all([
|
||||
collectUnscopedStateTargets(params.config, params.env),
|
||||
collectLegacyDreamDiarySources(params.config, params.env),
|
||||
collectSharedAgentScopeMigrationWarnings(params.config, params.env),
|
||||
]);
|
||||
if (stateSources.length === 0 && diarySources.length === 0 && sharedWarnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
...stateSources.map(
|
||||
(source) =>
|
||||
`- Memory Core ${source.label}: ${source.entryCount} workspace-scoped row(s) -> agent ${source.agentId}`,
|
||||
),
|
||||
...diarySources.map(
|
||||
(source) => `- Memory Core dream diary: ${source.filePath} -> agent ${source.agentId}`,
|
||||
),
|
||||
...sharedWarnings.map((warning) => `- ${warning}`),
|
||||
],
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
|
||||
const changes: string[] = [];
|
||||
const warnings = await collectSharedAgentScopeMigrationWarnings(params.config, params.env);
|
||||
for (const source of await collectUnscopedStateTargets(params.config, params.env)) {
|
||||
const result = await migrateMemoryCoreWorkspaceNamespaceToAgent({
|
||||
namespace: source.namespace,
|
||||
agentId: source.agentId,
|
||||
sourceWorkspaceDir: source.sourceStateWorkspaceDir,
|
||||
workspaceDir: source.stateWorkspaceDir,
|
||||
});
|
||||
if (result.sourceEntries > 0) {
|
||||
changes.push(
|
||||
`Migrated Memory Core ${source.label} -> agent-scoped SQLite state (${result.migratedEntries} row(s), ${result.retainedAgentEntries} existing agent row(s) retained)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const source of await collectLegacyDreamDiarySources(params.config, params.env)) {
|
||||
await migrateLegacyDreamDiary({ ...source, changes, warnings });
|
||||
}
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -201,11 +201,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityMigrationPaths": [
|
||||
"plugins.entries.memory-core.config",
|
||||
"plugins.slots.memory"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-api.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Memory Core setup hooks register compatibility migration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { migrateMemoryCoreLegacyConfig } from "./src/config-compat.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "memory-core",
|
||||
name: "Memory Core Setup",
|
||||
description: "Memory Core compatibility migration hooks",
|
||||
register(api) {
|
||||
api.registerConfigMigration((config) => migrateMemoryCoreLegacyConfig(config));
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryRemDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
@@ -114,8 +113,8 @@ type LoadedMemoryCommandConfig = {
|
||||
|
||||
function getMemoryCommandSecretTargetIds(): Set<string> {
|
||||
return new Set([
|
||||
"memory.search.remote.apiKey",
|
||||
"agents.list[].memory.search.remote.apiKey",
|
||||
"agents.defaults.memorySearch.remote.apiKey",
|
||||
"agents.list[].memorySearch.remote.apiKey",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -149,11 +148,9 @@ function emitMemorySecretResolveDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMemoryPluginConfig(
|
||||
cfg: OpenClawConfig,
|
||||
agentId = resolveDefaultAgentId(cfg),
|
||||
): Record<string, unknown> {
|
||||
return resolveMemoryCorePluginConfig(cfg, agentId) ?? {};
|
||||
function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
||||
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
||||
return asRecord(entry?.config) ?? {};
|
||||
}
|
||||
|
||||
const DAILY_MEMORY_FILE_NAME_RE = /^(\d{4}-\d{2}-\d{2})(?:-[^/]+)?\.md$/i;
|
||||
@@ -186,7 +183,6 @@ async function createHistoricalRemHarnessWorkspace(params: {
|
||||
inputPath: string;
|
||||
remLimit: number;
|
||||
nowMs: number;
|
||||
agentId?: string;
|
||||
timezone?: string;
|
||||
}): Promise<{
|
||||
workspaceDir: string;
|
||||
@@ -213,7 +209,6 @@ async function createHistoricalRemHarnessWorkspace(params: {
|
||||
filePaths: workspaceSourceFiles,
|
||||
limit: params.remLimit,
|
||||
nowMs: params.nowMs,
|
||||
agentId: params.agentId,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
return {
|
||||
@@ -226,9 +221,9 @@ async function createHistoricalRemHarnessWorkspace(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDreamingSummary(cfg: OpenClawConfig, agentId: string): string {
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg, agentId });
|
||||
function formatDreamingSummary(cfg: OpenClawConfig): string {
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
|
||||
if (!dreaming.enabled) {
|
||||
return "off";
|
||||
}
|
||||
@@ -793,18 +788,17 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
let dreamingAudit: DreamingArtifactsAuditSummary | undefined;
|
||||
let dreamingRepair: RepairDreamingArtifactsResult | undefined;
|
||||
if (workspaceDir) {
|
||||
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
|
||||
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
if (opts.fix && dreamingAudit.issues.some((issue) => issue.fixable)) {
|
||||
dreamingRepair = await repairDreamingArtifacts({ workspaceDir, agentId });
|
||||
dreamingAudit = await auditDreamingArtifacts({ workspaceDir, agentId });
|
||||
dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
|
||||
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
}
|
||||
if (opts.fix) {
|
||||
repair = await repairShortTermPromotionArtifacts({ workspaceDir, agentId });
|
||||
repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
}
|
||||
const customQmd = asRecord(asRecord(status.custom)?.qmd);
|
||||
audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
qmd:
|
||||
status.backend === "qmd"
|
||||
? {
|
||||
@@ -887,7 +881,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||
`${label("Store")} ${info(storePath)}`,
|
||||
`${label("Workspace")} ${info(workspacePath)}`,
|
||||
`${label("Dreaming")} ${info(formatDreamingSummary(cfg, agentId))}`,
|
||||
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
|
||||
].filter(Boolean) as string[];
|
||||
if (embeddingProbe) {
|
||||
const state =
|
||||
@@ -1251,16 +1245,14 @@ export async function runMemorySearch(
|
||||
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search");
|
||||
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
||||
const agentId = resolveAgent(cfg, opts.agent);
|
||||
const memoryPluginConfig = resolveMemoryPluginConfig(cfg, agentId);
|
||||
const memoryPluginConfig = resolveMemoryPluginConfig(cfg);
|
||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||
pluginConfig: memoryPluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
}).enabled;
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: memoryPluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
await withMemoryManagerForAgent({
|
||||
cfg,
|
||||
@@ -1288,7 +1280,6 @@ export async function runMemorySearch(
|
||||
if (dreamingEnabled) {
|
||||
void recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
query,
|
||||
results,
|
||||
timezone: dreaming.timezone,
|
||||
@@ -1344,9 +1335,8 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
const status = manager.status();
|
||||
const workspaceDir = status.workspaceDir?.trim();
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg),
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
if (!workspaceDir) {
|
||||
defaultRuntime.error("Memory promote requires a resolvable workspace directory.");
|
||||
@@ -1358,7 +1348,6 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
try {
|
||||
candidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
limit: opts.limit,
|
||||
minScore: opts.minScore ?? dreaming.minScore,
|
||||
minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount,
|
||||
@@ -1378,7 +1367,6 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
try {
|
||||
applyResult = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
candidates,
|
||||
limit: opts.limit,
|
||||
minScore: opts.minScore ?? dreaming.minScore,
|
||||
@@ -1395,12 +1383,11 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const storePath = resolveShortTermRecallStorePath(workspaceDir, agentId);
|
||||
const lockPath = resolveShortTermRecallLockPath(workspaceDir, agentId);
|
||||
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
||||
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
|
||||
const customQmd = asRecord(asRecord(status.custom)?.qmd);
|
||||
const audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
qmd:
|
||||
status.backend === "qmd"
|
||||
? {
|
||||
@@ -1533,9 +1520,8 @@ export async function runMemoryPromoteExplain(
|
||||
const status = manager.status();
|
||||
const workspaceDir = status.workspaceDir?.trim();
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg, agentId),
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg),
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
if (!workspaceDir) {
|
||||
defaultRuntime.error("Memory promote-explain requires a resolvable workspace directory.");
|
||||
@@ -1547,7 +1533,6 @@ export async function runMemoryPromoteExplain(
|
||||
try {
|
||||
candidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
@@ -1641,7 +1626,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
|
||||
run: async (manager) => {
|
||||
const status = manager.status();
|
||||
const managerWorkspaceDir = status.workspaceDir?.trim();
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
||||
if (!managerWorkspaceDir && !opts.path) {
|
||||
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
|
||||
process.exitCode = 1;
|
||||
@@ -1650,7 +1635,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
|
||||
const remConfig = resolveMemoryRemDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
const nowMs = Date.now();
|
||||
let workspaceDir = managerWorkspaceDir ?? "";
|
||||
@@ -1665,7 +1649,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
|
||||
inputPath: opts.path,
|
||||
remLimit: remConfig.limit,
|
||||
nowMs,
|
||||
agentId,
|
||||
timezone: remConfig.timezone,
|
||||
});
|
||||
workspaceDir = historical.workspaceDir;
|
||||
@@ -1693,7 +1676,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
|
||||
const preview = await previewRemHarness({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
agentId,
|
||||
pluginConfig,
|
||||
grounded: Boolean(opts.grounded),
|
||||
groundedInputPaths,
|
||||
@@ -1824,11 +1806,10 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
|
||||
run: async (manager) => {
|
||||
const status = manager.status();
|
||||
const workspaceDir = status.workspaceDir?.trim();
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg, agentId);
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
||||
const remConfig = resolveMemoryRemDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
if (!workspaceDir) {
|
||||
defaultRuntime.error("Memory rem-backfill requires a resolvable workspace directory.");
|
||||
@@ -1838,10 +1819,10 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
|
||||
|
||||
if (opts.rollback || opts.rollbackShortTerm) {
|
||||
const diaryRollback = opts.rollback
|
||||
? await removeBackfillDiaryEntries({ workspaceDir, agentId })
|
||||
? await removeBackfillDiaryEntries({ workspaceDir })
|
||||
: null;
|
||||
const shortTermRollback = opts.rollbackShortTerm
|
||||
? await removeGroundedShortTermCandidates({ workspaceDir, agentId })
|
||||
? await removeGroundedShortTermCandidates({ workspaceDir })
|
||||
: null;
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({
|
||||
@@ -1953,20 +1934,18 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
|
||||
|
||||
const written = await writeBackfillDiaryEntries({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries,
|
||||
timezone: remConfig.timezone,
|
||||
});
|
||||
let stagedShortTermEntries = 0;
|
||||
let replacedShortTermEntries = 0;
|
||||
if (opts.stageShortTerm) {
|
||||
const cleared = await removeGroundedShortTermCandidates({ workspaceDir, agentId });
|
||||
const cleared = await removeGroundedShortTermCandidates({ workspaceDir });
|
||||
replacedShortTermEntries = cleared.removed;
|
||||
const shortTermSeedItems = collectGroundedShortTermSeedItems(grounded.files);
|
||||
if (shortTermSeedItems.length > 0) {
|
||||
await recordGroundedShortTermCandidates({
|
||||
workspaceDir,
|
||||
agentId,
|
||||
query: "__dreaming_grounded_backfill__",
|
||||
items: shortTermSeedItems,
|
||||
dedupeByQueryPerDay: true,
|
||||
|
||||
@@ -117,7 +117,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("memory cli", () => {
|
||||
const inactiveMemorySecretDiagnostic = "memory.search.remote.apiKey inactive"; // pragma: allowlist secret
|
||||
const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret
|
||||
|
||||
function firstMockCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown {
|
||||
const call = mock.mock.calls[0];
|
||||
@@ -426,52 +426,6 @@ describe("memory cli", () => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports dreaming status for each agent", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: { enabled: true, frequency: "0 9 * * *" },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/tmp/main",
|
||||
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: "/tmp/main" },
|
||||
{
|
||||
id: "writer",
|
||||
workspace: "/tmp/writer",
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const close = vi.fn(async () => {});
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(log, "Dreaming: 0 9 * * *");
|
||||
expectLogged(log, "Dreaming: off");
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("prints index identity mismatch reasons", async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
@@ -534,10 +488,12 @@ describe("memory cli", () => {
|
||||
|
||||
it("resolves configured memory SecretRefs through gateway snapshot", async () => {
|
||||
const config = {
|
||||
memory: {
|
||||
search: {
|
||||
remote: {
|
||||
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
remote: {
|
||||
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -560,8 +516,8 @@ describe("memory cli", () => {
|
||||
expect(secretRefsCall.commandName).toBe("memory status");
|
||||
expect(secretRefsCall.targetIds).toStrictEqual(
|
||||
new Set([
|
||||
"memory.search.remote.apiKey",
|
||||
"agents.list[].memory.search.remote.apiKey",
|
||||
"agents.defaults.memorySearch.remote.apiKey",
|
||||
"agents.list[].memorySearch.remote.apiKey",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -761,7 +717,6 @@ describe("memory cli", () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "router vlan",
|
||||
results: [
|
||||
{
|
||||
@@ -793,41 +748,33 @@ describe("memory cli", () => {
|
||||
|
||||
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await shortTermTesting.writeRawRecallStore(
|
||||
workspaceDir,
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
good: {
|
||||
key: "good",
|
||||
path: "memory/2026-04-03.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
source: "memory",
|
||||
snippet: "QMD router cache note",
|
||||
recallCount: 1,
|
||||
totalScore: 0.8,
|
||||
maxScore: 0.8,
|
||||
firstRecalledAt: "2026-04-04T00:00:00.000Z",
|
||||
lastRecalledAt: "2026-04-04T00:00:00.000Z",
|
||||
queryHashes: ["a"],
|
||||
},
|
||||
bad: {
|
||||
path: "",
|
||||
},
|
||||
await shortTermTesting.writeRawRecallStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
good: {
|
||||
key: "good",
|
||||
path: "memory/2026-04-03.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
source: "memory",
|
||||
snippet: "QMD router cache note",
|
||||
recallCount: 1,
|
||||
totalScore: 0.8,
|
||||
maxScore: 0.8,
|
||||
firstRecalledAt: "2026-04-04T00:00:00.000Z",
|
||||
lastRecalledAt: "2026-04-04T00:00:00.000Z",
|
||||
queryHashes: ["a"],
|
||||
},
|
||||
bad: {
|
||||
path: "",
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
await shortTermTesting.writeShortTermLock(
|
||||
workspaceDir,
|
||||
{
|
||||
owner: "999999:0",
|
||||
acquiredAt: Date.now() - 120_000,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
await shortTermTesting.writeShortTermLock(workspaceDir, {
|
||||
owner: "999999:0",
|
||||
acquiredAt: Date.now() - 120_000,
|
||||
});
|
||||
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
@@ -840,11 +787,7 @@ describe("memory cli", () => {
|
||||
await runMemoryCli(["status", "--fix"]);
|
||||
|
||||
expectLogged(log, "Repair: rewrote store");
|
||||
const audit = await shortTermTesting.readRecallStore(
|
||||
workspaceDir,
|
||||
new Date().toISOString(),
|
||||
"main",
|
||||
);
|
||||
const audit = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
|
||||
const repaired = audit as {
|
||||
entries: Record<string, { conceptTags?: string[] }>;
|
||||
};
|
||||
@@ -855,19 +798,15 @@ describe("memory cli", () => {
|
||||
|
||||
it("shows the fix hint only before --fix has been run", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await shortTermTesting.writeRawRecallStore(
|
||||
workspaceDir,
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
bad: {
|
||||
path: "",
|
||||
},
|
||||
await shortTermTesting.writeRawRecallStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
bad: {
|
||||
path: "",
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
@@ -893,14 +832,7 @@ describe("memory cli", () => {
|
||||
|
||||
it("repairs contaminated dreaming artifacts during status --fix", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const sessionCorpusDir = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"main",
|
||||
"session-corpus",
|
||||
);
|
||||
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
|
||||
await fs.mkdir(sessionCorpusDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionCorpusDir, "2026-04-11.txt"),
|
||||
@@ -910,16 +842,12 @@ describe("memory cli", () => {
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
const dreamsPath = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"main",
|
||||
"DREAMS.md",
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
|
||||
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
|
||||
await fs.writeFile(dreamsPath, "# Dream Diary\n", "utf-8");
|
||||
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "# Dream Diary\n", "utf-8");
|
||||
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
@@ -934,7 +862,12 @@ describe("memory cli", () => {
|
||||
expectLogged(log, "Dream repair: archived session corpus");
|
||||
expectLogged(log, "Dream archive:");
|
||||
await expectPathMissing(sessionCorpusDir);
|
||||
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary");
|
||||
await expectPathMissing(
|
||||
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
|
||||
);
|
||||
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
|
||||
"# Dream Diary",
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1354,7 +1287,6 @@ describe("memory cli", () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "router notes",
|
||||
results: [
|
||||
{
|
||||
@@ -1399,7 +1331,6 @@ describe("memory cli", () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "router notes",
|
||||
results: [
|
||||
{
|
||||
@@ -1440,7 +1371,6 @@ describe("memory cli", () => {
|
||||
);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "weather plans",
|
||||
nowMs,
|
||||
results: [
|
||||
@@ -1628,10 +1558,7 @@ describe("memory cli", () => {
|
||||
|
||||
await runMemoryCli(["rem-backfill", "--path", historyPath]);
|
||||
|
||||
const dreams = await fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
|
||||
"utf-8",
|
||||
);
|
||||
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
|
||||
expect(dreams).toContain("openclaw:dreaming:backfill-entry");
|
||||
expect(dreams).toContain(`source=${historyPath}`);
|
||||
expect(dreams).toContain("January 1, 2025");
|
||||
@@ -1675,10 +1602,7 @@ describe("memory cli", () => {
|
||||
expect(
|
||||
errors.mock.calls.some((call) => String(call[0]).includes("found no YYYY-MM-DD.md files")),
|
||||
).toBe(false);
|
||||
const dreams = await fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "agents", "main", "DREAMS.md"),
|
||||
"utf-8",
|
||||
);
|
||||
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
|
||||
expect(dreams).toContain(`source=${sluggedPath}`);
|
||||
expect(dreams).toContain(`source=${secondSluggedPath}`);
|
||||
expect(dreams).toContain("Happy Together");
|
||||
@@ -1727,7 +1651,7 @@ describe("memory cli", () => {
|
||||
|
||||
await runMemoryCli(["rem-backfill", "--path", historyPath, "--stage-short-term"]);
|
||||
|
||||
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
|
||||
const entries = await readShortTermRecallEntries({ workspaceDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.snippet).toContain("Happy Together");
|
||||
expect(entries[0]?.groundedCount).toBe(3);
|
||||
@@ -1764,7 +1688,7 @@ describe("memory cli", () => {
|
||||
});
|
||||
await runMemoryCli(["rem-backfill", "--rollback-short-term"]);
|
||||
|
||||
const entries = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
|
||||
const entries = await readShortTermRecallEntries({ workspaceDir });
|
||||
expect(entries).toHaveLength(0);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1972,15 +1896,7 @@ describe("memory cli", () => {
|
||||
|
||||
it("rolls back grounded rem backfill entries from DREAMS.md", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const dreamsPath = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"main",
|
||||
"DREAMS.md",
|
||||
);
|
||||
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
|
||||
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(
|
||||
dreamsPath,
|
||||
[
|
||||
@@ -2043,7 +1959,6 @@ describe("memory cli", () => {
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "network setup",
|
||||
results: [
|
||||
{
|
||||
@@ -2092,7 +2007,6 @@ describe("memory cli", () => {
|
||||
const nowMs = Date.now();
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "router vlan",
|
||||
nowMs: nowMs - 2 * dayMs,
|
||||
results: [
|
||||
@@ -2108,7 +2022,6 @@ describe("memory cli", () => {
|
||||
});
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
agentId: "main",
|
||||
query: "glacier backup",
|
||||
nowMs: nowMs - dayMs,
|
||||
results: [
|
||||
@@ -2171,11 +2084,13 @@ describe("memory cli", () => {
|
||||
},
|
||||
]);
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
memory: {
|
||||
extensions: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2190,7 +2105,7 @@ describe("memory cli", () => {
|
||||
await runMemoryCli(["search", "glacier", "--json"]);
|
||||
|
||||
const entries = await waitFor(async () => {
|
||||
const recalled = await readShortTermRecallEntries({ workspaceDir, agentId: "main" });
|
||||
const recalled = await readShortTermRecallEntries({ workspaceDir });
|
||||
expect(recalled).toHaveLength(1);
|
||||
return recalled;
|
||||
});
|
||||
@@ -2274,7 +2189,7 @@ describe("memory cli", () => {
|
||||
}
|
||||
expect(payload.results).toHaveLength(1);
|
||||
expect(payload.results[0]?.path).toBe("memory/2026-04-03.md");
|
||||
expect(await readShortTermRecallEntries({ workspaceDir, agentId: "main" })).toHaveLength(0);
|
||||
expect(await readShortTermRecallEntries({ workspaceDir })).toHaveLength(0);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// Memory Core tests cover canonical dreaming config migration.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { migrateMemoryCoreLegacyConfig } from "./config-compat.js";
|
||||
|
||||
describe("memory-core config compatibility", () => {
|
||||
it("moves dreaming from the selected non-core memory plugin", () => {
|
||||
const migration = migrateMemoryCoreLegacyConfig({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memory-lancedb",
|
||||
},
|
||||
entries: {
|
||||
"memory-lancedb": {
|
||||
config: {
|
||||
embedding: {
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
expect(migration?.changes).toEqual([
|
||||
"Moved plugins.entries.memory-lancedb.config.dreaming → memory.extensions.memory-core.dreaming.",
|
||||
]);
|
||||
expect(migration?.config.memory?.extensions?.["memory-core"]).toEqual({
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
});
|
||||
expect(migration?.config.plugins?.entries?.["memory-lancedb"]?.config).toEqual({
|
||||
embedding: {
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit canonical dreaming settings", () => {
|
||||
const migration = migrateMemoryCoreLegacyConfig({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memory-lancedb",
|
||||
},
|
||||
entries: {
|
||||
"memory-lancedb": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
expect(migration?.config.memory?.extensions?.["memory-core"]).toEqual({
|
||||
dreaming: {
|
||||
enabled: false,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("declares the selected memory slot as a compatibility migration trigger", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as {
|
||||
configContracts?: { compatibilityMigrationPaths?: string[] };
|
||||
};
|
||||
|
||||
expect(manifest.configContracts?.compatibilityMigrationPaths).toContain("plugins.slots.memory");
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
// Memory Core compatibility migration moves global dreaming settings into agent memory config.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const existing = asRecord(target[key]);
|
||||
const nested = asRecord(value);
|
||||
if (existing && nested) {
|
||||
mergeMissing(existing, nested);
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(target, key)) {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMemoryCoreExtension(memory: Record<string, unknown>): Record<string, unknown> {
|
||||
const extensions = asRecord(memory.extensions) ?? {};
|
||||
memory.extensions = extensions;
|
||||
const core = asRecord(extensions["memory-core"]) ?? {};
|
||||
extensions["memory-core"] = core;
|
||||
return core;
|
||||
}
|
||||
|
||||
function ensureRootMemory(config: OpenClawConfig): Record<string, unknown> {
|
||||
const memory = asRecord(config.memory) ?? {};
|
||||
config.memory = memory;
|
||||
return memory;
|
||||
}
|
||||
|
||||
function ensureAgentMemory(agent: Record<string, unknown>): Record<string, unknown> {
|
||||
const memory = asRecord(agent.memory) ?? {};
|
||||
agent.memory = memory;
|
||||
return memory;
|
||||
}
|
||||
|
||||
function normalizePluginId(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveSelectedMemoryPluginConfig(
|
||||
config: OpenClawConfig,
|
||||
): { pluginId: string; config: Record<string, unknown> } | undefined {
|
||||
const selectedId = config.plugins?.slots?.memory;
|
||||
if (
|
||||
typeof selectedId !== "string" ||
|
||||
!selectedId.trim() ||
|
||||
normalizePluginId(selectedId) === "none" ||
|
||||
normalizePluginId(selectedId) === "memory-core"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = asRecord(config.plugins?.entries);
|
||||
for (const [pluginId, rawEntry] of Object.entries(entries ?? {})) {
|
||||
if (normalizePluginId(pluginId) !== normalizePluginId(selectedId)) {
|
||||
continue;
|
||||
}
|
||||
const pluginConfig = asRecord(asRecord(rawEntry)?.config);
|
||||
if (pluginConfig) {
|
||||
return { pluginId, config: pluginConfig };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Moves legacy global dreaming config and agent dreaming flags to canonical memory extensions. */
|
||||
export function migrateMemoryCoreLegacyConfig(config: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} | null {
|
||||
const legacyPluginConfig = asRecord(config.plugins?.entries?.["memory-core"]?.config);
|
||||
const selectedMemoryPlugin = resolveSelectedMemoryPluginConfig(config);
|
||||
const selectedPluginDreaming = asRecord(selectedMemoryPlugin?.config.dreaming);
|
||||
const legacyAgentDreaming = (config.agents?.list ?? []).some(
|
||||
(agent) => asRecord(agent)?.dreaming !== undefined,
|
||||
);
|
||||
if (!legacyPluginConfig && !selectedPluginDreaming && !legacyAgentDreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = structuredClone(config);
|
||||
const changes: string[] = [];
|
||||
if (legacyPluginConfig) {
|
||||
const plugins = asRecord(next.plugins) ?? {};
|
||||
next.plugins = plugins;
|
||||
const entries = asRecord(plugins.entries) ?? {};
|
||||
plugins.entries = entries;
|
||||
const entry = asRecord(entries["memory-core"]) ?? {};
|
||||
entries["memory-core"] = entry;
|
||||
const pluginConfig = asRecord(entry.config) ?? {};
|
||||
const core = ensureMemoryCoreExtension(ensureRootMemory(next));
|
||||
if (Object.keys(core).length > 0) {
|
||||
mergeMissing(core, pluginConfig);
|
||||
changes.push(
|
||||
"Merged plugins.entries.memory-core.config → memory.extensions.memory-core (kept explicit memory settings).",
|
||||
);
|
||||
} else {
|
||||
Object.assign(core, pluginConfig);
|
||||
changes.push(
|
||||
"Moved plugins.entries.memory-core.config → memory.extensions.memory-core.",
|
||||
);
|
||||
}
|
||||
delete entry.config;
|
||||
}
|
||||
|
||||
if (selectedPluginDreaming && selectedMemoryPlugin) {
|
||||
const selectedPluginConfig = resolveSelectedMemoryPluginConfig(next)?.config;
|
||||
if (!selectedPluginConfig) {
|
||||
throw new Error(
|
||||
`Cannot migrate dreaming config: missing selected memory plugin "${selectedMemoryPlugin.pluginId}".`,
|
||||
);
|
||||
}
|
||||
const core = ensureMemoryCoreExtension(ensureRootMemory(next));
|
||||
const existingDreaming = asRecord(core.dreaming);
|
||||
if (existingDreaming) {
|
||||
mergeMissing(existingDreaming, selectedPluginDreaming);
|
||||
changes.push(
|
||||
`Merged plugins.entries.${selectedMemoryPlugin.pluginId}.config.dreaming → memory.extensions.memory-core.dreaming (kept explicit core dreaming settings).`,
|
||||
);
|
||||
} else {
|
||||
core.dreaming = selectedPluginDreaming;
|
||||
changes.push(
|
||||
`Moved plugins.entries.${selectedMemoryPlugin.pluginId}.config.dreaming → memory.extensions.memory-core.dreaming.`,
|
||||
);
|
||||
}
|
||||
delete selectedPluginConfig.dreaming;
|
||||
}
|
||||
|
||||
const agents = asRecord(next.agents);
|
||||
if (Array.isArray(agents?.list)) {
|
||||
for (const [index, rawAgent] of agents.list.entries()) {
|
||||
const agent = asRecord(rawAgent);
|
||||
const dreaming = asRecord(agent?.dreaming);
|
||||
if (!agent || !dreaming) {
|
||||
continue;
|
||||
}
|
||||
const core = ensureMemoryCoreExtension(ensureAgentMemory(agent));
|
||||
const existingDreaming = asRecord(core.dreaming);
|
||||
if (existingDreaming) {
|
||||
mergeMissing(existingDreaming, dreaming);
|
||||
} else {
|
||||
core.dreaming = dreaming;
|
||||
}
|
||||
delete agent.dreaming;
|
||||
changes.push(
|
||||
`Moved agents.list.${index}.dreaming → agents.list.${index}.memory.extensions.memory-core.dreaming.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, changes };
|
||||
}
|
||||
@@ -16,21 +16,9 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
function resolveStoredDreaming(config: OpenClawConfig): Record<string, unknown> {
|
||||
const memory = asRecord(config.memory);
|
||||
const extensions = asRecord(memory?.extensions);
|
||||
const memoryCore = asRecord(extensions?.["memory-core"]);
|
||||
return asRecord(memoryCore?.dreaming) ?? {};
|
||||
}
|
||||
|
||||
function resolveAgentStoredDreaming(
|
||||
config: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Record<string, unknown> {
|
||||
const agent = config.agents?.list?.find((entry) => entry.id === agentId);
|
||||
const memory = asRecord(agent?.memory);
|
||||
const extensions = asRecord(memory?.extensions);
|
||||
const memoryCore = asRecord(extensions?.["memory-core"]);
|
||||
return asRecord(memoryCore?.dreaming) ?? {};
|
||||
const entry = asRecord(config.plugins?.entries?.["memory-core"]);
|
||||
const pluginConfig = asRecord(entry?.config);
|
||||
return asRecord(pluginConfig?.dreaming) ?? {};
|
||||
}
|
||||
|
||||
function createHarness(initialConfig: OpenClawConfig = {}) {
|
||||
@@ -87,7 +75,7 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
|
||||
|
||||
function createCommandContext(
|
||||
args?: string,
|
||||
overrides?: Partial<Pick<PluginCommandContext, "agentId" | "gatewayClientScopes" | "sessionKey">>,
|
||||
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
|
||||
): PluginCommandContext {
|
||||
return {
|
||||
channel: "webchat",
|
||||
@@ -95,9 +83,7 @@ function createCommandContext(
|
||||
commandBody: args ? `/dreaming ${args}` : "/dreaming",
|
||||
args,
|
||||
config: {},
|
||||
agentId: overrides?.agentId,
|
||||
gatewayClientScopes: overrides?.gatewayClientScopes,
|
||||
sessionKey: overrides?.sessionKey,
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
@@ -124,18 +110,20 @@ describe("memory-core /dreaming command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists default-agent enablement under memory.extensions.memory-core", async () => {
|
||||
it("persists global enablement under plugins.entries.memory-core.config.dreaming.enabled", async () => {
|
||||
const { command, runtime, getRuntimeConfig } = createHarness({
|
||||
memory: {
|
||||
extensions: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
phases: {
|
||||
deep: {
|
||||
minScore: 0.9,
|
||||
config: {
|
||||
dreaming: {
|
||||
phases: {
|
||||
deep: {
|
||||
minScore: 0.9,
|
||||
},
|
||||
},
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -151,82 +139,6 @@ describe("memory-core /dreaming command", () => {
|
||||
expect(result.text).toContain("Dreaming disabled.");
|
||||
});
|
||||
|
||||
it("uses the host-routed agent when the session key does not encode one", async () => {
|
||||
const { command, getRuntimeConfig } = createHarness({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "research" }],
|
||||
},
|
||||
});
|
||||
|
||||
await command.handler(
|
||||
createCommandContext("off", {
|
||||
agentId: "research",
|
||||
sessionKey: "plugin-owned:command-session",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
|
||||
expect(resolveAgentStoredDreaming(getRuntimeConfig(), "research").enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("matches host-routed canonical agent ids to raw configured ids", async () => {
|
||||
const { command, getRuntimeConfig } = createHarness({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "Team Ops" }],
|
||||
},
|
||||
});
|
||||
|
||||
await command.handler(
|
||||
createCommandContext("off", {
|
||||
agentId: "team-ops",
|
||||
sessionKey: "plugin-owned:command-session",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
|
||||
expect(resolveAgentStoredDreaming(getRuntimeConfig(), "Team Ops").enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown routed agents without changing inherited defaults", async () => {
|
||||
const { command, runtime, getRuntimeConfig } = createHarness({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "research" }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command.handler(
|
||||
createCommandContext("off", {
|
||||
agentId: "writer",
|
||||
sessionKey: "plugin-owned:command-session",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toContain('cannot be changed for unknown agent "writer"');
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks unscoped gateway callers from persisting dreaming config", async () => {
|
||||
const { command, runtime } = createHarness();
|
||||
|
||||
@@ -269,11 +181,13 @@ describe("memory-core /dreaming command", () => {
|
||||
|
||||
it("returns status without mutating config", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
memory: {
|
||||
extensions: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
frequency: "15 */8 * * *",
|
||||
config: {
|
||||
dreaming: {
|
||||
frequency: "15 */8 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -281,7 +195,6 @@ describe("memory-core /dreaming command", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Los_Angeles",
|
||||
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,53 +1,38 @@
|
||||
// Memory Core plugin module implements dreaming command behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { asRecord } from "./dreaming-shared.js";
|
||||
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
|
||||
|
||||
function updateDreamingEnabledInConfig(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
enabled: boolean,
|
||||
): OpenClawConfig | null {
|
||||
const agentList = [...(cfg.agents?.list ?? [])];
|
||||
const agentIndex = agentList.findIndex(
|
||||
(entry) => normalizeAgentId(entry?.id) === normalizeAgentId(agentId),
|
||||
);
|
||||
const isDefaultAgent = normalizeAgentId(agentId) === normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
if (agentIndex < 0 && (agentList.length > 0 || !isDefaultAgent)) {
|
||||
return null;
|
||||
}
|
||||
const existingAgentMemory =
|
||||
agentIndex >= 0 ? (agentList[agentIndex]?.memory ?? {}) : (cfg.memory ?? {});
|
||||
const extensions = { ...existingAgentMemory.extensions };
|
||||
const memoryCore = asRecord(extensions["memory-core"]) ?? {};
|
||||
const dreaming = asRecord(memoryCore.dreaming) ?? {};
|
||||
extensions["memory-core"] = {
|
||||
...memoryCore,
|
||||
dreaming: {
|
||||
...dreaming,
|
||||
enabled,
|
||||
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
||||
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
||||
return asRecord(entry?.config) ?? {};
|
||||
}
|
||||
|
||||
function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
|
||||
const entries = { ...cfg.plugins?.entries };
|
||||
const existingEntry = asRecord(entries["memory-core"]) ?? {};
|
||||
const existingConfig = asRecord(existingEntry.config) ?? {};
|
||||
const existingSleep = asRecord(existingConfig.dreaming) ?? {};
|
||||
entries["memory-core"] = {
|
||||
...existingEntry,
|
||||
config: {
|
||||
...existingConfig,
|
||||
dreaming: {
|
||||
...existingSleep,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
const memory = { ...existingAgentMemory, extensions };
|
||||
|
||||
if (agentIndex >= 0) {
|
||||
agentList[agentIndex] = { ...agentList[agentIndex], memory };
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
list: agentList,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
memory,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,12 +48,13 @@ function formatPhaseGuide(): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatStatus(cfg: OpenClawConfig, agentId: string): string {
|
||||
function formatStatus(cfg: OpenClawConfig): string {
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const dreaming = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
const deep = resolveShortTermPromotionDreamingConfig({ cfg, agentId });
|
||||
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
|
||||
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
|
||||
|
||||
return [
|
||||
@@ -102,18 +88,13 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
|
||||
.filter(Boolean)
|
||||
.map((token) => normalizeLowercaseStringOrEmpty(token));
|
||||
const currentConfig = api.runtime.config.current() as OpenClawConfig;
|
||||
const agentId = normalizeAgentId(
|
||||
ctx.agentId ??
|
||||
parseAgentSessionKey(ctx.sessionKey)?.agentId ??
|
||||
resolveDefaultAgentId(currentConfig),
|
||||
);
|
||||
|
||||
if (!firstToken || firstToken === "help" || firstToken === "options" || firstToken === "phases") {
|
||||
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
|
||||
return { text: formatUsage(formatStatus(currentConfig)) };
|
||||
}
|
||||
|
||||
if (firstToken === "status") {
|
||||
return { text: formatStatus(currentConfig, agentId) };
|
||||
return { text: formatStatus(currentConfig) };
|
||||
}
|
||||
|
||||
if (firstToken === "on" || firstToken === "off") {
|
||||
@@ -121,16 +102,10 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
|
||||
return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." };
|
||||
}
|
||||
const enabled = firstToken === "on";
|
||||
if (!updateDreamingEnabledInConfig(currentConfig, agentId, enabled)) {
|
||||
return { text: `Dreaming config cannot be changed for unknown agent "${agentId}".` };
|
||||
}
|
||||
const committed = await api.runtime.config.mutateConfigFile({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: (draft) => {
|
||||
const nextConfig = updateDreamingEnabledInConfig(draft, agentId, enabled);
|
||||
if (!nextConfig) {
|
||||
throw new Error(`Dreaming config target disappeared: ${agentId}`);
|
||||
}
|
||||
const nextConfig = updateDreamingEnabledInConfig(draft, enabled);
|
||||
Object.assign(draft, nextConfig);
|
||||
},
|
||||
});
|
||||
@@ -138,12 +113,12 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
|
||||
text: [
|
||||
`Dreaming ${enabled ? "enabled" : "disabled"}.`,
|
||||
"",
|
||||
formatStatus(committed.nextConfig, agentId),
|
||||
formatStatus(committed.nextConfig),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { text: formatUsage(formatStatus(currentConfig, agentId)) };
|
||||
return { text: formatUsage(formatStatus(currentConfig)) };
|
||||
}
|
||||
|
||||
export function registerDreamingCommand(api: OpenClawPluginApi): void {
|
||||
|
||||
@@ -5,12 +5,7 @@ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
|
||||
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
|
||||
import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
assertNoSymlinkParents,
|
||||
readRegularFile,
|
||||
replaceFileAtomic,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
|
||||
const DEEP_START_MARKER = "<!-- openclaw:dreaming:deep:start -->";
|
||||
@@ -24,17 +19,7 @@ type DreamsFileLockEntry = {
|
||||
|
||||
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
|
||||
|
||||
export async function resolveDreamsPath(workspaceDir: string, agentId?: string): Promise<string> {
|
||||
if (agentId?.trim()) {
|
||||
return path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
normalizeAgentId(agentId),
|
||||
DREAMS_FILENAMES[0],
|
||||
);
|
||||
}
|
||||
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
|
||||
for (const name of DREAMS_FILENAMES) {
|
||||
const target = path.join(workspaceDir, name);
|
||||
try {
|
||||
@@ -94,66 +79,7 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeDreamingArtifactPath(filePath: string): Promise<void> {
|
||||
const stat = await fs.lstat(filePath).catch((err: unknown) => {
|
||||
if (extractErrorCode(err) === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (!stat) {
|
||||
return;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Refusing to write symlinked dreaming artifact");
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Refusing to write non-file dreaming artifact");
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDreamingArtifactDirectory(params: {
|
||||
workspaceDir: string;
|
||||
filePath: string;
|
||||
}): Promise<void> {
|
||||
const directoryPath = path.dirname(params.filePath);
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: params.workspaceDir,
|
||||
targetPath: directoryPath,
|
||||
requireDirectories: true,
|
||||
});
|
||||
await fs.mkdir(directoryPath, { recursive: true });
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: params.workspaceDir,
|
||||
targetPath: directoryPath,
|
||||
allowMissing: false,
|
||||
requireDirectories: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDreamingArtifactFile(params: {
|
||||
workspaceDir: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
await ensureDreamingArtifactDirectory(params);
|
||||
await assertSafeDreamingArtifactPath(params.filePath);
|
||||
await replaceFileAtomic({
|
||||
filePath: params.filePath,
|
||||
content: params.content,
|
||||
mode: 0o600,
|
||||
preserveExistingMode: true,
|
||||
tempPrefix: `${path.basename(params.filePath)}.dreams`,
|
||||
throwOnCleanupError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeDreamsFileAtomic(
|
||||
workspaceDir: string,
|
||||
dreamsPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
await ensureDreamingArtifactDirectory({ workspaceDir, filePath: dreamsPath });
|
||||
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
|
||||
await assertSafeDreamsPath(dreamsPath);
|
||||
await replaceFileAtomic({
|
||||
filePath: dreamsPath,
|
||||
@@ -167,7 +93,6 @@ async function writeDreamsFileAtomic(
|
||||
|
||||
export async function updateDreamsFile<T>(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
updater: (
|
||||
existing: string,
|
||||
dreamsPath: string,
|
||||
@@ -179,7 +104,8 @@ export async function updateDreamsFile<T>(params: {
|
||||
shouldWrite?: boolean;
|
||||
};
|
||||
}): Promise<T> {
|
||||
const dreamsPath = await resolveDreamsPath(params.workspaceDir, params.agentId);
|
||||
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
|
||||
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
|
||||
let lockEntry = dreamsFileLocks.get(dreamsPath);
|
||||
if (!lockEntry) {
|
||||
lockEntry = { withLock: createAsyncLock(), refs: 0 };
|
||||
@@ -188,18 +114,10 @@ export async function updateDreamsFile<T>(params: {
|
||||
lockEntry.refs += 1;
|
||||
try {
|
||||
return await lockEntry.withLock(async () => {
|
||||
await ensureDreamingArtifactDirectory({
|
||||
workspaceDir: params.workspaceDir,
|
||||
filePath: dreamsPath,
|
||||
});
|
||||
const existing = await readDreamsFile(dreamsPath);
|
||||
const { content, result, shouldWrite = true } = await params.updater(existing, dreamsPath);
|
||||
if (shouldWrite) {
|
||||
await writeDreamsFileAtomic(
|
||||
params.workspaceDir,
|
||||
dreamsPath,
|
||||
content.endsWith("\n") ? content : `${content}\n`,
|
||||
);
|
||||
await writeDreamsFileAtomic(dreamsPath, content.endsWith("\n") ? content : `${content}\n`);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -213,13 +131,11 @@ export async function updateDreamsFile<T>(params: {
|
||||
|
||||
export async function updateDeepDreamsFile(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
bodyLines: string[];
|
||||
}): Promise<string> {
|
||||
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
|
||||
return await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing, dreamsPath) => ({
|
||||
content: replaceManagedMarkdownBlock({
|
||||
original: existing,
|
||||
|
||||
@@ -273,51 +273,4 @@ describe("dreaming markdown storage", () => {
|
||||
).rejects.toThrow("Refusing to write symlinked DREAMS.md");
|
||||
await expect(fs.readFile(targetPath, "utf-8")).resolves.toBe("outside\n");
|
||||
});
|
||||
|
||||
it("refuses a symlinked parent for agent dreams files", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
|
||||
const outsideDir = path.join(workspaceDir, "outside");
|
||||
await fs.mkdir(outsideDir);
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"));
|
||||
await fs.symlink(outsideDir, path.join(workspaceDir, "memory", ".dreams"));
|
||||
|
||||
await expect(
|
||||
writeDeepDreamingReport({
|
||||
workspaceDir,
|
||||
agentId: "writer",
|
||||
bodyLines: ["- Do not escape workspace."],
|
||||
storage: {
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
nowMs,
|
||||
timezone,
|
||||
}),
|
||||
).rejects.toThrow("must not traverse symlinked directory");
|
||||
await expect(fs.access(path.join(outsideDir, "agents"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("refuses a symlinked parent for agent dreaming reports", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
|
||||
const outsideDir = path.join(workspaceDir, "outside");
|
||||
await fs.mkdir(outsideDir);
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"));
|
||||
await fs.symlink(outsideDir, path.join(workspaceDir, "memory", ".dreams"));
|
||||
|
||||
await expect(
|
||||
writeDailyDreamingPhaseBlock({
|
||||
workspaceDir,
|
||||
agentId: "writer",
|
||||
phase: "light",
|
||||
bodyLines: ["- Do not escape workspace."],
|
||||
storage: {
|
||||
mode: "separate",
|
||||
separateReports: false,
|
||||
},
|
||||
nowMs,
|
||||
timezone,
|
||||
}),
|
||||
).rejects.toThrow("must not traverse symlinked directory");
|
||||
await expect(fs.access(path.join(outsideDir, "agents"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
replaceManagedMarkdownBlock,
|
||||
withTrailingNewline,
|
||||
} from "openclaw/plugin-sdk/memory-host-markdown";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
ensureDreamingArtifactDirectory,
|
||||
updateDeepDreamsFile,
|
||||
writeDreamingArtifactFile,
|
||||
} from "./dreaming-dreams-file.js";
|
||||
import { updateDeepDreamsFile } from "./dreaming-dreams-file.js";
|
||||
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
|
||||
|
||||
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
|
||||
@@ -40,24 +35,8 @@ function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDailyMemoryPath(
|
||||
workspaceDir: string,
|
||||
epochMs: number,
|
||||
timezone?: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
|
||||
const isoDay = formatMemoryDreamingDay(epochMs, timezone);
|
||||
if (agentId?.trim()) {
|
||||
return path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
normalizeAgentId(agentId),
|
||||
"daily",
|
||||
`${isoDay}.md`,
|
||||
);
|
||||
}
|
||||
return path.join(workspaceDir, "memory", `${isoDay}.md`);
|
||||
}
|
||||
|
||||
@@ -66,21 +45,8 @@ function resolveSeparateReportPath(
|
||||
phase: MemoryDreamingPhaseName,
|
||||
epochMs: number,
|
||||
timezone?: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const isoDay = formatMemoryDreamingDay(epochMs, timezone);
|
||||
if (agentId?.trim()) {
|
||||
return path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
normalizeAgentId(agentId),
|
||||
"reports",
|
||||
phase,
|
||||
`${isoDay}.md`,
|
||||
);
|
||||
}
|
||||
return path.join(workspaceDir, "memory", "dreaming", phase, `${isoDay}.md`);
|
||||
}
|
||||
|
||||
@@ -94,7 +60,6 @@ function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean {
|
||||
|
||||
export async function writeDailyDreamingPhaseBlock(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
phase: Exclude<MemoryDreamingPhaseName, "deep">;
|
||||
bodyLines: string[];
|
||||
nowMs?: number;
|
||||
@@ -107,16 +72,8 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
let reportPath: string | undefined;
|
||||
|
||||
if (shouldWriteInline(params.storage)) {
|
||||
inlinePath = resolveDailyMemoryPath(
|
||||
params.workspaceDir,
|
||||
nowMs,
|
||||
params.timezone,
|
||||
params.agentId,
|
||||
);
|
||||
await ensureDreamingArtifactDirectory({
|
||||
workspaceDir: params.workspaceDir,
|
||||
filePath: inlinePath,
|
||||
});
|
||||
inlinePath = resolveDailyMemoryPath(params.workspaceDir, nowMs, params.timezone);
|
||||
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
|
||||
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return "";
|
||||
@@ -131,11 +88,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
endMarker: markers.end,
|
||||
body,
|
||||
});
|
||||
await writeDreamingArtifactFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
filePath: inlinePath,
|
||||
content: withTrailingNewline(updated),
|
||||
});
|
||||
await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
|
||||
}
|
||||
|
||||
if (shouldWriteSeparate(params.storage)) {
|
||||
@@ -144,34 +97,26 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
params.phase,
|
||||
nowMs,
|
||||
params.timezone,
|
||||
params.agentId,
|
||||
);
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
const report = [
|
||||
`# ${params.phase === "light" ? "Light Sleep" : "REM Sleep"}`,
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
].join("\n");
|
||||
await writeDreamingArtifactFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
filePath: reportPath,
|
||||
content: report,
|
||||
});
|
||||
await fs.writeFile(reportPath, report, "utf-8");
|
||||
}
|
||||
|
||||
await appendMemoryHostEvent(
|
||||
params.workspaceDir,
|
||||
{
|
||||
type: "memory.dream.completed",
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: params.phase,
|
||||
...(inlinePath ? { inlinePath } : {}),
|
||||
...(reportPath ? { reportPath } : {}),
|
||||
lineCount: params.bodyLines.length,
|
||||
storageMode: params.storage.mode,
|
||||
},
|
||||
params.agentId,
|
||||
);
|
||||
await appendMemoryHostEvent(params.workspaceDir, {
|
||||
type: "memory.dream.completed",
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: params.phase,
|
||||
...(inlinePath ? { inlinePath } : {}),
|
||||
...(reportPath ? { reportPath } : {}),
|
||||
lineCount: params.bodyLines.length,
|
||||
storageMode: params.storage.mode,
|
||||
});
|
||||
|
||||
return {
|
||||
...(inlinePath ? { inlinePath } : {}),
|
||||
@@ -181,7 +126,6 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
|
||||
export async function writeDeepDreamingReport(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
bodyLines: string[];
|
||||
nowMs?: number;
|
||||
timezone?: string;
|
||||
@@ -191,36 +135,22 @@ export async function writeDeepDreamingReport(params: {
|
||||
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
|
||||
const inlinePath = await updateDeepDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
bodyLines: params.bodyLines,
|
||||
});
|
||||
let reportPath: string | undefined;
|
||||
if (shouldWriteSeparate(params.storage)) {
|
||||
reportPath = resolveSeparateReportPath(
|
||||
params.workspaceDir,
|
||||
"deep",
|
||||
nowMs,
|
||||
params.timezone,
|
||||
params.agentId,
|
||||
);
|
||||
await writeDreamingArtifactFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
filePath: reportPath,
|
||||
content: `# Deep Sleep\n\n${body}\n`,
|
||||
});
|
||||
reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
|
||||
}
|
||||
await appendMemoryHostEvent(
|
||||
params.workspaceDir,
|
||||
{
|
||||
type: "memory.dream.completed",
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: "deep",
|
||||
inlinePath,
|
||||
...(reportPath ? { reportPath } : {}),
|
||||
lineCount: params.bodyLines.length,
|
||||
storageMode: params.storage.mode,
|
||||
},
|
||||
params.agentId,
|
||||
);
|
||||
await appendMemoryHostEvent(params.workspaceDir, {
|
||||
type: "memory.dream.completed",
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: "deep",
|
||||
inlinePath,
|
||||
...(reportPath ? { reportPath } : {}),
|
||||
lineCount: params.bodyLines.length,
|
||||
storageMode: params.storage.mode,
|
||||
});
|
||||
return reportPath;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,6 @@ function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string
|
||||
|
||||
export async function appendFallbackNarrativeEntry(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
data: NarrativePhaseData;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
@@ -172,7 +171,6 @@ export async function appendFallbackNarrativeEntry(params: {
|
||||
try {
|
||||
await appendNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
narrative: buildRequestScopedFallbackNarrative(params.data),
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
@@ -243,7 +241,6 @@ async function startNarrativeRunOrFallback(params: {
|
||||
message: string;
|
||||
data: NarrativePhaseData;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
model?: string;
|
||||
@@ -267,7 +264,6 @@ async function startNarrativeRunOrFallback(params: {
|
||||
}
|
||||
await appendFallbackNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data: params.data,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
@@ -283,13 +279,9 @@ async function startNarrativeRunOrFallback(params: {
|
||||
*/
|
||||
function buildNarrativeSessionKey(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
phase: NarrativePhaseData["phase"];
|
||||
}): string {
|
||||
const scope = params.agentId?.trim()
|
||||
? `${params.workspaceDir}\0${params.agentId.trim()}`
|
||||
: params.workspaceDir;
|
||||
const workspaceHash = createHash("sha1").update(scope).digest("hex").slice(0, 12);
|
||||
const workspaceHash = createHash("sha1").update(params.workspaceDir).digest("hex").slice(0, 12);
|
||||
return `dreaming-narrative-${params.phase}-${workspaceHash}`;
|
||||
}
|
||||
|
||||
@@ -511,7 +503,6 @@ function isOptionalDiaryContextReadError(err: unknown): boolean {
|
||||
|
||||
export async function readRecentDreamDiaryEntries(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
}): Promise<string[]> {
|
||||
const limit = Math.max(0, Math.floor(params.limit ?? RECENT_DIARY_CONTEXT_LIMIT));
|
||||
@@ -520,7 +511,7 @@ export async function readRecentDreamDiaryEntries(params: {
|
||||
}
|
||||
let existing: string;
|
||||
try {
|
||||
const dreamsPath = await resolveDreamsPath(params.workspaceDir, params.agentId);
|
||||
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
|
||||
existing = await readDreamsFile(dreamsPath);
|
||||
} catch (err) {
|
||||
if (isOptionalDiaryContextReadError(err)) {
|
||||
@@ -647,7 +638,6 @@ export function buildBackfillDiaryEntry(params: {
|
||||
|
||||
export async function writeBackfillDiaryEntries(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
entries: Array<{
|
||||
isoDay: string;
|
||||
bodyLines: string[];
|
||||
@@ -657,7 +647,6 @@ export async function writeBackfillDiaryEntries(params: {
|
||||
}): Promise<{ dreamsPath: string; written: number; replaced: number }> {
|
||||
return await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing, dreamsPath) => {
|
||||
const stripped = stripBackfillDiaryBlocks(existing);
|
||||
const startIdx = stripped.updated.indexOf(DIARY_START_MARKER);
|
||||
@@ -692,11 +681,9 @@ export async function writeBackfillDiaryEntries(params: {
|
||||
|
||||
export async function removeBackfillDiaryEntries(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<{ dreamsPath: string; removed: number }> {
|
||||
return await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing, dreamsPath) => {
|
||||
const stripped = stripBackfillDiaryBlocks(existing);
|
||||
return {
|
||||
@@ -713,11 +700,9 @@ export async function removeBackfillDiaryEntries(params: {
|
||||
|
||||
export async function dedupeDreamDiaryEntries(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<{ dreamsPath: string; removed: number; kept: number }> {
|
||||
return await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing, dreamsPath) => {
|
||||
const ensured = ensureDiarySection(existing);
|
||||
const startIdx = ensured.indexOf(DIARY_START_MARKER);
|
||||
@@ -762,7 +747,6 @@ export function buildDiaryEntry(narrative: string, dateStr: string): string {
|
||||
|
||||
export async function appendNarrativeEntry(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
narrative: string;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
@@ -771,7 +755,6 @@ export async function appendNarrativeEntry(params: {
|
||||
const entry = buildDiaryEntry(params.narrative, dateStr);
|
||||
return await updateDreamsFile({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
updater: (existing, dreamsPath) => {
|
||||
let updated: string;
|
||||
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
|
||||
@@ -1007,7 +990,6 @@ async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
|
||||
export async function generateAndAppendDreamNarrative(params: {
|
||||
subagent: SubagentSurface;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
data: NarrativePhaseData;
|
||||
nowMs?: number;
|
||||
timezone?: string;
|
||||
@@ -1022,7 +1004,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
|
||||
const sessionKey = buildNarrativeSessionKey({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
phase: params.data.phase,
|
||||
});
|
||||
const message = buildNarrativePrompt(params.data);
|
||||
@@ -1055,7 +1036,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
message,
|
||||
data: params.data,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
nowMs,
|
||||
timezone: params.timezone,
|
||||
model: attemptModel,
|
||||
@@ -1098,7 +1078,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
);
|
||||
await appendFallbackNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data: params.data,
|
||||
nowMs,
|
||||
timezone: params.timezone,
|
||||
@@ -1134,7 +1113,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
);
|
||||
await appendFallbackNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data: params.data,
|
||||
nowMs,
|
||||
timezone: params.timezone,
|
||||
@@ -1146,7 +1124,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
|
||||
await appendNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
narrative,
|
||||
nowMs,
|
||||
timezone: params.timezone,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "./dreaming-phases.js";
|
||||
import { previewRemHarness } from "./rem-harness.js";
|
||||
import {
|
||||
readShortTermRecallEntries,
|
||||
rankShortTermPromotionCandidates,
|
||||
recordShortTermRecalls,
|
||||
testing as shortTermTesting,
|
||||
@@ -118,33 +117,6 @@ function requireFirstIngestionEntry(sessionIngestion: {
|
||||
return firstEntry;
|
||||
}
|
||||
|
||||
function normalizeDreamingTestConfig(config: OpenClawConfig): OpenClawConfig {
|
||||
const canonical = resolveMemoryCorePluginConfig(config);
|
||||
if (canonical) {
|
||||
return config;
|
||||
}
|
||||
const legacy = config.plugins?.entries?.["memory-core"]?.config;
|
||||
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
memory: {
|
||||
...config.memory,
|
||||
extensions: {
|
||||
...config.memory?.extensions,
|
||||
"memory-core": legacy,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
config: OpenClawConfig,
|
||||
workspaceDir?: string,
|
||||
@@ -155,39 +127,35 @@ function createHarness(
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const canonicalConfig = normalizeDreamingTestConfig(config);
|
||||
|
||||
const resolvedConfig = workspaceDir
|
||||
? {
|
||||
...canonicalConfig,
|
||||
...config,
|
||||
agents: {
|
||||
...canonicalConfig.agents,
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...canonicalConfig.agents?.defaults,
|
||||
...config.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
|
||||
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
...canonicalConfig,
|
||||
...config,
|
||||
agents: {
|
||||
...canonicalConfig.agents,
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...canonicalConfig.agents?.defaults,
|
||||
userTimezone: canonicalConfig.agents?.defaults?.userTimezone ?? "UTC",
|
||||
...config.agents?.defaults,
|
||||
userTimezone: config.agents?.defaults?.userTimezone ?? "UTC",
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedPluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
|
||||
const beforeAgentReply = async (
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
) => {
|
||||
const light = resolveMemoryLightDreamingConfig({
|
||||
pluginConfig: resolvedPluginConfig,
|
||||
cfg: resolvedConfig,
|
||||
});
|
||||
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: resolvedConfig });
|
||||
const lightResult = await testing.runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
@@ -202,10 +170,7 @@ function createHarness(
|
||||
if (lightResult) {
|
||||
return lightResult;
|
||||
}
|
||||
const rem = resolveMemoryRemDreamingConfig({
|
||||
pluginConfig: resolvedPluginConfig,
|
||||
cfg: resolvedConfig,
|
||||
});
|
||||
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: resolvedConfig });
|
||||
return await testing.runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
@@ -358,12 +323,11 @@ describe("memory-core dreaming phases", () => {
|
||||
const nowMs = Date.parse("2026-04-05T10:05:00.000Z");
|
||||
const workspaceHash = createHash("sha1").update(workspaceDir).digest("hex").slice(0, 12);
|
||||
const expectedSessionKey = `dreaming-narrative-light-${workspaceHash}`;
|
||||
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
|
||||
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: canonicalTestConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs,
|
||||
@@ -425,13 +389,12 @@ describe("memory-core dreaming phases", () => {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
|
||||
|
||||
await expect(
|
||||
runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: canonicalTestConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
|
||||
@@ -596,12 +559,11 @@ describe("memory-core dreaming phases", () => {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const canonicalTestConfig = normalizeDreamingTestConfig(testConfig);
|
||||
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: canonicalTestConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(canonicalTestConfig),
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs,
|
||||
@@ -1086,168 +1048,6 @@ describe("memory-core dreaming phases", () => {
|
||||
expectIncludesSubstring(snippets, "Set retention to 365 days.");
|
||||
});
|
||||
|
||||
it("limits an agent-scoped sweep to that agent's sessions in a shared workspace", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
|
||||
const researchSessionsDir = resolveSessionTranscriptsDirForAgent("research");
|
||||
const writerSessionsDir = resolveSessionTranscriptsDirForAgent("writer");
|
||||
await fs.mkdir(researchSessionsDir, { recursive: true });
|
||||
await fs.mkdir(writerSessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(researchSessionsDir, "research-session.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: "2026-04-05T18:01:00.000Z",
|
||||
content: [{ type: "text", text: "Research session stays scoped to research." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(writerSessionsDir, "writer-session.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: "2026-04-05T18:02:00.000Z",
|
||||
content: [{ type: "text", text: "Writer session must not enter research dreams." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
const config: OpenClawConfig = {
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
timezone: "UTC",
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 20,
|
||||
lookbackDays: 7,
|
||||
},
|
||||
rem: {
|
||||
enabled: false,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
userTimezone: "UTC",
|
||||
},
|
||||
list: [
|
||||
{ id: "research", workspace: workspaceDir },
|
||||
{ id: "writer", workspace: workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
try {
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: config,
|
||||
agentId: "research",
|
||||
pluginConfig: resolveMemoryCorePluginConfig(config, "research"),
|
||||
logger,
|
||||
nowMs: Date.parse("2026-04-05T19:00:00.000Z"),
|
||||
});
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: config,
|
||||
agentId: "writer",
|
||||
pluginConfig: resolveMemoryCorePluginConfig(config, "writer"),
|
||||
logger,
|
||||
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
const researchCorpus = await fs.readFile(
|
||||
path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"research",
|
||||
"session-corpus",
|
||||
"2026-04-05.txt",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
const writerCorpus = await fs.readFile(
|
||||
path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"writer",
|
||||
"session-corpus",
|
||||
"2026-04-05.txt",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
expect(researchCorpus).toContain("Research session stays scoped to research.");
|
||||
expect(researchCorpus).not.toContain("Writer session must not enter research dreams.");
|
||||
expect(writerCorpus).toContain("Writer session must not enter research dreams.");
|
||||
expect(writerCorpus).not.toContain("Research session stays scoped to research.");
|
||||
const researchIngestion = await testing.readSessionIngestionState(workspaceDir, "research");
|
||||
expect(Object.keys(researchIngestion.files)).toEqual([
|
||||
"research:sessions/research/research-session.jsonl",
|
||||
]);
|
||||
const writerIngestion = await testing.readSessionIngestionState(workspaceDir, "writer");
|
||||
expect(Object.keys(writerIngestion.files)).toEqual([
|
||||
"writer:sessions/writer/writer-session.jsonl",
|
||||
]);
|
||||
const [researchRecalls, writerRecalls] = await Promise.all([
|
||||
readShortTermRecallEntries({
|
||||
workspaceDir,
|
||||
agentId: "research",
|
||||
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
|
||||
}),
|
||||
readShortTermRecallEntries({
|
||||
workspaceDir,
|
||||
agentId: "writer",
|
||||
nowMs: Date.parse("2026-04-05T19:01:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
expectIncludesSubstring(
|
||||
researchRecalls.map((entry) => entry.snippet),
|
||||
"Research session stays scoped to research.",
|
||||
);
|
||||
expectNotIncludesSubstring(
|
||||
researchRecalls.map((entry) => entry.snippet),
|
||||
"Writer session must not enter research dreams.",
|
||||
);
|
||||
expectIncludesSubstring(
|
||||
writerRecalls.map((entry) => entry.snippet),
|
||||
"Writer session must not enter research dreams.",
|
||||
);
|
||||
expectNotIncludesSubstring(
|
||||
writerRecalls.map((entry) => entry.snippet),
|
||||
"Research session stays scoped to research.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
const subagentWorkspaceDir = await createDreamingWorkspace();
|
||||
@@ -1293,10 +1093,7 @@ describe("memory-core dreaming phases", () => {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: workspaceDir },
|
||||
{ id: "agi-ceo", workspace: subagentWorkspaceDir },
|
||||
],
|
||||
list: [{ id: "agi-ceo", workspace: subagentWorkspaceDir }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -2472,14 +2269,12 @@ describe("memory-core dreaming phases", () => {
|
||||
|
||||
const { beforeAgentReply } = createHarness(
|
||||
{
|
||||
memory: {
|
||||
search: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
resolveMemoryRemDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
|
||||
@@ -104,6 +103,7 @@ export const SESSION_INGESTION_STATE_RELATIVE_PATH = path.join(
|
||||
".dreams",
|
||||
"session-ingestion.json",
|
||||
);
|
||||
const SESSION_CORPUS_RELATIVE_DIR = path.join("memory", ".dreams", "session-corpus");
|
||||
const SESSION_INGESTION_SCORE = 0.58;
|
||||
const SESSION_INGESTION_MAX_SNIPPET_CHARS = 280;
|
||||
const SESSION_INGESTION_MIN_SNIPPET_CHARS = 12;
|
||||
@@ -494,14 +494,10 @@ function normalizeMemoryDay(value: unknown): string | undefined {
|
||||
return MEMORY_DAY_RE.test(day) ? day : undefined;
|
||||
}
|
||||
|
||||
async function readDailyIngestionState(
|
||||
workspaceDir: string,
|
||||
agentId?: string,
|
||||
): Promise<DailyIngestionState> {
|
||||
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
|
||||
const entries = await readMemoryCoreWorkspaceEntries<DailyIngestionFileState>({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
});
|
||||
return normalizeDailyIngestionState({
|
||||
version: 1,
|
||||
@@ -512,12 +508,10 @@ async function readDailyIngestionState(
|
||||
async function writeDailyIngestionState(
|
||||
workspaceDir: string,
|
||||
state: DailyIngestionState,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
|
||||
});
|
||||
}
|
||||
@@ -603,20 +597,15 @@ export function normalizeSessionIngestionState(raw: unknown): SessionIngestionSt
|
||||
return { version: 3, files, seenMessages };
|
||||
}
|
||||
|
||||
async function readSessionIngestionState(
|
||||
workspaceDir: string,
|
||||
agentId?: string,
|
||||
): Promise<SessionIngestionState> {
|
||||
async function readSessionIngestionState(workspaceDir: string): Promise<SessionIngestionState> {
|
||||
const [fileEntries, seenChunks] = await Promise.all([
|
||||
readMemoryCoreWorkspaceEntries<SessionIngestionFileState>({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
readMemoryCoreWorkspaceEntries<{ scope: string; index: number; hashes: string[] }>({
|
||||
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
]);
|
||||
const seenMessages: Record<string, string[]> = {};
|
||||
@@ -645,7 +634,6 @@ async function readSessionIngestionState(
|
||||
async function writeSessionIngestionState(
|
||||
workspaceDir: string,
|
||||
state: SessionIngestionState,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
|
||||
Array.from({ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) }, (_, index) => {
|
||||
@@ -663,13 +651,11 @@ async function writeSessionIngestionState(
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: seenEntries,
|
||||
}),
|
||||
]);
|
||||
@@ -780,18 +766,18 @@ function resolveSessionAgentsForWorkspace(params: {
|
||||
|
||||
async function appendSessionCorpusLines(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
day: string;
|
||||
lines: SessionIngestionMessage[];
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
if (params.lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const relativeDir = params.agentId
|
||||
? path.posix.join("memory", ".dreams", "agents", params.agentId, "session-corpus")
|
||||
: path.posix.join("memory", ".dreams", "session-corpus");
|
||||
const relativePath = path.posix.join(relativeDir, `${params.day}.txt`);
|
||||
const absolutePath = path.join(params.workspaceDir, ...relativePath.split("/"));
|
||||
const relativePath = path.posix.join("memory", ".dreams", "session-corpus", `${params.day}.txt`);
|
||||
const absolutePath = path.join(
|
||||
params.workspaceDir,
|
||||
SESSION_CORPUS_RELATIVE_DIR,
|
||||
`${params.day}.txt`,
|
||||
);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
let existing = "";
|
||||
try {
|
||||
@@ -830,7 +816,6 @@ async function appendSessionCorpusLines(params: {
|
||||
async function collectSessionIngestionBatches(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
agentId?: string;
|
||||
primaryWorkspaceDir?: string;
|
||||
lookbackDays: number;
|
||||
nowMs: number;
|
||||
@@ -846,13 +831,11 @@ async function collectSessionIngestionBatches(params: {
|
||||
Object.keys(params.state.seenMessages).length > 0,
|
||||
};
|
||||
}
|
||||
const agentIds = params.agentId
|
||||
? [params.agentId]
|
||||
: resolveSessionAgentsForWorkspace({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
});
|
||||
const agentIds = resolveSessionAgentsForWorkspace({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
});
|
||||
const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays);
|
||||
const batchByDay = new Map<string, SessionIngestionMessage[]>();
|
||||
const nextFiles: Record<string, SessionIngestionFileState> = {};
|
||||
@@ -1117,7 +1100,6 @@ async function collectSessionIngestionBatches(params: {
|
||||
}
|
||||
const results = await appendSessionCorpusLines({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId ? normalizeAgentId(params.agentId) : undefined,
|
||||
day,
|
||||
lines,
|
||||
});
|
||||
@@ -1136,18 +1118,15 @@ async function collectSessionIngestionBatches(params: {
|
||||
async function ingestSessionTranscriptSignals(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
agentId?: string;
|
||||
primaryWorkspaceDir?: string;
|
||||
lookbackDays: number;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const state = await readSessionIngestionState(params.workspaceDir, agentId);
|
||||
const state = await readSessionIngestionState(params.workspaceDir);
|
||||
const collected = await collectSessionIngestionBatches({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.lookbackDays,
|
||||
nowMs: params.nowMs,
|
||||
@@ -1158,7 +1137,6 @@ async function ingestSessionTranscriptSignals(params: {
|
||||
for (const batch of collected.batches) {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
query: `__dreaming_sessions__:${batch.day}`,
|
||||
results: batch.results,
|
||||
signalType: "daily",
|
||||
@@ -1169,7 +1147,7 @@ async function ingestSessionTranscriptSignals(params: {
|
||||
});
|
||||
}
|
||||
if (collected.changed) {
|
||||
await writeSessionIngestionState(params.workspaceDir, collected.nextState, agentId);
|
||||
await writeSessionIngestionState(params.workspaceDir, collected.nextState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1310,14 +1288,12 @@ async function collectDailyIngestionBatches(params: {
|
||||
|
||||
async function ingestDailyMemorySignals(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
lookbackDays: number;
|
||||
limit: number;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const state = await readDailyIngestionState(params.workspaceDir, agentId);
|
||||
const state = await readDailyIngestionState(params.workspaceDir);
|
||||
const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone);
|
||||
const collected = await collectDailyIngestionBatches({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -1330,7 +1306,6 @@ async function ingestDailyMemorySignals(params: {
|
||||
for (const batch of collected.batches) {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
query: `__dreaming_daily__:${batch.day}`,
|
||||
results: batch.results,
|
||||
signalType: "daily",
|
||||
@@ -1341,7 +1316,7 @@ async function ingestDailyMemorySignals(params: {
|
||||
});
|
||||
}
|
||||
if (collected.changed) {
|
||||
await writeDailyIngestionState(params.workspaceDir, collected.nextState, agentId);
|
||||
await writeDailyIngestionState(params.workspaceDir, collected.nextState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1350,7 +1325,6 @@ export async function seedHistoricalDailyMemorySignals(params: {
|
||||
filePaths: string[];
|
||||
limit: number;
|
||||
nowMs: number;
|
||||
agentId?: string;
|
||||
timezone?: string;
|
||||
}): Promise<{
|
||||
importedFileCount: number;
|
||||
@@ -1444,7 +1418,6 @@ export async function seedHistoricalDailyMemorySignals(params: {
|
||||
}
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId ? normalizeAgentId(params.agentId) : undefined,
|
||||
query: `__dreaming_daily__:${entry.file.day}`,
|
||||
results,
|
||||
signalType: "daily",
|
||||
@@ -1699,7 +1672,6 @@ export function previewRemDreaming(params: {
|
||||
async function runLightDreaming(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
agentId?: string;
|
||||
primaryWorkspaceDir?: string;
|
||||
config: LightDreamingConfig;
|
||||
logger: Logger;
|
||||
@@ -1708,10 +1680,8 @@ async function runLightDreaming(params: {
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
await ingestDailyMemorySignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
limit: params.config.limit,
|
||||
nowMs,
|
||||
@@ -1720,7 +1690,6 @@ async function runLightDreaming(params: {
|
||||
await ingestSessionTranscriptSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
nowMs,
|
||||
@@ -1729,11 +1698,7 @@ async function runLightDreaming(params: {
|
||||
const recentEntries = await filterLiveShortTermRecallEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
entries: filterRecallEntriesWithinLookback({
|
||||
entries: await readShortTermRecallEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
nowMs,
|
||||
}),
|
||||
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
|
||||
nowMs,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
}),
|
||||
@@ -1750,7 +1715,6 @@ async function runLightDreaming(params: {
|
||||
);
|
||||
const recentDiaryEntries = await readRecentDreamDiaryEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
limit: LIGHT_DIARY_HISTORY_LIMIT,
|
||||
});
|
||||
const entries = prioritizeLightEntriesByDiaryCoverage(rankedEntries, recentDiaryEntries);
|
||||
@@ -1758,7 +1722,6 @@ async function runLightDreaming(params: {
|
||||
const bodyLines = buildLightDreamingBody(capped);
|
||||
await writeDailyDreamingPhaseBlock({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
phase: "light",
|
||||
bodyLines,
|
||||
nowMs,
|
||||
@@ -1767,7 +1730,6 @@ async function runLightDreaming(params: {
|
||||
});
|
||||
await recordDreamingPhaseSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
phase: "light",
|
||||
keys: capped.map((entry) => entry.key),
|
||||
nowMs,
|
||||
@@ -1791,7 +1753,6 @@ async function runLightDreaming(params: {
|
||||
runDetachedDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
data,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1802,7 +1763,6 @@ async function runLightDreaming(params: {
|
||||
await generateAndAppendDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
data,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1816,7 +1776,6 @@ async function runLightDreaming(params: {
|
||||
async function runRemDreaming(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
agentId?: string;
|
||||
primaryWorkspaceDir?: string;
|
||||
config: RemDreamingConfig;
|
||||
logger: Logger;
|
||||
@@ -1825,10 +1784,8 @@ async function runRemDreaming(params: {
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
await ingestDailyMemorySignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
limit: params.config.limit,
|
||||
nowMs,
|
||||
@@ -1837,7 +1794,6 @@ async function runRemDreaming(params: {
|
||||
await ingestSessionTranscriptSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
nowMs,
|
||||
@@ -1846,11 +1802,7 @@ async function runRemDreaming(params: {
|
||||
const allEntries = await filterLiveShortTermRecallEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
entries: filterRecallEntriesWithinLookback({
|
||||
entries: await readShortTermRecallEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
nowMs,
|
||||
}),
|
||||
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
|
||||
nowMs,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
}),
|
||||
@@ -1859,7 +1811,6 @@ async function runRemDreaming(params: {
|
||||
// sequential light→REM pipeline instead of rescanning the full store.
|
||||
const lightKeys = await readLightStagedKeys({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
nowMs,
|
||||
});
|
||||
const stagedEntries =
|
||||
@@ -1872,7 +1823,6 @@ async function runRemDreaming(params: {
|
||||
});
|
||||
await writeDailyDreamingPhaseBlock({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
phase: "rem",
|
||||
bodyLines: preview.bodyLines,
|
||||
nowMs,
|
||||
@@ -1882,14 +1832,12 @@ async function runRemDreaming(params: {
|
||||
if (stagedEntries.length > 0) {
|
||||
await recordRemConsideredPhaseSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
keys: stagedEntries.map((entry) => entry.key),
|
||||
nowMs,
|
||||
});
|
||||
}
|
||||
await recordDreamingPhaseSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
phase: "rem",
|
||||
keys: preview.candidateKeys,
|
||||
nowMs,
|
||||
@@ -1920,7 +1868,6 @@ async function runRemDreaming(params: {
|
||||
runDetachedDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
data,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1931,7 +1878,6 @@ async function runRemDreaming(params: {
|
||||
await generateAndAppendDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId,
|
||||
data,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1946,7 +1892,6 @@ export async function runDreamingSweepPhases(params: {
|
||||
workspaceDir: string;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: DreamingHostConfig;
|
||||
agentId?: string;
|
||||
logger: Logger;
|
||||
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
|
||||
detachNarratives?: boolean;
|
||||
@@ -1958,13 +1903,11 @@ export async function runDreamingSweepPhases(params: {
|
||||
const light = resolveMemoryLightDreamingConfig({
|
||||
pluginConfig: params.pluginConfig,
|
||||
cfg: params.cfg as Parameters<typeof resolveMemoryLightDreamingConfig>[0]["cfg"],
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (light.enabled && light.limit > 0) {
|
||||
await runLightDreaming({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
config: light,
|
||||
logger: params.logger,
|
||||
subagent: params.subagent,
|
||||
@@ -1976,13 +1919,11 @@ export async function runDreamingSweepPhases(params: {
|
||||
const rem = resolveMemoryRemDreamingConfig({
|
||||
pluginConfig: params.pluginConfig,
|
||||
cfg: params.cfg as Parameters<typeof resolveMemoryRemDreamingConfig>[0]["cfg"],
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (rem.enabled && rem.limit > 0) {
|
||||
await runRemDreaming({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
config: rem,
|
||||
logger: params.logger,
|
||||
subagent: params.subagent,
|
||||
|
||||
@@ -216,102 +216,6 @@ describe("dreaming artifact repair", () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves unscoped legacy ingestion state during an agent-scoped repair", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const agentId = "Team Ops";
|
||||
const sessionCorpusDir = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
"team-ops",
|
||||
"session-corpus",
|
||||
);
|
||||
const legacyIngestionPath = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"session-ingestion.json",
|
||||
);
|
||||
await fs.mkdir(sessionCorpusDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
|
||||
await fs.writeFile(
|
||||
legacyIngestionPath,
|
||||
JSON.stringify({ version: 3, files: {}, seenMessages: {} }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await writeMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: [
|
||||
{
|
||||
key: "team-ops/session.jsonl",
|
||||
value: { lastSize: 120, lastMtimeMs: 1_000, lastContentHash: "hash", cursorLine: 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const repair = await repairDreamingArtifacts({ workspaceDir, agentId });
|
||||
|
||||
expect(repair.archivedSessionCorpus).toBe(true);
|
||||
expect(repair.archivedSessionIngestion).toBe(false);
|
||||
await expect(fs.readFile(legacyIngestionPath, "utf-8")).resolves.toContain('"version": 3');
|
||||
await expect(
|
||||
readMemoryCoreWorkspaceEntries({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(auditDreamingArtifacts({ workspaceDir })).resolves.toMatchObject({
|
||||
sessionIngestionExists: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to archive agent artifacts through a symlinked parent", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
|
||||
const outsideDir = path.join(workspaceDir, "outside");
|
||||
const sessionCorpusDir = path.join(outsideDir, "agents", "writer", "session-corpus");
|
||||
await fs.rm(dreamsDir, { recursive: true, force: true });
|
||||
await fs.mkdir(sessionCorpusDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
|
||||
await fs.symlink(outsideDir, dreamsDir);
|
||||
|
||||
const repair = await repairDreamingArtifacts({ workspaceDir, agentId: "writer" });
|
||||
|
||||
expect(repair.changed).toBe(false);
|
||||
expect(repair.archivedSessionCorpus).toBe(false);
|
||||
expect(repair.warnings).toContainEqual(
|
||||
expect.stringContaining("must not traverse symlinked directory"),
|
||||
);
|
||||
await expect(fs.readFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "utf-8")).resolves.toBe(
|
||||
"corpus\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to archive dreaming artifacts through a symlinked archive parent", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
|
||||
const outsideDir = path.join(workspaceDir, "outside");
|
||||
await fs.mkdir(sessionCorpusDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
|
||||
await fs.mkdir(outsideDir);
|
||||
await fs.symlink(outsideDir, path.join(workspaceDir, ".openclaw-repair"));
|
||||
|
||||
const repair = await repairDreamingArtifacts({ workspaceDir });
|
||||
|
||||
expect(repair.changed).toBe(false);
|
||||
expect(repair.archivedSessionCorpus).toBe(false);
|
||||
expect(repair.warnings).toContainEqual(
|
||||
expect.stringContaining("must not traverse symlinked directory"),
|
||||
);
|
||||
await expect(fs.readFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "utf-8")).resolves.toBe(
|
||||
"corpus\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ingestion state present from SQLite when legacy JSON is absent", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
// Write SQLite ingestion entries but NO legacy session-ingestion.json
|
||||
|
||||
@@ -3,14 +3,11 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { assertNoSymlinkParents } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
clearMemoryCoreWorkspaceNamespace,
|
||||
DREAMING_DAILY_INGESTION_NAMESPACE,
|
||||
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
|
||||
memoryCoreStateReference,
|
||||
readMemoryCoreWorkspaceEntries,
|
||||
} from "./dreaming-state.js";
|
||||
|
||||
@@ -64,29 +61,7 @@ function requireAbsoluteWorkspaceDir(rawWorkspaceDir: string): string {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
async function resolveExistingDreamsPath(
|
||||
workspaceDir: string,
|
||||
agentId?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (agentId?.trim()) {
|
||||
const scoped = path.join(
|
||||
workspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"agents",
|
||||
normalizeAgentId(agentId),
|
||||
DREAMS_FILENAMES[0],
|
||||
);
|
||||
try {
|
||||
await fs.access(scoped);
|
||||
return scoped;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function resolveExistingDreamsPath(workspaceDir: string): Promise<string | undefined> {
|
||||
for (const fileName of DREAMS_FILENAMES) {
|
||||
const candidate = path.join(workspaceDir, fileName);
|
||||
try {
|
||||
@@ -142,77 +117,41 @@ async function ensureArchivablePath(targetPath: string): Promise<"file" | "dir"
|
||||
throw new Error(`Refusing to archive non-file artifact: ${targetPath}`);
|
||||
}
|
||||
|
||||
async function assertSafeRepairArtifactParent(
|
||||
workspaceDir: string,
|
||||
targetPath: string,
|
||||
): Promise<void> {
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: workspaceDir,
|
||||
targetPath: path.dirname(targetPath),
|
||||
requireDirectories: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSafeArchiveDirectory(workspaceDir: string, archiveDir: string): Promise<void> {
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: workspaceDir,
|
||||
targetPath: archiveDir,
|
||||
requireDirectories: true,
|
||||
});
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: workspaceDir,
|
||||
targetPath: archiveDir,
|
||||
allowMissing: false,
|
||||
requireDirectories: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function moveToArchive(params: {
|
||||
workspaceDir: string;
|
||||
targetPath: string;
|
||||
archiveDir: string;
|
||||
}): Promise<string | null> {
|
||||
await assertSafeRepairArtifactParent(params.workspaceDir, params.targetPath);
|
||||
const kind = await ensureArchivablePath(params.targetPath);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
await ensureSafeArchiveDirectory(params.workspaceDir, params.archiveDir);
|
||||
await fs.mkdir(params.archiveDir, { recursive: true });
|
||||
const baseName = path.basename(params.targetPath);
|
||||
const destination = path.join(params.archiveDir, `${baseName}.${randomUUID()}`);
|
||||
await fs.rename(params.targetPath, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
async function clearSessionIngestionState(workspaceDir: string, agentId?: string): Promise<void> {
|
||||
async function clearSessionIngestionState(workspaceDir: string): Promise<void> {
|
||||
await Promise.all([
|
||||
clearMemoryCoreWorkspaceNamespace({
|
||||
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
clearMemoryCoreWorkspaceNamespace({
|
||||
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function auditDreamingArtifacts(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<DreamingArtifactsAuditSummary> {
|
||||
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
|
||||
const agentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined;
|
||||
const dreamsPath = await resolveExistingDreamsPath(workspaceDir, agentId);
|
||||
const sessionCorpusDir = agentId
|
||||
? path.join(workspaceDir, "memory", ".dreams", "agents", agentId, "session-corpus")
|
||||
: path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
|
||||
const sessionIngestionPath = agentId
|
||||
? memoryCoreStateReference(DREAMING_SESSION_INGESTION_FILES_NAMESPACE, workspaceDir, agentId)
|
||||
: path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
|
||||
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
|
||||
const sessionCorpusDir = path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR);
|
||||
const sessionIngestionPath = path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH);
|
||||
const issues: DreamingArtifactsAuditIssue[] = [];
|
||||
let sessionCorpusFileCount = 0;
|
||||
let suspiciousSessionCorpusFileCount = 0;
|
||||
@@ -257,19 +196,17 @@ export async function auditDreamingArtifacts(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
try {
|
||||
await fs.access(sessionIngestionPath);
|
||||
sessionIngestionExists = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "dreaming-session-ingestion-unreadable",
|
||||
message: `Dreaming session-ingestion state could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await fs.access(sessionIngestionPath);
|
||||
sessionIngestionExists = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "dreaming-session-ingestion-unreadable",
|
||||
message: `Dreaming session-ingestion state could not be inspected: ${(err as NodeJS.ErrnoException).code ?? "error"}.`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +222,6 @@ export async function auditDreamingArtifacts(params: {
|
||||
const entries = await readMemoryCoreWorkspaceEntries({
|
||||
namespace,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
});
|
||||
if (entries.length > 0) {
|
||||
sessionIngestionExists = true;
|
||||
@@ -320,12 +256,10 @@ export async function auditDreamingArtifacts(params: {
|
||||
|
||||
export async function repairDreamingArtifacts(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
archiveDiary?: boolean;
|
||||
now?: Date;
|
||||
}): Promise<RepairDreamingArtifactsResult> {
|
||||
const workspaceDir = requireAbsoluteWorkspaceDir(params.workspaceDir);
|
||||
const agentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined;
|
||||
const warnings: string[] = [];
|
||||
const archivedPaths: string[] = [];
|
||||
let archiveDir: string | undefined;
|
||||
@@ -344,11 +278,7 @@ export async function repairDreamingArtifacts(params: {
|
||||
|
||||
const archivePathIfPresent = async (targetPath: string): Promise<string | null> => {
|
||||
try {
|
||||
return await moveToArchive({
|
||||
workspaceDir,
|
||||
targetPath,
|
||||
archiveDir: ensureArchiveDir(),
|
||||
});
|
||||
return await moveToArchive({ targetPath, archiveDir: ensureArchiveDir() });
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
@@ -356,18 +286,16 @@ export async function repairDreamingArtifacts(params: {
|
||||
};
|
||||
|
||||
const sessionCorpusDestination = await archivePathIfPresent(
|
||||
agentId
|
||||
? path.join(workspaceDir, "memory", ".dreams", "agents", agentId, "session-corpus")
|
||||
: path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
|
||||
path.join(workspaceDir, SESSION_CORPUS_RELATIVE_DIR),
|
||||
);
|
||||
if (sessionCorpusDestination) {
|
||||
archivedSessionCorpus = true;
|
||||
archivedPaths.push(sessionCorpusDestination);
|
||||
}
|
||||
|
||||
const sessionIngestionDestination = agentId
|
||||
? null
|
||||
: await archivePathIfPresent(path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH));
|
||||
const sessionIngestionDestination = await archivePathIfPresent(
|
||||
path.join(workspaceDir, SESSION_INGESTION_RELATIVE_PATH),
|
||||
);
|
||||
if (sessionIngestionDestination) {
|
||||
archivedSessionIngestion = true;
|
||||
archivedPaths.push(sessionIngestionDestination);
|
||||
@@ -375,7 +303,7 @@ export async function repairDreamingArtifacts(params: {
|
||||
|
||||
if (sessionCorpusDestination || sessionIngestionDestination) {
|
||||
try {
|
||||
await clearSessionIngestionState(workspaceDir, agentId);
|
||||
await clearSessionIngestionState(workspaceDir);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed clearing dreaming session-ingestion SQLite state: ${
|
||||
@@ -386,7 +314,7 @@ export async function repairDreamingArtifacts(params: {
|
||||
}
|
||||
|
||||
if (params.archiveDiary) {
|
||||
const dreamsPath = await resolveExistingDreamsPath(workspaceDir, agentId);
|
||||
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
|
||||
if (dreamsPath) {
|
||||
const dreamsDestination = await archivePathIfPresent(dreamsPath);
|
||||
if (dreamsDestination) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
|
||||
export const MEMORY_CORE_PLUGIN_ID = "memory-core";
|
||||
export const DREAMING_DAILY_INGESTION_NAMESPACE = "dreaming-daily-ingestion";
|
||||
@@ -15,7 +14,6 @@ export const SHORT_TERM_RECALL_NAMESPACE = "short-term-recall";
|
||||
export const SHORT_TERM_PHASE_SIGNAL_NAMESPACE = "short-term-phase-signals";
|
||||
export const SHORT_TERM_META_NAMESPACE = "short-term-meta";
|
||||
export const SHORT_TERM_LOCK_NAMESPACE = "short-term-locks";
|
||||
export const SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE = "short-term-memory-file-locks";
|
||||
|
||||
export const DREAMING_WORKSPACE_STATE_MAX_ENTRIES = 50_000;
|
||||
export const SHORT_TERM_LOCK_MAX_ENTRIES = 4_096;
|
||||
@@ -29,7 +27,6 @@ type WorkspaceValue<T> = {
|
||||
version: 1;
|
||||
workspaceKey: string;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
key: string;
|
||||
value: T;
|
||||
};
|
||||
@@ -39,7 +36,6 @@ export type MemoryCoreWorkspaceEntry<T> = { key: string; value: T };
|
||||
type MemoryCoreWorkspaceParams = {
|
||||
namespace: string;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
type WriteMemoryCoreWorkspaceEntriesParams<T> = MemoryCoreWorkspaceParams & {
|
||||
@@ -84,34 +80,18 @@ export function normalizeMemoryCoreWorkspaceKey(workspaceDir: string): string {
|
||||
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function normalizeMemoryCoreAgentId(agentId: string | undefined): string | undefined {
|
||||
return agentId?.trim() ? normalizeAgentId(agentId) : undefined;
|
||||
export function memoryCoreWorkspaceStateKey(workspaceDir: string): string {
|
||||
return createHash("sha256").update(normalizeMemoryCoreWorkspaceKey(workspaceDir)).digest("hex");
|
||||
}
|
||||
|
||||
export function memoryCoreWorkspaceStateKey(workspaceDir: string, agentId?: string): string {
|
||||
const normalizedAgentId = normalizeMemoryCoreAgentId(agentId);
|
||||
const scope = normalizedAgentId
|
||||
? `${normalizeMemoryCoreWorkspaceKey(workspaceDir)}\0${normalizedAgentId}`
|
||||
: normalizeMemoryCoreWorkspaceKey(workspaceDir);
|
||||
return createHash("sha256").update(scope).digest("hex");
|
||||
}
|
||||
|
||||
export function memoryCoreWorkspaceEntryKey(
|
||||
workspaceDir: string,
|
||||
logicalKey: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir, agentId);
|
||||
export function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string {
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir);
|
||||
const itemKey = createHash("sha256").update(logicalKey).digest("hex");
|
||||
return `${workspaceKey}:${itemKey}`;
|
||||
}
|
||||
|
||||
export function memoryCoreStateReference(
|
||||
namespace: string,
|
||||
workspaceDir: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir, agentId)}`;
|
||||
export function memoryCoreStateReference(namespace: string, workspaceDir: string): string {
|
||||
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir)}`;
|
||||
}
|
||||
|
||||
function openWorkspaceStore<T>(namespace: string): PluginStateKeyedStore<WorkspaceValue<T>> {
|
||||
@@ -128,7 +108,7 @@ export function readMemoryCoreWorkspaceEntries<T>(
|
||||
export async function readMemoryCoreWorkspaceEntries(
|
||||
params: MemoryCoreWorkspaceParams,
|
||||
): Promise<Array<MemoryCoreWorkspaceEntry<unknown>>> {
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
|
||||
const prefix = `${workspaceKey}:`;
|
||||
const entries = await openWorkspaceStore<unknown>(params.namespace).entries();
|
||||
return entries
|
||||
@@ -144,18 +124,16 @@ export async function writeMemoryCoreWorkspaceEntries(
|
||||
params: WriteMemoryCoreWorkspaceEntriesParams<unknown>,
|
||||
): Promise<void> {
|
||||
const store = openWorkspaceStore<unknown>(params.namespace);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
|
||||
const agentId = normalizeMemoryCoreAgentId(params.agentId);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
|
||||
const prefix = `${workspaceKey}:`;
|
||||
const replacementKeys = new Set<string>();
|
||||
for (const entry of params.entries) {
|
||||
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key, params.agentId);
|
||||
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key);
|
||||
replacementKeys.add(stateKey);
|
||||
await store.register(stateKey, {
|
||||
version: 1,
|
||||
workspaceKey,
|
||||
workspaceDir: path.resolve(params.workspaceDir),
|
||||
...(agentId ? { agentId } : {}),
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
});
|
||||
@@ -174,15 +152,13 @@ export function writeMemoryCoreWorkspaceEntry<T>(
|
||||
export async function writeMemoryCoreWorkspaceEntry(
|
||||
params: WriteMemoryCoreWorkspaceEntryParams<unknown>,
|
||||
): Promise<void> {
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
|
||||
const agentId = normalizeMemoryCoreAgentId(params.agentId);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
|
||||
await openWorkspaceStore<unknown>(params.namespace).register(
|
||||
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key, params.agentId),
|
||||
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
|
||||
{
|
||||
version: 1,
|
||||
workspaceKey,
|
||||
workspaceDir: path.resolve(params.workspaceDir),
|
||||
...(agentId ? { agentId } : {}),
|
||||
key: params.key,
|
||||
value: params.value,
|
||||
},
|
||||
@@ -192,10 +168,9 @@ export async function writeMemoryCoreWorkspaceEntry(
|
||||
export async function clearMemoryCoreWorkspaceNamespace(params: {
|
||||
namespace: string;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<void> {
|
||||
const store = openWorkspaceStore(params.namespace);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, params.agentId);
|
||||
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
|
||||
const prefix = `${workspaceKey}:`;
|
||||
for (const entry of await store.entries()) {
|
||||
if (entry.key.startsWith(prefix)) {
|
||||
@@ -203,45 +178,3 @@ export async function clearMemoryCoreWorkspaceNamespace(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateMemoryCoreWorkspaceNamespaceToAgent(params: {
|
||||
namespace: string;
|
||||
workspaceDir: string;
|
||||
sourceWorkspaceDir?: string;
|
||||
agentId: string;
|
||||
}): Promise<{ sourceEntries: number; migratedEntries: number; retainedAgentEntries: number }> {
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const sourceWorkspaceDir = params.sourceWorkspaceDir ?? params.workspaceDir;
|
||||
const sourceWorkspaceKey = memoryCoreWorkspaceStateKey(sourceWorkspaceDir);
|
||||
const targetWorkspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir, agentId);
|
||||
if (sourceWorkspaceKey === targetWorkspaceKey) {
|
||||
return { sourceEntries: 0, migratedEntries: 0, retainedAgentEntries: 0 };
|
||||
}
|
||||
|
||||
const store = openWorkspaceStore<unknown>(params.namespace);
|
||||
const sourcePrefix = `${sourceWorkspaceKey}:`;
|
||||
const sourceEntries = (await store.entries()).filter(
|
||||
(entry) =>
|
||||
entry.key.startsWith(sourcePrefix) &&
|
||||
entry.value.workspaceKey === sourceWorkspaceKey &&
|
||||
!entry.value.agentId,
|
||||
);
|
||||
let migratedEntries = 0;
|
||||
let retainedAgentEntries = 0;
|
||||
for (const entry of sourceEntries) {
|
||||
const targetKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.value.key, agentId);
|
||||
const migrated = await store.registerIfAbsent(targetKey, {
|
||||
...entry.value,
|
||||
workspaceKey: targetWorkspaceKey,
|
||||
workspaceDir: path.resolve(params.workspaceDir),
|
||||
agentId,
|
||||
});
|
||||
if (migrated) {
|
||||
migratedEntries += 1;
|
||||
} else {
|
||||
retainedAgentEntries += 1;
|
||||
}
|
||||
await store.delete(entry.key);
|
||||
}
|
||||
return { sourceEntries: sourceEntries.length, migratedEntries, retainedAgentEntries };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resetSystemEventsForTest,
|
||||
} from "openclaw/plugin-sdk/system-event-runtime";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { migrateMemoryCoreLegacyConfig } from "./config-compat.js";
|
||||
import {
|
||||
testing,
|
||||
reconcileShortTermDreamingCronJob,
|
||||
@@ -90,7 +89,6 @@ function createCronHarness(
|
||||
addCalls.push(input);
|
||||
jobs.push({
|
||||
id: `job-${jobs.length + 1}`,
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
enabled: input.enabled,
|
||||
@@ -112,7 +110,6 @@ function createCronHarness(
|
||||
const current = jobs[index];
|
||||
jobs[index] = {
|
||||
...current,
|
||||
...(patch.agentId ? { agentId: patch.agentId } : {}),
|
||||
...(patch.name ? { name: patch.name } : {}),
|
||||
...(patch.description ? { description: patch.description } : {}),
|
||||
...(typeof patch.enabled === "boolean" ? { enabled: patch.enabled } : {}),
|
||||
@@ -270,14 +267,7 @@ async function triggerGatewayStart(
|
||||
onMock: ReturnType<typeof vi.fn>,
|
||||
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
|
||||
): Promise<void> {
|
||||
const migrated = ctx.config ? migrateMemoryCoreLegacyConfig(ctx.config)?.config : undefined;
|
||||
await getGatewayStartHandler(onMock)(
|
||||
{ port: 18789 },
|
||||
{
|
||||
...ctx,
|
||||
...(migrated ? { config: migrated } : {}),
|
||||
},
|
||||
);
|
||||
await getGatewayStartHandler(onMock)({ port: 18789 }, ctx);
|
||||
}
|
||||
|
||||
async function triggerGatewayStop(
|
||||
@@ -288,25 +278,6 @@ async function triggerGatewayStop(
|
||||
}
|
||||
|
||||
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
|
||||
const normalizeConfig = (config: OpenClawConfig): OpenClawConfig =>
|
||||
migrateMemoryCoreLegacyConfig(config)?.config ?? config;
|
||||
let rawConfig = api.config;
|
||||
Object.defineProperty(api, "config", {
|
||||
configurable: true,
|
||||
get: () => normalizeConfig(rawConfig),
|
||||
set: (config: OpenClawConfig) => {
|
||||
rawConfig = config;
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = api.runtime as {
|
||||
config?: { current?: () => OpenClawConfig };
|
||||
};
|
||||
if (runtime.config?.current) {
|
||||
const current = runtime.config.current;
|
||||
runtime.config.current = () => normalizeConfig(current());
|
||||
}
|
||||
|
||||
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
|
||||
}
|
||||
|
||||
@@ -576,8 +547,7 @@ describe("short-term dreaming cron reconciliation", () => {
|
||||
expect(result.status).toBe("added");
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
const addCall = requireAddCall(harness, 0);
|
||||
expect(addCall.agentId).toBe("main");
|
||||
expect(addCall.name).toBe(`${constants.MANAGED_DREAMING_CRON_NAME} (main)`);
|
||||
expect(addCall.name).toBe(constants.MANAGED_DREAMING_CRON_NAME);
|
||||
expect(addCall.sessionTarget).toBe("isolated");
|
||||
expect(addCall.wakeMode).toBe("now");
|
||||
expect(addCall.delivery?.mode).toBe("none");
|
||||
@@ -587,41 +557,6 @@ describe("short-term dreaming cron reconciliation", () => {
|
||||
expectCronSchedule(addCall.schedule, "0 1 * * *", "UTC");
|
||||
});
|
||||
|
||||
it("creates distinct agent-owned managed cron jobs", async () => {
|
||||
const harness = createCronHarness();
|
||||
const logger = createLogger();
|
||||
const config = {
|
||||
enabled: true,
|
||||
cron: "0 1 * * *",
|
||||
limit: 8,
|
||||
minScore: 0.5,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 5,
|
||||
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
|
||||
verboseLogging: false,
|
||||
} as const;
|
||||
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron: harness.cron,
|
||||
config,
|
||||
logger,
|
||||
agentId: "research",
|
||||
});
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron: harness.cron,
|
||||
config,
|
||||
logger,
|
||||
agentId: "writer",
|
||||
});
|
||||
|
||||
expect(harness.addCalls).toHaveLength(2);
|
||||
expect(harness.addCalls.map((job) => job.agentId)).toEqual(["research", "writer"]);
|
||||
expect(harness.addCalls.map((job) => job.name)).toEqual([
|
||||
`${constants.MANAGED_DREAMING_CRON_NAME} (research)`,
|
||||
`${constants.MANAGED_DREAMING_CRON_NAME} (writer)`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates drifted managed jobs and prunes duplicates", async () => {
|
||||
const desiredConfig = {
|
||||
enabled: true,
|
||||
@@ -1344,7 +1279,7 @@ describe("gateway startup reconciliation", () => {
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.listCalls).toBe(2);
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
|
||||
@@ -1353,7 +1288,7 @@ describe("gateway startup reconciliation", () => {
|
||||
{ trigger: "user", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(2);
|
||||
expect(harness.listCalls).toBe(1);
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
@@ -1395,7 +1330,7 @@ describe("gateway startup reconciliation", () => {
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.listCalls).toBe(2);
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply(
|
||||
@@ -1407,7 +1342,7 @@ describe("gateway startup reconciliation", () => {
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(4);
|
||||
expect(harness.listCalls).toBe(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
clearInternalHooks();
|
||||
@@ -1756,7 +1691,7 @@ describe("gateway startup reconciliation", () => {
|
||||
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
const addCall = requireAddCall(harness, 0);
|
||||
expect(addCall.name).toBe("Memory Dreaming Promotion (main)");
|
||||
expect(addCall.name).toBe("Memory Dreaming Promotion");
|
||||
expectCronSchedule(addCall.schedule, "15 4 * * *", "UTC");
|
||||
expect(addCall.sessionTarget).toBe("isolated");
|
||||
const payload = requireAgentTurnPayload(addCall.payload);
|
||||
@@ -2840,19 +2775,12 @@ describe("short-term dreaming trigger", () => {
|
||||
trigger: "heartbeat",
|
||||
workspaceDir: mainWorkspace,
|
||||
cfg: {
|
||||
memory: {
|
||||
search: {
|
||||
enabled: true,
|
||||
},
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "alpha",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// Memory Core plugin module implements dreaming behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveDefaultAgentId,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import {
|
||||
DEFAULT_MEMORY_DREAMING_FREQUENCY as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_LIMIT as DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
@@ -50,7 +46,6 @@ type CronPayload =
|
||||
| { kind: "systemEvent"; text: string }
|
||||
| { kind: "agentTurn"; message: string; lightContext?: boolean };
|
||||
type ManagedCronJobCreate = {
|
||||
agentId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
@@ -64,7 +59,6 @@ type ManagedCronJobCreate = {
|
||||
};
|
||||
|
||||
type ManagedCronJobPatch = {
|
||||
agentId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
@@ -79,7 +73,6 @@ type ManagedCronJobPatch = {
|
||||
|
||||
type ManagedCronJobLike = {
|
||||
id: string;
|
||||
agentId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
@@ -162,23 +155,18 @@ function formatRepairSummary(repair: {
|
||||
return actions.join(", ");
|
||||
}
|
||||
|
||||
function resolveManagedCronDescription(
|
||||
config: ShortTermPromotionDreamingConfig,
|
||||
agentId: string,
|
||||
): string {
|
||||
function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string {
|
||||
const recencyHalfLifeDays =
|
||||
config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS;
|
||||
return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md for agent=${agentId} (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`;
|
||||
return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`;
|
||||
}
|
||||
|
||||
function buildManagedDreamingCronJob(
|
||||
config: ShortTermPromotionDreamingConfig,
|
||||
agentId = "main",
|
||||
): ManagedCronJobCreate {
|
||||
return {
|
||||
agentId,
|
||||
name: `${MANAGED_DREAMING_CRON_NAME} (${agentId})`,
|
||||
description: resolveManagedCronDescription(config, agentId),
|
||||
name: MANAGED_DREAMING_CRON_NAME,
|
||||
description: resolveManagedCronDescription(config),
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "cron",
|
||||
@@ -219,21 +207,7 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
|
||||
}
|
||||
const name = normalizeTrimmedString(job.name);
|
||||
const payloadToken = resolveManagedDreamingPayloadToken(job.payload);
|
||||
return (
|
||||
(name === MANAGED_DREAMING_CRON_NAME ||
|
||||
name?.startsWith(`${MANAGED_DREAMING_CRON_NAME} (`) === true) &&
|
||||
payloadToken === DREAMING_SYSTEM_EVENT_TEXT
|
||||
);
|
||||
}
|
||||
|
||||
function isManagedDreamingJobForAgent(job: ManagedCronJobLike, agentId: string): boolean {
|
||||
const normalizedJobAgentId = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId));
|
||||
const normalizedAgentId = normalizeLowercaseStringOrEmpty(agentId);
|
||||
return (
|
||||
isManagedDreamingJob(job) &&
|
||||
(normalizedJobAgentId === normalizedAgentId ||
|
||||
(!normalizedJobAgentId && normalizedAgentId === "main"))
|
||||
);
|
||||
return name === MANAGED_DREAMING_CRON_NAME && payloadToken === DREAMING_SYSTEM_EVENT_TEXT;
|
||||
}
|
||||
|
||||
function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
|
||||
@@ -298,12 +272,6 @@ function buildManagedDreamingPatch(
|
||||
if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) {
|
||||
patch.name = desired.name;
|
||||
}
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId)) !==
|
||||
normalizeLowercaseStringOrEmpty(desired.agentId)
|
||||
) {
|
||||
patch.agentId = desired.agentId;
|
||||
}
|
||||
if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) {
|
||||
patch.description = desired.description;
|
||||
}
|
||||
@@ -420,7 +388,6 @@ function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean {
|
||||
export function resolveShortTermPromotionDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): ShortTermPromotionDreamingConfig {
|
||||
const resolved = resolveMemoryDeepDreamingConfig(params);
|
||||
return {
|
||||
@@ -445,7 +412,6 @@ export async function reconcileShortTermDreamingCronJob(params: {
|
||||
cron: CronServiceLike | null;
|
||||
config: ShortTermPromotionDreamingConfig;
|
||||
logger: Logger;
|
||||
agentId?: string;
|
||||
}): Promise<ReconcileResult> {
|
||||
const cron = params.cron;
|
||||
if (!cron) {
|
||||
@@ -453,8 +419,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
|
||||
}
|
||||
|
||||
const allJobs = await cron.list({ includeDisabled: true });
|
||||
const agentId = params.agentId ?? "main";
|
||||
const managed = allJobs.filter((job) => isManagedDreamingJobForAgent(job, agentId));
|
||||
const managed = allJobs.filter(isManagedDreamingJob);
|
||||
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
|
||||
|
||||
if (!params.config.enabled) {
|
||||
@@ -482,7 +447,7 @@ export async function reconcileShortTermDreamingCronJob(params: {
|
||||
return { status: "disabled", removed };
|
||||
}
|
||||
|
||||
const desired = buildManagedDreamingCronJob(params.config, agentId);
|
||||
const desired = buildManagedDreamingCronJob(params.config);
|
||||
if (managed.length === 0) {
|
||||
await cron.add(desired);
|
||||
const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
|
||||
@@ -533,7 +498,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
trigger?: string;
|
||||
workspaceDir?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
config: ShortTermPromotionDreamingConfig;
|
||||
logger: Logger;
|
||||
subagent?: OpenClawPluginApi["runtime"]["subagent"];
|
||||
@@ -554,8 +518,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
const workspaceCandidates = params.cfg
|
||||
? resolveMemoryDreamingWorkspaces(params.cfg, {
|
||||
primaryWorkspaceDir: fallbackWorkspaceDir,
|
||||
primaryAgentId: params.agentId ?? resolveDefaultAgentId(params.cfg),
|
||||
agentIds: params.agentId ? [params.agentId] : undefined,
|
||||
primaryAgentId: "main",
|
||||
}).map((entry) => entry.workspaceDir)
|
||||
: [];
|
||||
const seenWorkspaces = new Set<string>();
|
||||
@@ -589,10 +552,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
let totalCandidates = 0;
|
||||
let totalApplied = 0;
|
||||
let failedWorkspaces = 0;
|
||||
const pluginConfig =
|
||||
params.cfg && params.agentId
|
||||
? resolveMemoryCorePluginConfig(params.cfg, params.agentId)
|
||||
: undefined;
|
||||
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
|
||||
const detachNarratives = params.trigger === "cron";
|
||||
const [
|
||||
{ writeDeepDreamingReport },
|
||||
@@ -616,7 +576,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
workspaceDir,
|
||||
pluginConfig,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
logger: params.logger,
|
||||
subagent: params.subagent,
|
||||
detachNarratives,
|
||||
@@ -624,10 +583,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
});
|
||||
|
||||
const reportLines: string[] = [];
|
||||
const repair = await repairShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
if (repair.changed) {
|
||||
params.logger.info(
|
||||
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}) [workspace=${workspaceDir}].`,
|
||||
@@ -636,7 +592,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
}
|
||||
const candidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
minRecallCount: params.config.minRecallCount,
|
||||
@@ -663,7 +618,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
}
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
candidates,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
@@ -692,7 +646,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
}
|
||||
await writeDeepDreamingReport({
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
bodyLines: reportLines,
|
||||
nowMs: sweepNowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -708,7 +661,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
if (!params.subagent) {
|
||||
await appendFallbackNarrativeEntry({
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data,
|
||||
nowMs: sweepNowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -719,7 +671,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
runDetachedDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data,
|
||||
nowMs: sweepNowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -730,7 +681,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
await generateAndAppendDreamNarrative({
|
||||
subagent: params.subagent,
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
data,
|
||||
nowMs: sweepNowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -803,44 +753,39 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
resolveStartupCron = null;
|
||||
};
|
||||
|
||||
const runtimeConfigKey = (
|
||||
plans: Array<{ agentId: string; config: ShortTermPromotionDreamingConfig }>,
|
||||
): string =>
|
||||
plans
|
||||
.map(({ agentId, config }) =>
|
||||
[
|
||||
agentId,
|
||||
config.enabled ? "enabled" : "disabled",
|
||||
config.cron,
|
||||
config.timezone ?? "",
|
||||
String(config.limit),
|
||||
String(config.minScore),
|
||||
String(config.minRecallCount),
|
||||
String(config.minUniqueQueries),
|
||||
String(config.recencyHalfLifeDays ?? ""),
|
||||
String(config.maxAgeDays ?? ""),
|
||||
config.verboseLogging ? "verbose" : "quiet",
|
||||
config.storage?.mode ?? "",
|
||||
config.storage?.separateReports ? "separate" : "inline",
|
||||
].join("|"),
|
||||
)
|
||||
.toSorted()
|
||||
.join("\n");
|
||||
const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string =>
|
||||
[
|
||||
config.enabled ? "enabled" : "disabled",
|
||||
config.cron,
|
||||
config.timezone ?? "",
|
||||
String(config.limit),
|
||||
String(config.minScore),
|
||||
String(config.minRecallCount),
|
||||
String(config.minUniqueQueries),
|
||||
String(config.recencyHalfLifeDays ?? ""),
|
||||
String(config.maxAgeDays ?? ""),
|
||||
config.verboseLogging ? "verbose" : "quiet",
|
||||
config.storage?.mode ?? "",
|
||||
config.storage?.separateReports ? "separate" : "inline",
|
||||
].join("|");
|
||||
|
||||
const reconcileManagedDreamingCron = async (params: {
|
||||
reason: "startup" | "startup_retry" | "runtime";
|
||||
startupConfig?: OpenClawConfig;
|
||||
startupCron?: (() => CronServiceLike | null) | null;
|
||||
}): Promise<void> => {
|
||||
}): Promise<ShortTermPromotionDreamingConfig> => {
|
||||
const startupCfg =
|
||||
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
|
||||
const plans = listAgentIds(startupCfg).map((agentId) => ({
|
||||
agentId,
|
||||
config: resolveShortTermPromotionDreamingConfig({
|
||||
cfg: startupCfg,
|
||||
agentId,
|
||||
}),
|
||||
}));
|
||||
const pluginConfig =
|
||||
params.reason === "startup"
|
||||
? (resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
resolveMemoryCorePluginConfig(api.config) ??
|
||||
api.pluginConfig)
|
||||
: resolveMemoryCorePluginConfig(startupCfg);
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg: startupCfg,
|
||||
});
|
||||
if (params.reason === "startup") {
|
||||
resolveStartupCron = params.startupCron ?? null;
|
||||
}
|
||||
@@ -860,8 +805,8 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
// Ignore — fall through with cron = null
|
||||
}
|
||||
}
|
||||
const configKey = runtimeConfigKey(plans);
|
||||
if (!cron && plans.some((plan) => plan.config.enabled) && !unavailableCronWarningEmitted) {
|
||||
const configKey = runtimeConfigKey(config);
|
||||
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
|
||||
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
|
||||
// The runtime reconciliation path (heartbeat-driven) will still warn if the
|
||||
// cron service remains unavailable after boot.
|
||||
@@ -883,7 +828,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
// Startup retries only probe cron availability; the exhausted retry path
|
||||
// re-enters runtime reconciliation so persistent failures still warn once.
|
||||
if (!cron && params.reason === "startup_retry") {
|
||||
return;
|
||||
return config;
|
||||
}
|
||||
if (params.reason === "runtime") {
|
||||
const now = Date.now();
|
||||
@@ -894,29 +839,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
lastRuntimeConfigKey === configKey &&
|
||||
lastRuntimeCronRef === cron
|
||||
) {
|
||||
return;
|
||||
return config;
|
||||
}
|
||||
lastRuntimeReconcileAtMs = now;
|
||||
lastRuntimeConfigKey = configKey;
|
||||
lastRuntimeCronRef = cron;
|
||||
}
|
||||
for (const plan of plans) {
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron,
|
||||
config: plan.config,
|
||||
logger: api.logger,
|
||||
agentId: plan.agentId,
|
||||
});
|
||||
}
|
||||
if (cron) {
|
||||
const configuredAgentIds = new Set(plans.map((plan) => plan.agentId));
|
||||
for (const job of await cron.list({ includeDisabled: true })) {
|
||||
const jobAgentId = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.agentId));
|
||||
if (isManagedDreamingJob(job) && (!jobAgentId || !configuredAgentIds.has(jobAgentId))) {
|
||||
await cron.remove(job.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron,
|
||||
config,
|
||||
logger: api.logger,
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
const scheduleStartupCronRetry = (): void => {
|
||||
@@ -1010,23 +944,17 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
if (!shouldHandleManagedDreaming && !hasCronManagementContext()) {
|
||||
return undefined;
|
||||
}
|
||||
await reconcileManagedDreamingCron({
|
||||
const config = await reconcileManagedDreamingCron({
|
||||
reason: "runtime",
|
||||
});
|
||||
if (!shouldHandleManagedDreaming) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = ctx.agentId ?? resolveDefaultAgentId(currentConfig);
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
cfg: currentConfig,
|
||||
agentId,
|
||||
});
|
||||
return await runShortTermDreamingPromotionIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: currentConfig,
|
||||
agentId,
|
||||
config,
|
||||
logger: api.logger,
|
||||
subagent: config.enabled ? api.runtime?.subagent : undefined,
|
||||
|
||||
@@ -71,20 +71,11 @@ const readAgentMemoryFileMock = vi.fn(
|
||||
vi.mock("./tools.runtime.js", () => ({
|
||||
resolveMemoryBackendConfig: ({
|
||||
cfg,
|
||||
agentId,
|
||||
}: {
|
||||
cfg?: {
|
||||
memory?: { qmd?: unknown };
|
||||
agents?: {
|
||||
list?: Array<{ id?: string; memory?: { qmd?: unknown } }>;
|
||||
};
|
||||
};
|
||||
agentId?: string;
|
||||
cfg?: { memory?: { backend?: string; qmd?: unknown } };
|
||||
}) => ({
|
||||
backend,
|
||||
qmd:
|
||||
cfg?.agents?.list?.find((agent) => agent.id === agentId)?.memory?.qmd ??
|
||||
cfg?.memory?.qmd,
|
||||
qmd: cfg?.memory?.qmd,
|
||||
}),
|
||||
getMemorySearchManager: getMemorySearchManagerMock,
|
||||
readAgentMemoryFile: readAgentMemoryFileMock,
|
||||
|
||||
@@ -385,36 +385,34 @@ describe("memory index", () => {
|
||||
hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number };
|
||||
}): TestCfg {
|
||||
return {
|
||||
memory: {
|
||||
search: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: { vector: { enabled: params.vectorEnabled ?? false } },
|
||||
// Perf: keep test indexes to a single chunk to reduce sqlite work.
|
||||
chunking: { tokens: 4000, overlap: 0 },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true },
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
nonBatchConcurrency: 1,
|
||||
batch: { enabled: true, pollIntervalMs: 0, timeoutMinutes: 1 },
|
||||
}
|
||||
: undefined,
|
||||
query: {
|
||||
minScore: params.minScore ?? 0,
|
||||
hybrid: params.hybrid ?? { enabled: false },
|
||||
},
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
experimental: { sessionMemory: params.sessionMemory ?? false },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
model: params.model ?? "mock-embed",
|
||||
fallback: params.fallback,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
store: { vector: { enabled: params.vectorEnabled ?? false } },
|
||||
// Perf: keep test indexes to a single chunk to reduce sqlite work.
|
||||
chunking: { tokens: 4000, overlap: 0 },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true },
|
||||
remote: params.batchEnabled
|
||||
? {
|
||||
nonBatchConcurrency: 1,
|
||||
batch: { enabled: true, pollIntervalMs: 0, timeoutMinutes: 1 },
|
||||
}
|
||||
: undefined,
|
||||
query: {
|
||||
minScore: params.minScore ?? 0,
|
||||
hybrid: params.hybrid ?? { enabled: false },
|
||||
},
|
||||
cache: params.cacheEnabled ? { enabled: true } : undefined,
|
||||
extraPaths: params.extraPaths,
|
||||
multimodal: params.multimodal,
|
||||
sources: params.sources,
|
||||
experimental: { sessionMemory: params.sessionMemory ?? false },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
createSubsystemLogger,
|
||||
onSessionTranscriptUpdate,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
resolveUserPath,
|
||||
type OpenClawConfig,
|
||||
@@ -156,7 +155,6 @@ const SOURCE_WIDE_SESSION_INDEX_FLUSH_FILES = 128;
|
||||
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
|
||||
const MEMORY_WATCH_PRESSURE_STARTUP_CHECK_DELAY_MS = 10_000;
|
||||
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
|
||||
".dreams",
|
||||
".git",
|
||||
"node_modules",
|
||||
".pnpm-store",
|
||||
@@ -934,7 +932,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
count,
|
||||
unit,
|
||||
"Large memory folders or extraPaths can make OpenClaw run out of file watchers or open files.",
|
||||
"Remove large extraPaths, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
"Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
(message) => log.warn(message),
|
||||
);
|
||||
}
|
||||
@@ -1788,18 +1786,11 @@ export abstract class MemoryManagerSyncOps {
|
||||
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
|
||||
: null;
|
||||
|
||||
const agentIds = new Set([
|
||||
this.agentId,
|
||||
...(this.cfg.agents?.list ?? [])
|
||||
.map((entry) => entry.id?.trim())
|
||||
.filter((agentId): agentId is string => Boolean(agentId)),
|
||||
]);
|
||||
const excludedRoots = Array.from(agentIds, (agentId) =>
|
||||
path.join(resolveAgentWorkspaceDir(this.cfg, agentId), "memory", ".dreams"),
|
||||
const files = await listMemoryFiles(
|
||||
this.workspaceDir,
|
||||
this.settings.extraPaths,
|
||||
this.settings.multimodal,
|
||||
);
|
||||
const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths, this.settings.multimodal, {
|
||||
excludedRoots,
|
||||
});
|
||||
const fileEntries = (
|
||||
await runWithConcurrency(
|
||||
files.map(
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
|
||||
@@ -62,23 +62,21 @@ describe("memory manager reindex recovery", () => {
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: params.provider ?? "openai",
|
||||
model: "mock-embed",
|
||||
store: { vector: { enabled: false } },
|
||||
chunking: { tokens: 4000, overlap: 0 },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
remote: { nonBatchConcurrency: 1 },
|
||||
cache: { enabled: false },
|
||||
sources: params.sources,
|
||||
experimental: { sessionMemory: params.sources?.includes("sessions") ?? false },
|
||||
},
|
||||
},
|
||||
memory: { backend: "builtin" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: params.provider ?? "openai",
|
||||
model: "mock-embed",
|
||||
store: { vector: { enabled: false } },
|
||||
chunking: { tokens: 4000, overlap: 0 },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
remote: { nonBatchConcurrency: 1 },
|
||||
cache: { enabled: false },
|
||||
sources: params.sources,
|
||||
experimental: { sessionMemory: params.sources?.includes("sessions") ?? false },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
@@ -200,6 +198,21 @@ describe("memory manager reindex recovery", () => {
|
||||
).toEqual(publishedRows);
|
||||
});
|
||||
|
||||
it("rejects a full reindex while another process owns the build lock", async () => {
|
||||
const memoryManager = await openManager(createCfg({ provider: "none", sources: ["memory"] }));
|
||||
const harness = memoryManager as unknown as ReindexHarness;
|
||||
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
|
||||
const lock = acquireMemoryReindexLock(databasePath);
|
||||
|
||||
try {
|
||||
await expect(harness.runInPlaceReindex({ reason: "test", force: true })).rejects.toThrow(
|
||||
/another reindex is active/,
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
it("forces source-wide session sync when retrying a failed full reindex", async () => {
|
||||
const memoryManager = await openManager(
|
||||
createCfg({
|
||||
@@ -230,26 +243,6 @@ describe("memory manager reindex recovery", () => {
|
||||
expect(harness.sessionsFullRetryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a full reindex while another process owns the build lock", async () => {
|
||||
const memoryManager = await openManager(
|
||||
createCfg({
|
||||
provider: "none",
|
||||
sources: ["memory"],
|
||||
}),
|
||||
);
|
||||
const harness = memoryManager as unknown as ReindexHarness;
|
||||
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
|
||||
const lock = acquireMemoryReindexLock(databasePath);
|
||||
|
||||
try {
|
||||
await expect(harness.runInPlaceReindex({ reason: "test", force: true })).rejects.toThrow(
|
||||
/another reindex is active/,
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the database after constructor schema failure", async () => {
|
||||
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
|
||||
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
||||
@@ -347,5 +340,4 @@ describe("memory manager reindex recovery", () => {
|
||||
expect(harness.dirty).toBe(false);
|
||||
expect(harness.memoryFullRetryDirty).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -77,19 +77,17 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
|
||||
? undefined
|
||||
: { vector: { enabled: params.vectorEnabled } };
|
||||
const cfg = {
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: params.provider ?? "auto",
|
||||
model: "",
|
||||
store,
|
||||
cache: { enabled: false },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
},
|
||||
},
|
||||
memory: { backend: "builtin" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: params.provider ?? "auto",
|
||||
model: "",
|
||||
store,
|
||||
cache: { enabled: false },
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ function resolveConfiguredMemoryEmbeddingProvider(params: {
|
||||
const agentEntry = params.cfg.agents?.list?.find(
|
||||
(entry) => entry && normalizeAgentId(entry.id) === normalizedAgentId,
|
||||
);
|
||||
return agentEntry?.memory?.search?.provider ?? params.cfg.memory?.search?.provider;
|
||||
return agentEntry?.memorySearch?.provider ?? params.cfg.agents?.defaults?.memorySearch?.provider;
|
||||
}
|
||||
|
||||
function resolveMemoryEmbeddingProviderRequirement(params: {
|
||||
|
||||
@@ -199,6 +199,7 @@ describe("memory watcher config", () => {
|
||||
}
|
||||
await closeAllMemorySearchManagers();
|
||||
clearRegistry();
|
||||
vi.unstubAllEnvs();
|
||||
if (workspaceDir) {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
workspaceDir = "";
|
||||
@@ -208,6 +209,7 @@ describe("memory watcher config", () => {
|
||||
|
||||
async function setupWatcherWorkspace(seedFile: { name: string; contents: string }) {
|
||||
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
|
||||
extraDir = path.join(workspaceDir, "extra");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
@@ -215,23 +217,22 @@ describe("memory watcher config", () => {
|
||||
}
|
||||
|
||||
function createWatcherConfig(overrides?: Partial<MemorySearchConfig>): OpenClawConfig {
|
||||
return {
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
extraPaths: [extraDir],
|
||||
...overrides,
|
||||
},
|
||||
const defaults: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> = {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
extraPaths: [extraDir],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
return {
|
||||
memory: { backend: "builtin" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
defaults,
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
@@ -289,9 +290,6 @@ describe("memory watcher config", () => {
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "node_modules", "pkg", "index.md"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
ignored?.(path.join(workspaceDir, "memory", ".dreams", "agents", "writer", "DREAMS.md")),
|
||||
).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);
|
||||
|
||||
@@ -175,6 +175,9 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
cfg = {
|
||||
agents: {
|
||||
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
@@ -183,9 +186,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,6 @@ const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState");
|
||||
const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
|
||||
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
|
||||
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
|
||||
".dreams",
|
||||
".git",
|
||||
".cache",
|
||||
"node_modules",
|
||||
@@ -496,7 +495,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
// default models directory into our custom cache so the index stays
|
||||
// isolated while models are shared.
|
||||
await this.symlinkSharedModels();
|
||||
await this.refreshManagedCollectionIndexConfig();
|
||||
|
||||
await this.ensureCollections();
|
||||
if (mode === "cli") {
|
||||
@@ -632,10 +630,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// QMD collection add and rebind rewrite collection entries, dropping managed
|
||||
// fields such as private artifact ignore patterns.
|
||||
await this.refreshManagedCollectionIndexConfig();
|
||||
}
|
||||
|
||||
private async tryRebindSameNameCollection(params: {
|
||||
@@ -1168,12 +1162,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
` path: ${this.quoteYamlString(collection.path)}`,
|
||||
` pattern: ${this.quoteYamlString(collection.pattern)}`,
|
||||
);
|
||||
if (collection.ignore?.length) {
|
||||
lines.push(" ignore:");
|
||||
for (const pattern of collection.ignore) {
|
||||
lines.push(` - ${this.quoteYamlString(pattern)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -1230,14 +1218,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
// QMD collection add rewrites entries and drops managed fields such as ignore.
|
||||
await this.refreshManagedCollectionIndexConfig();
|
||||
} catch (configErr) {
|
||||
log.warn(
|
||||
`qmd managed collection index refresh failed after update repair (${reason}): ${formatErrorMessage(configErr)}`,
|
||||
);
|
||||
}
|
||||
log.warn(`qmd managed collections rebuilt for update repair (${reason})`);
|
||||
}
|
||||
|
||||
@@ -1783,7 +1763,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
count,
|
||||
"paths",
|
||||
"Large QMD collections can make OpenClaw run out of file watchers or open files.",
|
||||
"Remove large collections, or set memory.search.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
"Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
(message) => log.warn(message),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,32 +153,27 @@ function createQmdCfg(
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
memory: { backend: "qmd", qmd },
|
||||
agents: {
|
||||
list: [{ id: agentId, default: true, workspace }],
|
||||
},
|
||||
agents: { list: [{ id: agentId, default: true, workspace }] },
|
||||
};
|
||||
}
|
||||
|
||||
function createBuiltinCfg(agentId: string): OpenClawConfig {
|
||||
return {
|
||||
memory: {
|
||||
search: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
store: {
|
||||
path: "/tmp/index.sqlite",
|
||||
vector: { enabled: false },
|
||||
},
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
sources: ["memory"],
|
||||
experimental: { sessionMemory: false },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/tmp/workspace",
|
||||
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
store: {
|
||||
path: "/tmp/index.sqlite",
|
||||
vector: { enabled: false },
|
||||
},
|
||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
sources: ["memory"],
|
||||
experimental: { sessionMemory: false },
|
||||
},
|
||||
},
|
||||
list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }],
|
||||
},
|
||||
@@ -503,9 +498,7 @@ describe("getMemorySearchManager caching", () => {
|
||||
const agentId = "missing-workspace";
|
||||
const cfg = {
|
||||
memory: { backend: "qmd", qmd: {} },
|
||||
agents: {
|
||||
list: [{ id: agentId, default: true, workspace }],
|
||||
},
|
||||
agents: { list: [{ id: agentId, default: true, workspace }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,17 +38,13 @@ describe("listMemoryCorePublicArtifacts", () => {
|
||||
"# Dream Report\n",
|
||||
"utf8",
|
||||
);
|
||||
await appendMemoryHostEvent(
|
||||
workspaceDir,
|
||||
{
|
||||
type: "memory.recall.recorded",
|
||||
timestamp: "2026-04-06T12:00:00.000Z",
|
||||
query: "alpha",
|
||||
resultCount: 0,
|
||||
results: [],
|
||||
},
|
||||
"main",
|
||||
);
|
||||
await appendMemoryHostEvent(workspaceDir, {
|
||||
type: "memory.recall.recorded",
|
||||
timestamp: "2026-04-06T12:00:00.000Z",
|
||||
query: "alpha",
|
||||
resultCount: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -84,8 +80,8 @@ describe("listMemoryCorePublicArtifacts", () => {
|
||||
{
|
||||
kind: "event-log",
|
||||
workspaceDir,
|
||||
relativePath: "memory/.dreams/agents/main/events.jsonl",
|
||||
absolutePath: resolveMemoryHostEventLogPath(workspaceDir, "main"),
|
||||
relativePath: "memory/.dreams/events.jsonl",
|
||||
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
|
||||
agentIds: ["main"],
|
||||
contentType: "json",
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { OpenClawConfig } from "../api.js";
|
||||
|
||||
export async function listMemoryCorePublicArtifacts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): Promise<MemoryPluginPublicArtifact[]> {
|
||||
return await listMemoryHostPublicArtifacts(params);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ type MemoryRemHarnessDeepConfig = ReturnType<typeof resolveMemoryDeepDreamingCon
|
||||
export type PreviewRemHarnessOptions = {
|
||||
workspaceDir: string;
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
grounded?: boolean;
|
||||
groundedInputPaths?: string[];
|
||||
@@ -124,16 +123,13 @@ export async function previewRemHarness(
|
||||
const remConfig = resolveMemoryRemDreamingConfig({
|
||||
pluginConfig: params.pluginConfig,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const deepConfig = resolveMemoryDeepDreamingConfig({
|
||||
pluginConfig: params.pluginConfig,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const allRecallEntries = await readShortTermRecallEntries({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
nowMs,
|
||||
});
|
||||
const recallEntries = await filterLiveShortTermRecallEntries({
|
||||
@@ -175,7 +171,6 @@ export async function previewRemHarness(
|
||||
const candidateLimit = normalizeOptionalPositiveLimit(params.candidateLimit);
|
||||
const rankedCandidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
|
||||
@@ -1425,101 +1425,6 @@ describe("short-term promotion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes shared MEMORY.md writes across symlinked agent workspaces", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
|
||||
"Research promotion.",
|
||||
"Writer promotion.",
|
||||
]);
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
const aliasWorkspaceDir = path.join(fixtureRoot, `case-${caseId++}-alias`);
|
||||
const aliasMemoryPath = path.join(aliasWorkspaceDir, "MEMORY.md");
|
||||
await fs.symlink(workspaceDir, aliasWorkspaceDir);
|
||||
const originalReadFile = fs.readFile.bind(fs);
|
||||
let memoryReadCount = 0;
|
||||
let releaseFirstMemoryRead!: () => void;
|
||||
const firstMemoryRead = new Promise<void>((resolve) => {
|
||||
releaseFirstMemoryRead = resolve;
|
||||
});
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (filePath, options) => {
|
||||
if (filePath === memoryPath || filePath === aliasMemoryPath) {
|
||||
memoryReadCount += 1;
|
||||
if (memoryReadCount === 1) {
|
||||
await firstMemoryRead;
|
||||
}
|
||||
}
|
||||
return await originalReadFile(filePath, options);
|
||||
});
|
||||
|
||||
const candidate = (params: { key: string; line: number; snippet: string }) => ({
|
||||
key: params.key,
|
||||
path: "memory/2026-04-03.md",
|
||||
startLine: params.line,
|
||||
endLine: params.line,
|
||||
source: "memory" as const,
|
||||
snippet: params.snippet,
|
||||
recallCount: 1,
|
||||
avgScore: 1,
|
||||
maxScore: 1,
|
||||
uniqueQueries: 1,
|
||||
firstRecalledAt: "2026-04-03T10:00:00.000Z",
|
||||
lastRecalledAt: "2026-04-03T10:00:00.000Z",
|
||||
ageDays: 0,
|
||||
score: 1,
|
||||
recallDays: ["2026-04-03"],
|
||||
conceptTags: [],
|
||||
components: {
|
||||
frequency: 1,
|
||||
relevance: 1,
|
||||
diversity: 1,
|
||||
recency: 1,
|
||||
consolidation: 1,
|
||||
conceptual: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const research = applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
agentId: "research",
|
||||
candidates: [
|
||||
candidate({
|
||||
key: "memory:memory/2026-04-03.md:1:1",
|
||||
line: 1,
|
||||
snippet: "Research promotion.",
|
||||
}),
|
||||
],
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
await vi.waitFor(() => expect(memoryReadCount).toBe(1));
|
||||
const writer = applyShortTermPromotions({
|
||||
workspaceDir: aliasWorkspaceDir,
|
||||
agentId: "writer",
|
||||
candidates: [
|
||||
candidate({
|
||||
key: "memory:memory/2026-04-03.md:2:2",
|
||||
line: 2,
|
||||
snippet: "Writer promotion.",
|
||||
}),
|
||||
],
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
expect(memoryReadCount).toBe(1);
|
||||
releaseFirstMemoryRead();
|
||||
await Promise.all([research, writer]);
|
||||
|
||||
await expect(fs.readFile(memoryPath, "utf8")).resolves.toContain("Research promotion.");
|
||||
await expect(fs.readFile(memoryPath, "utf8")).resolves.toContain("Writer promotion.");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rank contaminated dreaming snippets from an existing short-term store", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await testing.writeRawRecallStore(workspaceDir, {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { asRecord, formatErrorMessage } from "./dreaming-shared.js";
|
||||
import {
|
||||
SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
SHORT_TERM_LOCK_NAMESPACE,
|
||||
SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
|
||||
SHORT_TERM_META_NAMESPACE,
|
||||
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
SHORT_TERM_RECALL_NAMESPACE,
|
||||
@@ -41,7 +40,7 @@ import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
|
||||
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
|
||||
const DREAMING_MEMORY_PATH_RE = /(?:^|\/)memory\/dreaming\//;
|
||||
const SHORT_TERM_SESSION_CORPUS_RE =
|
||||
/(?:^|\/)memory\/\.dreams\/(?:agents\/[^/]+\/)?session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/;
|
||||
/(?:^|\/)memory\/\.dreams\/session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/;
|
||||
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_RECENCY_HALF_LIFE_DAYS = 14;
|
||||
@@ -237,7 +236,6 @@ export type RepairShortTermPromotionArtifactsResult = {
|
||||
|
||||
type RankShortTermPromotionOptions = {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
minScore?: number;
|
||||
minRecallCount?: number;
|
||||
@@ -251,7 +249,6 @@ type RankShortTermPromotionOptions = {
|
||||
|
||||
type ApplyShortTermPromotionsOptions = {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
candidates: PromotionCandidate[];
|
||||
limit?: number;
|
||||
minScore?: number;
|
||||
@@ -804,16 +801,16 @@ function calculatePhaseSignalBoost(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStorePath(workspaceDir: string, agentId?: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir, agentId);
|
||||
function resolveStorePath(workspaceDir: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir);
|
||||
}
|
||||
|
||||
function resolvePhaseSignalPath(workspaceDir: string, agentId?: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir, agentId);
|
||||
function resolvePhaseSignalPath(workspaceDir: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir);
|
||||
}
|
||||
|
||||
function resolveLockPath(workspaceDir: string, agentId?: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir, agentId);
|
||||
function resolveLockPath(workspaceDir: string): string {
|
||||
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir);
|
||||
}
|
||||
|
||||
function parseLockOwnerPid(raw: string): number | null {
|
||||
@@ -879,13 +876,9 @@ async function withInProcessShortTermLock<T>(lockPath: string, task: () => Promi
|
||||
}
|
||||
}
|
||||
|
||||
async function withShortTermLock<T>(
|
||||
workspaceDir: string,
|
||||
agentId: string | undefined,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, agentId);
|
||||
const lockRef = resolveLockPath(workspaceDir, agentId);
|
||||
async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>): Promise<T> {
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
|
||||
const lockRef = resolveLockPath(workspaceDir);
|
||||
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
@@ -928,76 +921,15 @@ async function withShortTermLock<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function withMemoryFileWriteLock<T>(
|
||||
workspaceDir: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const physicalWorkspaceDir = await fs.realpath(workspaceDir);
|
||||
const lockKey = memoryCoreWorkspaceStateKey(physicalWorkspaceDir);
|
||||
const lockRef = memoryCoreStateReference(
|
||||
SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
|
||||
physicalWorkspaceDir,
|
||||
);
|
||||
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
});
|
||||
return await withInProcessShortTermLock(
|
||||
`${SHORT_TERM_MEMORY_FILE_LOCK_NAMESPACE}:${lockKey}`,
|
||||
async () => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (true) {
|
||||
const owner = `${process.pid}:${Date.now()}`;
|
||||
const acquired = await lockStore.registerIfAbsent(lockKey, {
|
||||
owner,
|
||||
acquiredAt: Date.now(),
|
||||
});
|
||||
if (acquired) {
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
const current = await lockStore.lookup(lockKey).catch(() => undefined);
|
||||
if (current?.owner === owner) {
|
||||
await lockStore.delete(lockKey).catch(() => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await lockStore.lookup(lockKey);
|
||||
if (existing && Date.now() - existing.acquiredAt > SHORT_TERM_LOCK_STALE_MS) {
|
||||
const ownerPid = parseLockOwnerPid(existing.owner);
|
||||
if (ownerPid === null || !isProcessLikelyAlive(ownerPid)) {
|
||||
await lockStore.delete(lockKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt >= SHORT_TERM_LOCK_WAIT_TIMEOUT_MS) {
|
||||
throw new Error(`Timed out waiting for shared MEMORY.md write lock at ${lockRef}`);
|
||||
}
|
||||
|
||||
await sleep(SHORT_TERM_LOCK_RETRY_DELAY_MS);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readStore(
|
||||
workspaceDir: string,
|
||||
nowIso: string,
|
||||
agentId?: string,
|
||||
): Promise<ShortTermRecallStore> {
|
||||
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
|
||||
const [entryRows, metaRows] = await Promise.all([
|
||||
readMemoryCoreWorkspaceEntries<ShortTermRecallEntry>({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
]);
|
||||
const meta = metaRows.find((entry) => entry.key === "recall")?.value;
|
||||
@@ -1079,18 +1011,15 @@ export function normalizeShortTermPhaseSignalStore(
|
||||
async function readPhaseSignalStore(
|
||||
workspaceDir: string,
|
||||
nowIso: string,
|
||||
agentId?: string,
|
||||
): Promise<ShortTermPhaseSignalStore> {
|
||||
const [entryRows, metaRows] = await Promise.all([
|
||||
readMemoryCoreWorkspaceEntries<ShortTermPhaseSignalEntry>({
|
||||
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
}),
|
||||
]);
|
||||
const meta = metaRows.find((entry) => entry.key === "phase")?.value;
|
||||
@@ -1107,43 +1036,34 @@ async function readPhaseSignalStore(
|
||||
async function writePhaseSignalStore(
|
||||
workspaceDir: string,
|
||||
store: ShortTermPhaseSignalStore,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
key: "phase",
|
||||
value: { updatedAt: store.updatedAt },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function writeStore(
|
||||
workspaceDir: string,
|
||||
store: ShortTermRecallStore,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
|
||||
enforceShortTermRecallSnippetCap(store);
|
||||
enforceShortTermRecallStoreRetention(store);
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
key: "recall",
|
||||
value: { updatedAt: store.updatedAt },
|
||||
}),
|
||||
@@ -1272,17 +1192,16 @@ function trimDreamingStatsEntries(
|
||||
|
||||
export async function loadShortTermPromotionDreamingStats(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
}): Promise<ShortTermDreamingStats> {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
const nowIso = new Date(params.nowMs).toISOString();
|
||||
const store = await readStore(workspaceDir, nowIso, params.agentId);
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
let phaseSignalError: string | undefined;
|
||||
let phaseStore: ShortTermPhaseSignalStore;
|
||||
try {
|
||||
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
|
||||
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso);
|
||||
} catch (err) {
|
||||
phaseSignalError = formatErrorMessage(err);
|
||||
phaseStore = emptyPhaseSignalStore(nowIso);
|
||||
@@ -1383,8 +1302,8 @@ export async function loadShortTermPromotionDreamingStats(params: {
|
||||
remPhaseHitCount,
|
||||
promotedTotal,
|
||||
promotedToday,
|
||||
storePath: resolveStorePath(workspaceDir, params.agentId),
|
||||
phaseSignalPath: resolvePhaseSignalPath(workspaceDir, params.agentId),
|
||||
storePath: resolveStorePath(workspaceDir),
|
||||
phaseSignalPath: resolvePhaseSignalPath(workspaceDir),
|
||||
shortTermEntries: trimDreamingStatsEntries(
|
||||
shortTermEntries,
|
||||
compareDreamingStatsEntryByRecency,
|
||||
@@ -1461,7 +1380,6 @@ function buildMemoryRecallSkippedEvent(params: {
|
||||
|
||||
export async function recordShortTermRecalls(params: {
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
query: string;
|
||||
results: MemorySearchResult[];
|
||||
signalType?: "recall" | "daily";
|
||||
@@ -1496,7 +1414,6 @@ export async function recordShortTermRecalls(params: {
|
||||
eligibleResultCount: relevant.length,
|
||||
skipped,
|
||||
}),
|
||||
params.agentId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1504,8 +1421,8 @@ export async function recordShortTermRecalls(params: {
|
||||
const queryHash = hashQuery(query);
|
||||
const todayBucket =
|
||||
normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso, params.agentId);
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
|
||||
for (const result of relevant) {
|
||||
const normalizedPath = normalizeMemoryPath(result.path);
|
||||
@@ -1571,23 +1488,19 @@ export async function recordShortTermRecalls(params: {
|
||||
}
|
||||
|
||||
store.updatedAt = nowIso;
|
||||
await writeStore(workspaceDir, store, params.agentId);
|
||||
await appendMemoryHostEvent(
|
||||
workspaceDir,
|
||||
{
|
||||
type: "memory.recall.recorded",
|
||||
timestamp: nowIso,
|
||||
query,
|
||||
resultCount: relevant.length,
|
||||
results: relevant.map((result) => ({
|
||||
path: normalizeMemoryPath(result.path),
|
||||
startLine: Math.max(1, Math.floor(result.startLine)),
|
||||
endLine: Math.max(1, Math.floor(result.endLine)),
|
||||
score: clampScore(result.score),
|
||||
})),
|
||||
},
|
||||
params.agentId,
|
||||
);
|
||||
await writeStore(workspaceDir, store);
|
||||
await appendMemoryHostEvent(workspaceDir, {
|
||||
type: "memory.recall.recorded",
|
||||
timestamp: nowIso,
|
||||
query,
|
||||
resultCount: relevant.length,
|
||||
results: relevant.map((result) => ({
|
||||
path: normalizeMemoryPath(result.path),
|
||||
startLine: Math.max(1, Math.floor(result.startLine)),
|
||||
endLine: Math.max(1, Math.floor(result.endLine)),
|
||||
score: clampScore(result.score),
|
||||
})),
|
||||
});
|
||||
if (skipped.length > 0) {
|
||||
await appendMemoryHostEvent(
|
||||
workspaceDir,
|
||||
@@ -1597,7 +1510,6 @@ export async function recordShortTermRecalls(params: {
|
||||
eligibleResultCount: relevant.length,
|
||||
skipped,
|
||||
}),
|
||||
params.agentId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1605,7 +1517,6 @@ export async function recordShortTermRecalls(params: {
|
||||
|
||||
export async function recordGroundedShortTermCandidates(params: {
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
query: string;
|
||||
items: Array<{
|
||||
path: string;
|
||||
@@ -1665,8 +1576,8 @@ export async function recordGroundedShortTermCandidates(params: {
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const fallbackDayBucket = formatMemoryDreamingDay(nowMs, params.timezone);
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso, params.agentId);
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
|
||||
for (const item of relevant) {
|
||||
const dayBucket = item.dayBucket ?? fallbackDayBucket;
|
||||
@@ -1726,13 +1637,12 @@ export async function recordGroundedShortTermCandidates(params: {
|
||||
}
|
||||
|
||||
store.updatedAt = nowIso;
|
||||
await writeStore(workspaceDir, store, params.agentId);
|
||||
await writeStore(workspaceDir, store);
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordDreamingPhaseSignals(params: {
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
phase: "light" | "rem";
|
||||
keys: string[];
|
||||
nowMs?: number;
|
||||
@@ -1748,10 +1658,10 @@ export async function recordDreamingPhaseSignals(params: {
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
readStore(workspaceDir, nowIso, params.agentId),
|
||||
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
|
||||
readStore(workspaceDir, nowIso),
|
||||
readPhaseSignalStore(workspaceDir, nowIso),
|
||||
]);
|
||||
const knownKeys = new Set(Object.keys(store.entries));
|
||||
|
||||
@@ -1781,13 +1691,12 @@ export async function recordDreamingPhaseSignals(params: {
|
||||
}
|
||||
|
||||
phaseSignals.updatedAt = nowIso;
|
||||
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
|
||||
await writePhaseSignalStore(workspaceDir, phaseSignals);
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordRemConsideredPhaseSignals(params: {
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
keys: string[];
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
@@ -1802,10 +1711,10 @@ export async function recordRemConsideredPhaseSignals(params: {
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
readStore(workspaceDir, nowIso, params.agentId),
|
||||
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
|
||||
readStore(workspaceDir, nowIso),
|
||||
readPhaseSignalStore(workspaceDir, nowIso),
|
||||
]);
|
||||
const knownKeys = new Set(Object.keys(store.entries));
|
||||
|
||||
@@ -1829,13 +1738,12 @@ export async function recordRemConsideredPhaseSignals(params: {
|
||||
}
|
||||
|
||||
phaseSignals.updatedAt = nowIso;
|
||||
await writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId);
|
||||
await writePhaseSignalStore(workspaceDir, phaseSignals);
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLightStagedKeys(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
nowMs?: number;
|
||||
}): Promise<Set<string>> {
|
||||
const workspaceDir = params.workspaceDir?.trim();
|
||||
@@ -1844,7 +1752,7 @@ export async function readLightStagedKeys(params: {
|
||||
}
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const store = await readPhaseSignalStore(workspaceDir, nowIso, params.agentId);
|
||||
const store = await readPhaseSignalStore(workspaceDir, nowIso);
|
||||
const keys = new Set<string>();
|
||||
for (const [key, entry] of Object.entries(store.entries)) {
|
||||
if (entry.lightHits <= 0) {
|
||||
@@ -1895,8 +1803,8 @@ export async function rankShortTermPromotionCandidates(
|
||||
const weights = normalizeWeights(options.weights);
|
||||
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
readStore(workspaceDir, nowIso, options.agentId),
|
||||
readPhaseSignalStore(workspaceDir, nowIso, options.agentId),
|
||||
readStore(workspaceDir, nowIso),
|
||||
readPhaseSignalStore(workspaceDir, nowIso),
|
||||
]);
|
||||
const candidates: PromotionCandidate[] = [];
|
||||
|
||||
@@ -2010,7 +1918,6 @@ export async function rankShortTermPromotionCandidates(
|
||||
|
||||
export async function readShortTermRecallEntries(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
nowMs?: number;
|
||||
}): Promise<ShortTermRecallEntry[]> {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
@@ -2019,7 +1926,7 @@ export async function readShortTermRecallEntries(params: {
|
||||
}
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const store = await readStore(workspaceDir, nowIso, params.agentId);
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
return Object.values(store.entries).filter(
|
||||
(entry): entry is ShortTermRecallEntry =>
|
||||
Boolean(entry) && entry.source === "memory" && isShortTermMemoryPath(entry.path),
|
||||
@@ -2477,174 +2384,162 @@ export async function applyShortTermPromotions(
|
||||
const maxAgeDays = toFiniteNonNegativeInt(options.maxAgeDays, -1);
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
|
||||
return await withShortTermLock(
|
||||
workspaceDir,
|
||||
options.agentId,
|
||||
async () =>
|
||||
await withMemoryFileWriteLock(workspaceDir, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso, options.agentId);
|
||||
const selected = options.candidates
|
||||
.filter((candidate) => {
|
||||
if (isContaminatedDreamingSnippet(candidate.snippet)) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.promotedAt) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.score < minScore) {
|
||||
return false;
|
||||
}
|
||||
const candidateSignalCount = Math.max(
|
||||
0,
|
||||
candidate.signalCount ??
|
||||
totalSignalCountForEntry({
|
||||
recallCount: candidate.recallCount,
|
||||
dailyCount: candidate.dailyCount,
|
||||
groundedCount: candidate.groundedCount,
|
||||
}),
|
||||
);
|
||||
if (candidateSignalCount < minRecallCount) {
|
||||
return false;
|
||||
}
|
||||
if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
|
||||
return false;
|
||||
}
|
||||
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
|
||||
return false;
|
||||
}
|
||||
const latest = store.entries[candidate.key];
|
||||
if (latest?.promotedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
const rehydratedSelected: PromotionCandidate[] = [];
|
||||
for (const candidate of selected) {
|
||||
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
|
||||
if (rehydrated && !isContaminatedDreamingSnippet(rehydrated.snippet)) {
|
||||
rehydratedSelected.push(rehydrated);
|
||||
}
|
||||
return await withShortTermLock(workspaceDir, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
const selected = options.candidates
|
||||
.filter((candidate) => {
|
||||
if (isContaminatedDreamingSnippet(candidate.snippet)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rehydratedSelected.length === 0) {
|
||||
return {
|
||||
memoryPath,
|
||||
applied: 0,
|
||||
appended: 0,
|
||||
reconciledExisting: 0,
|
||||
appliedCandidates: [],
|
||||
compactedSections: 0,
|
||||
compactedDates: [],
|
||||
};
|
||||
if (candidate.promotedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingMemory = await fs.readFile(memoryPath, "utf-8").catch((err: unknown) => {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return "";
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const existingMarkers = extractPromotionMarkers(existingMemory);
|
||||
const alreadyWritten = rehydratedSelected.filter((candidate) =>
|
||||
existingMarkers.has(candidate.key),
|
||||
);
|
||||
const toAppend = rehydratedSelected.filter(
|
||||
(candidate) => !existingMarkers.has(candidate.key),
|
||||
);
|
||||
|
||||
let compactedDates: string[] = [];
|
||||
if (toAppend.length > 0) {
|
||||
const section = buildPromotionSection(
|
||||
toAppend,
|
||||
nowMs,
|
||||
options.timezone,
|
||||
options.maxPromotedSnippetTokens,
|
||||
);
|
||||
const budgetChars =
|
||||
typeof options.memoryFileMaxChars === "number" &&
|
||||
Number.isFinite(options.memoryFileMaxChars)
|
||||
? Math.max(0, Math.floor(options.memoryFileMaxChars))
|
||||
: DEFAULT_MEMORY_FILE_MAX_CHARS;
|
||||
const compaction = compactMemoryForBudget({
|
||||
existingMemory,
|
||||
newSection: section,
|
||||
budgetChars,
|
||||
});
|
||||
compactedDates = compaction.droppedDates;
|
||||
const baseMemory = compaction.compacted;
|
||||
const header = baseMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
|
||||
await fs.writeFile(
|
||||
memoryPath,
|
||||
`${header}${withTrailingNewline(baseMemory)}${section}`,
|
||||
"utf-8",
|
||||
);
|
||||
if (candidate.score < minScore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const candidate of rehydratedSelected) {
|
||||
const entry = store.entries[candidate.key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
entry.startLine = candidate.startLine;
|
||||
entry.endLine = candidate.endLine;
|
||||
entry.snippet = candidate.snippet;
|
||||
entry.promotedAt = nowIso;
|
||||
}
|
||||
store.updatedAt = nowIso;
|
||||
await writeStore(workspaceDir, store, options.agentId);
|
||||
await appendMemoryHostEvent(
|
||||
workspaceDir,
|
||||
{
|
||||
type: "memory.promotion.applied",
|
||||
timestamp: nowIso,
|
||||
memoryPath,
|
||||
applied: rehydratedSelected.length,
|
||||
candidates: rehydratedSelected.map((candidate) => ({
|
||||
key: candidate.key,
|
||||
path: candidate.path,
|
||||
startLine: candidate.startLine,
|
||||
endLine: candidate.endLine,
|
||||
score: candidate.score,
|
||||
const candidateSignalCount = Math.max(
|
||||
0,
|
||||
candidate.signalCount ??
|
||||
totalSignalCountForEntry({
|
||||
recallCount: candidate.recallCount,
|
||||
})),
|
||||
},
|
||||
options.agentId,
|
||||
dailyCount: candidate.dailyCount,
|
||||
groundedCount: candidate.groundedCount,
|
||||
}),
|
||||
);
|
||||
if (candidateSignalCount < minRecallCount) {
|
||||
return false;
|
||||
}
|
||||
if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
|
||||
return false;
|
||||
}
|
||||
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
|
||||
return false;
|
||||
}
|
||||
const latest = store.entries[candidate.key];
|
||||
if (latest?.promotedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
memoryPath,
|
||||
applied: rehydratedSelected.length,
|
||||
appended: toAppend.length,
|
||||
reconciledExisting: alreadyWritten.length,
|
||||
appliedCandidates: rehydratedSelected,
|
||||
compactedSections: compactedDates.length,
|
||||
compactedDates,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const rehydratedSelected: PromotionCandidate[] = [];
|
||||
for (const candidate of selected) {
|
||||
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
|
||||
if (rehydrated && !isContaminatedDreamingSnippet(rehydrated.snippet)) {
|
||||
rehydratedSelected.push(rehydrated);
|
||||
}
|
||||
}
|
||||
|
||||
if (rehydratedSelected.length === 0) {
|
||||
return {
|
||||
memoryPath,
|
||||
applied: 0,
|
||||
appended: 0,
|
||||
reconciledExisting: 0,
|
||||
appliedCandidates: [],
|
||||
compactedSections: 0,
|
||||
compactedDates: [],
|
||||
};
|
||||
}
|
||||
|
||||
const existingMemory = await fs.readFile(memoryPath, "utf-8").catch((err: unknown) => {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return "";
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const existingMarkers = extractPromotionMarkers(existingMemory);
|
||||
const alreadyWritten = rehydratedSelected.filter((candidate) =>
|
||||
existingMarkers.has(candidate.key),
|
||||
);
|
||||
const toAppend = rehydratedSelected.filter((candidate) => !existingMarkers.has(candidate.key));
|
||||
|
||||
let compactedDates: string[] = [];
|
||||
if (toAppend.length > 0) {
|
||||
const section = buildPromotionSection(
|
||||
toAppend,
|
||||
nowMs,
|
||||
options.timezone,
|
||||
options.maxPromotedSnippetTokens,
|
||||
);
|
||||
const budgetChars =
|
||||
typeof options.memoryFileMaxChars === "number" &&
|
||||
Number.isFinite(options.memoryFileMaxChars)
|
||||
? Math.max(0, Math.floor(options.memoryFileMaxChars))
|
||||
: DEFAULT_MEMORY_FILE_MAX_CHARS;
|
||||
const compaction = compactMemoryForBudget({
|
||||
existingMemory,
|
||||
newSection: section,
|
||||
budgetChars,
|
||||
});
|
||||
compactedDates = compaction.droppedDates;
|
||||
const baseMemory = compaction.compacted;
|
||||
const header = baseMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
|
||||
await fs.writeFile(
|
||||
memoryPath,
|
||||
`${header}${withTrailingNewline(baseMemory)}${section}`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of rehydratedSelected) {
|
||||
const entry = store.entries[candidate.key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
entry.startLine = candidate.startLine;
|
||||
entry.endLine = candidate.endLine;
|
||||
entry.snippet = candidate.snippet;
|
||||
entry.promotedAt = nowIso;
|
||||
}
|
||||
store.updatedAt = nowIso;
|
||||
await writeStore(workspaceDir, store);
|
||||
await appendMemoryHostEvent(workspaceDir, {
|
||||
type: "memory.promotion.applied",
|
||||
timestamp: nowIso,
|
||||
memoryPath,
|
||||
applied: rehydratedSelected.length,
|
||||
candidates: rehydratedSelected.map((candidate) => ({
|
||||
key: candidate.key,
|
||||
path: candidate.path,
|
||||
startLine: candidate.startLine,
|
||||
endLine: candidate.endLine,
|
||||
score: candidate.score,
|
||||
recallCount: candidate.recallCount,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
memoryPath,
|
||||
applied: rehydratedSelected.length,
|
||||
appended: toAppend.length,
|
||||
reconciledExisting: alreadyWritten.length,
|
||||
appliedCandidates: rehydratedSelected,
|
||||
compactedSections: compactedDates.length,
|
||||
compactedDates,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveShortTermRecallStorePath(workspaceDir: string, agentId?: string): string {
|
||||
return resolveStorePath(workspaceDir, agentId);
|
||||
export function resolveShortTermRecallStorePath(workspaceDir: string): string {
|
||||
return resolveStorePath(workspaceDir);
|
||||
}
|
||||
|
||||
export function resolveShortTermRecallLockPath(workspaceDir: string, agentId?: string): string {
|
||||
return resolveLockPath(workspaceDir, agentId);
|
||||
export function resolveShortTermRecallLockPath(workspaceDir: string): string {
|
||||
return resolveLockPath(workspaceDir);
|
||||
}
|
||||
|
||||
export async function auditShortTermPromotionArtifacts(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
qmd?: {
|
||||
dbPath?: string;
|
||||
collections?: number;
|
||||
};
|
||||
}): Promise<ShortTermAuditSummary> {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
const storePath = resolveStorePath(workspaceDir, params.agentId);
|
||||
const lockPath = resolveLockPath(workspaceDir, params.agentId);
|
||||
const storePath = resolveStorePath(workspaceDir);
|
||||
const lockPath = resolveLockPath(workspaceDir);
|
||||
const issues: ShortTermAuditIssue[] = [];
|
||||
let entryCount = 0;
|
||||
let promotedCount = 0;
|
||||
@@ -2658,7 +2553,6 @@ export async function auditShortTermPromotionArtifacts(params: {
|
||||
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const exists = rawEntries.length > 0;
|
||||
if (exists) {
|
||||
@@ -2704,7 +2598,7 @@ export async function auditShortTermPromotionArtifacts(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
|
||||
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
@@ -2787,7 +2681,6 @@ export async function auditShortTermPromotionArtifacts(params: {
|
||||
|
||||
export async function repairShortTermPromotionArtifacts(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<RepairShortTermPromotionArtifactsResult> {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
const nowIso = new Date().toISOString();
|
||||
@@ -2796,7 +2689,7 @@ export async function repairShortTermPromotionArtifacts(params: {
|
||||
let removedOverflowEntries = 0;
|
||||
let removedStaleLock = false;
|
||||
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir, params.agentId);
|
||||
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
|
||||
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
@@ -2809,11 +2702,10 @@ export async function repairShortTermPromotionArtifacts(params: {
|
||||
}
|
||||
}
|
||||
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (rawEntries.length > 0) {
|
||||
const normalized = normalizeShortTermRecallStore(
|
||||
@@ -2862,14 +2754,10 @@ export async function repairShortTermPromotionArtifacts(params: {
|
||||
removedOverflowEntries > 0 ||
|
||||
JSON.stringify(normalized.entries) !== JSON.stringify(comparableStore.entries);
|
||||
if (needsRewrite) {
|
||||
await writeStore(
|
||||
workspaceDir,
|
||||
{
|
||||
...comparableStore,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
params.agentId,
|
||||
);
|
||||
await writeStore(workspaceDir, {
|
||||
...comparableStore,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
rewroteStore = true;
|
||||
}
|
||||
}
|
||||
@@ -2886,17 +2774,16 @@ export async function repairShortTermPromotionArtifacts(params: {
|
||||
|
||||
export async function removeGroundedShortTermCandidates(params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
}): Promise<{ removed: number; storePath: string }> {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
const storePath = resolveStorePath(workspaceDir, params.agentId);
|
||||
const storePath = resolveStorePath(workspaceDir);
|
||||
const nowIso = new Date().toISOString();
|
||||
let removed = 0;
|
||||
|
||||
await withShortTermLock(workspaceDir, params.agentId, async () => {
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
readStore(workspaceDir, nowIso, params.agentId),
|
||||
readPhaseSignalStore(workspaceDir, nowIso, params.agentId),
|
||||
readStore(workspaceDir, nowIso),
|
||||
readPhaseSignalStore(workspaceDir, nowIso),
|
||||
]);
|
||||
|
||||
for (const [key, entry] of Object.entries(store.entries)) {
|
||||
@@ -2920,8 +2807,8 @@ export async function removeGroundedShortTermCandidates(params: {
|
||||
store.updatedAt = nowIso;
|
||||
phaseSignals.updatedAt = nowIso;
|
||||
await Promise.all([
|
||||
writeStore(workspaceDir, store, params.agentId),
|
||||
writePhaseSignalStore(workspaceDir, phaseSignals, params.agentId),
|
||||
writeStore(workspaceDir, store),
|
||||
writePhaseSignalStore(workspaceDir, phaseSignals),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -2935,20 +2822,18 @@ export const testing = {
|
||||
isProcessLikelyAlive,
|
||||
readRecallStore: readStore,
|
||||
readPhaseSignalStore,
|
||||
writeRawRecallStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
|
||||
writeRawRecallStore: async (workspaceDir: string, raw: unknown) => {
|
||||
const record = asRecord(raw);
|
||||
const entries = asRecord(record?.entries);
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_RECALL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [],
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
key: "recall",
|
||||
value: {
|
||||
updatedAt:
|
||||
@@ -2959,20 +2844,18 @@ export const testing = {
|
||||
}),
|
||||
]);
|
||||
},
|
||||
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown, agentId?: string) => {
|
||||
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown) => {
|
||||
const record = asRecord(raw);
|
||||
const entries = asRecord(record?.entries);
|
||||
await Promise.all([
|
||||
writeMemoryCoreWorkspaceEntries({
|
||||
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [],
|
||||
}),
|
||||
writeMemoryCoreWorkspaceEntry({
|
||||
namespace: SHORT_TERM_META_NAMESPACE,
|
||||
workspaceDir,
|
||||
agentId,
|
||||
key: "phase",
|
||||
value: {
|
||||
updatedAt:
|
||||
@@ -2983,17 +2866,17 @@ export const testing = {
|
||||
}),
|
||||
]);
|
||||
},
|
||||
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry, agentId?: string) => {
|
||||
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry) => {
|
||||
await openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
}).register(memoryCoreWorkspaceStateKey(workspaceDir, agentId), entry);
|
||||
}).register(memoryCoreWorkspaceStateKey(workspaceDir), entry);
|
||||
},
|
||||
deleteShortTermLock: async (workspaceDir: string, agentId?: string) => {
|
||||
deleteShortTermLock: async (workspaceDir: string) => {
|
||||
await openMemoryCoreStateStore<ShortTermLockEntry>({
|
||||
namespace: SHORT_TERM_LOCK_NAMESPACE,
|
||||
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
|
||||
}).delete(memoryCoreWorkspaceStateKey(workspaceDir, agentId));
|
||||
}).delete(memoryCoreWorkspaceStateKey(workspaceDir));
|
||||
},
|
||||
deriveConceptTags,
|
||||
calculateConsolidationComponent,
|
||||
|
||||
@@ -91,9 +91,7 @@ describe("memory search citations", () => {
|
||||
setMemoryBackend("builtin");
|
||||
const cfg = asOpenClawConfig({
|
||||
memory: { citations: "on" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
});
|
||||
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
||||
const result = await tool.execute("call_citations_on", { query: "notes" });
|
||||
@@ -107,9 +105,7 @@ describe("memory search citations", () => {
|
||||
setMemoryBackend("builtin");
|
||||
const cfg = asOpenClawConfig({
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
});
|
||||
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
||||
const result = await tool.execute("call_citations_off", { query: "notes" });
|
||||
@@ -123,9 +119,7 @@ describe("memory search citations", () => {
|
||||
setMemoryBackend("qmd");
|
||||
const cfg = asOpenClawConfig({
|
||||
memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
});
|
||||
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
||||
const result = await tool.execute("call_citations_qmd", { query: "notes" });
|
||||
@@ -175,9 +169,7 @@ describe("memory tools", () => {
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: { backend: "qmd", qmd: { command: "qmd" } },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -197,9 +189,7 @@ describe("memory tools", () => {
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: { backend: "qmd", qmd: { command: "qmd" } },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
}),
|
||||
oneShotCliRun: true,
|
||||
});
|
||||
@@ -320,18 +310,18 @@ describe("memory tools", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: {
|
||||
extensions: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await tool.execute("call_recall_persist", { query: "glacier backup" });
|
||||
@@ -340,7 +330,6 @@ describe("memory tools", () => {
|
||||
const store = await shortTermPromotionTesting.readRecallStore(
|
||||
workspaceDir,
|
||||
new Date().toISOString(),
|
||||
"main",
|
||||
);
|
||||
const values = Object.values(store.entries);
|
||||
expect(values).toHaveLength(1);
|
||||
@@ -350,7 +339,7 @@ describe("memory tools", () => {
|
||||
expect(entry?.path).toBe("memory/2026-04-03.md");
|
||||
expect(entry?.recallCount).toBe(1);
|
||||
const events = await waitFor(async () => {
|
||||
const memoryEvents = await readMemoryHostEvents({ workspaceDir, agentId: "main" });
|
||||
const memoryEvents = await readMemoryHostEvents({ workspaceDir });
|
||||
expect(memoryEvents).toHaveLength(1);
|
||||
return memoryEvents;
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
// Memory Core plugin module implements tools.citations behavior.
|
||||
import {
|
||||
parseAgentSessionKey,
|
||||
resolveAgentMemoryConfig,
|
||||
type MemoryCitationsMode,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function resolveMemoryCitationsMode(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): MemoryCitationsMode {
|
||||
const mode = resolveAgentMemoryConfig(cfg, agentId)?.citations;
|
||||
export function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
|
||||
const mode = cfg.memory?.citations;
|
||||
if (mode === "on" || mode === "off" || mode === "auto") {
|
||||
return mode;
|
||||
}
|
||||
|
||||
@@ -67,20 +67,22 @@ describe("memory_search recall tracking", () => {
|
||||
|
||||
const tool = createSearchTool(
|
||||
asOpenClawConfig({
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
citations: "on",
|
||||
qmd: { limits: { maxInjectedChars: 100 } },
|
||||
extensions: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
citations: "on",
|
||||
qmd: { limits: { maxInjectedChars: 100 } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -112,18 +114,18 @@ describe("memory_search recall tracking", () => {
|
||||
|
||||
const tool = createSearchTool(
|
||||
asOpenClawConfig({
|
||||
memory: {
|
||||
extensions: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
setMemorySearchImpl(async () => [
|
||||
@@ -174,23 +176,24 @@ describe("memory_search recall tracking", () => {
|
||||
|
||||
const tool = createSearchTool(
|
||||
asOpenClawConfig({
|
||||
memory: {
|
||||
extensions: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
timezone: "Europe/London",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Los_Angeles",
|
||||
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
timezone: "Europe/London",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -215,18 +218,18 @@ describe("memory_search recall tracking", () => {
|
||||
|
||||
const tool = createSearchTool(
|
||||
asOpenClawConfig({
|
||||
memory: {
|
||||
extensions: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
dreaming: {
|
||||
enabled: false,
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@ export function buildMemorySearchUnavailableResult(
|
||||
export async function searchMemoryCorpusSupplements(params: {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
corpus?: "memory" | "wiki" | "all" | "sessions";
|
||||
}): Promise<MemoryCorpusSearchResult[]> {
|
||||
@@ -172,7 +171,6 @@ export async function getMemoryCorpusSupplementResult(params: {
|
||||
lookup: string;
|
||||
fromLine?: number;
|
||||
lineCount?: number;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
corpus?: "memory" | "wiki" | "all" | "sessions";
|
||||
}) {
|
||||
|
||||
@@ -43,9 +43,7 @@ export function createAutoCitationsMemorySearchTool(agentSessionKey: string) {
|
||||
return createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: { citations: "auto" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
}),
|
||||
agentSessionKey,
|
||||
});
|
||||
|
||||
@@ -239,10 +239,8 @@ describe("memory_search unavailable payloads", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("closed-db", { query: "hidden thread codename" });
|
||||
@@ -288,10 +286,8 @@ describe("memory_search unavailable payloads", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
},
|
||||
oneShotCliRun: true,
|
||||
});
|
||||
@@ -338,10 +334,8 @@ describe("memory_search unavailable payloads", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("zero-hit-retry", { query: "hidden thread codename" });
|
||||
@@ -368,10 +362,8 @@ describe("memory_search unavailable payloads", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("paused-index", { query: "hidden thread codename" });
|
||||
@@ -465,8 +457,8 @@ describe("memory_search corpus labels", () => {
|
||||
config: asOpenClawConfig({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, memory: { search: { enabled: false } } },
|
||||
{ id: "recall", memory: { search: { enabled: true } } },
|
||||
{ id: "main", default: true, memorySearch: { enabled: false } },
|
||||
{ id: "recall", memorySearch: { enabled: true } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -481,27 +473,31 @@ describe("memory_search corpus labels", () => {
|
||||
|
||||
it("re-resolves config when executing a previously created tool", async () => {
|
||||
const startupConfig = asOpenClawConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
});
|
||||
const patchedConfig = asOpenClawConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
});
|
||||
let liveConfig = startupConfig;
|
||||
@@ -541,10 +537,8 @@ describe("memory_search corpus labels", () => {
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
tools: { sessions: { visibility: "all" } },
|
||||
},
|
||||
agentSessionKey: "agent:main:main",
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
MemorySearchRuntimeDebug,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDeepDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
@@ -238,7 +239,6 @@ function resolveRecallTrackingResults(
|
||||
|
||||
function queueShortTermRecallTracking(params: {
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
query: string;
|
||||
rawResults: MemorySearchResult[];
|
||||
surfacedResults: MemorySearchResult[];
|
||||
@@ -247,7 +247,6 @@ function queueShortTermRecallTracking(params: {
|
||||
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
|
||||
void recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentId: params.agentId,
|
||||
query: params.query,
|
||||
results: trackingResults,
|
||||
timezone: params.timezone,
|
||||
@@ -294,7 +293,6 @@ async function getSupplementMemoryReadResult(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
corpus?: "memory" | "wiki" | "all";
|
||||
}) {
|
||||
@@ -302,7 +300,6 @@ async function getSupplementMemoryReadResult(params: {
|
||||
lookup: params.relPath,
|
||||
fromLine: params.from,
|
||||
lineCount: params.lines,
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
corpus: params.corpus,
|
||||
});
|
||||
@@ -322,7 +319,6 @@ async function resolveMemoryReadFailureResult(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
}) {
|
||||
if (params.requestedCorpus === "all") {
|
||||
@@ -330,7 +326,6 @@ async function resolveMemoryReadFailureResult(params: {
|
||||
relPath: params.relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
corpus: params.requestedCorpus,
|
||||
});
|
||||
@@ -348,7 +343,6 @@ async function executeMemoryReadResult<T>(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
}) {
|
||||
try {
|
||||
@@ -360,7 +354,6 @@ async function executeMemoryReadResult<T>(params: {
|
||||
relPath: params.relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
});
|
||||
}
|
||||
@@ -456,18 +449,19 @@ export function createMemorySearchTool(options: {
|
||||
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
|
||||
}
|
||||
|
||||
const citationsMode = resolveMemoryCitationsMode(cfg, agentId);
|
||||
const citationsMode = resolveMemoryCitationsMode(cfg);
|
||||
const includeCitations = shouldIncludeCitations({
|
||||
mode: citationsMode,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
}).enabled;
|
||||
const dreaming = resolveMemoryDeepDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
@@ -576,7 +570,6 @@ export function createMemorySearchTool(options: {
|
||||
if (dreamingEnabled) {
|
||||
queueShortTermRecallTracking({
|
||||
workspaceDir: status.workspaceDir,
|
||||
agentId,
|
||||
query,
|
||||
rawResults,
|
||||
surfacedResults: memoryResults,
|
||||
@@ -613,7 +606,6 @@ export function createMemorySearchTool(options: {
|
||||
await searchMemoryCorpusSupplements({
|
||||
query,
|
||||
maxResults,
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
corpus: requestedCorpus,
|
||||
}),
|
||||
@@ -688,7 +680,6 @@ export function createMemoryGetTool(options: {
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
corpus: requestedCorpus,
|
||||
});
|
||||
@@ -716,7 +707,6 @@ export function createMemoryGetTool(options: {
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
});
|
||||
}
|
||||
@@ -739,7 +729,6 @@ export function createMemoryGetTool(options: {
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
});
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user