memory/dreaming: decouple managed cron from heartbeat (#70737)

* Revert "fix(memory/dreaming): surface blocked status when heartbeat is disabled for main (#69875)"

This reverts commit 529577e045.

Making way for the dreaming-vs-heartbeat decoupling from Josh's
josh/dreaming-isolated-cron-fix branch, which moves the managed dreaming
cron to isolated agent turns (sessionTarget: "isolated") so dreaming no
longer requires heartbeat to fire. Once the cron no longer rides the
heartbeat path, the blocked-reason observability has nothing left to
report — removing it cleanly here before the cherry-picks land.

* openclaw-3ba.1: move managed dreaming cron to isolated agent turns

* openclaw-46d: claim cron runs before embedded attempts

* openclaw-575: disable managed dreaming cron delivery

* openclaw-575: accept wrapped dreaming cron tokens

* openclaw-ccd: filter cron and wrapper transcript noise from dreaming corpus

* openclaw-cd9: filter archived, cron, and heartbeat transcript noise from dreaming corpus

* openclaw-cd9: suppress role-label reflection tags in rem dreaming

* openclaw-b49: stop narrative timeouts from blocking dreaming cron

* openclaw-b49: keep managed dreaming cron out of diary subagents

* openclaw-ff9: restore cron dream diary generation without serial waits

* openclaw-ff9: run dreaming narratives with lightweight isolated subagent lanes

* openclaw-ff9: detach cron dream diary generation from run completion

* openclaw-ff9: defer cron diary task startup until after cron completion

* doctor/cron: migrate stale managed dreaming jobs to isolated agent turns

After the dreaming cron moved off the heartbeat path to sessionTarget:
"isolated" + payload.kind: "agentTurn" (see the preceding memory-core
changes), users with existing ~/.openclaw/cron/jobs.json entries in the
old sessionTarget: "main" + payload.kind: "systemEvent" shape still
carry stale jobs until the gateway restart reconcile rewrites them.

Add a dreaming-specific cron migration to the existing
maybeRepairLegacyCronStore doctor path so "openclaw doctor" (and
"openclaw doctor --fix") rewrites those jobs without needing a gateway
restart. Match lives in a new doctor-cron-dreaming-payload-migration
helper alongside the existing legacy-delivery and store-migration files.

The matching uses the memory-core managed-job name and description tag
plus the short-term-promotion payload token. Constants are mirrored
from extensions/memory-core/src/dreaming.ts and commented so a future
rename in memory-core is a visible drift point here too.

* memory/dreaming: tighten cron-token match to known wrapper, not substring

The previous match relaxed the line check from 'trimmed line equals token'
to 'line contains token anywhere as a substring' to accept the
`[cron:<id>] <token>` wrapper that isolated-cron turns add. Substring
matching also let any user message embedding the token mid-sentence
trigger the dream-promotion hook, and was flagged by both Greptile and
Aisle on PR #70737.

Replace it with strip-the-known-prefix-then-exact-match: keep the
`[cron:<id>]` wrapper case working, reject every other variant. Add
focused unit coverage that the bare token, the wrapped token, and bare
multiline cases match while embedded / code-fenced / arbitrarily-wrapped
variants do not.

* memory/dreaming: drop assistant followup only on assistant-side signals

Per PR #70737 review (aisle-research-bot, Medium): the previous logic
suppressed the next assistant message whenever the prior user message
matched a 'generated prompt' pattern (`[cron:...]`,
`System (untrusted): ...`, heartbeat prompts, exec-completion events).
Real users can type those same patterns, which let a user exfiltrate
real assistant replies from the dreaming corpus by prefixing their own
prompt — the assistant's reply would be silently dropped.

Remove the cross-message coupling. Assistant-side machinery (silent
replies, system wrappers) is already dropped by sanitizeSessionText,
which is the right layer for that filter. Add an explicit assistant-side
HEARTBEAT_TOKEN check to keep the legitimate `HEARTBEAT_OK` ack drop
working without depending on the prior user message. Add a regression
test exercising the spoofing scenario.

* doctor/cron: assert mirrored dreaming constants stay in sync

Per PR #70737 review (greptile-apps): the doctor migration mirrors three
constants (MANAGED_DREAMING_CRON_NAME, MANAGED_DREAMING_CRON_TAG,
DREAMING_SYSTEM_EVENT_TEXT) from extensions/memory-core/src/dreaming.ts.
A future rename in either file would silently break the migration.

Add a vitest unit that reads both files and asserts the literals match.
Manually verified the assertion fires with a clear error when one side
diverges. Adds no runtime cost; sits in the regular test pipeline.

* fix(memory): stabilize dreaming CI checks

* memory/dreaming: skip eager narrative session cleanup when detached

Per PR #70737 review (chatgpt-codex-connector, P2): runDreamingSweepPhases
called deleteNarrativeSessionBestEffort synchronously right after each
phase. Once narrative generation moved to detached mode (queued via
queueMicrotask), the eager cleanup races the writer: the session is
deleted before the queued subagent run reads it, silently dropping cron
diary entries.

Skip the eager cleanup branch when params.detachNarratives is true.
generateAndAppendDreamNarrative still runs its own deleteSession in the
finally{} block, so the cleanup intent is preserved without the race.
Heartbeat-driven (non-detached) runs keep the original eager-cleanup
behavior.

* fix(plugin-sdk): restore heartbeat-summary re-export

Per PR #70737 review (chatgpt-codex-connector, P1): the revert of
PR #69875 dropped the `heartbeat-summary` re-export from
`openclaw/plugin-sdk/infra-runtime`. That subpath shipped publicly two
days earlier, so removing it is technically a breaking change to a
public SDK surface — third-party plugins importing
`isHeartbeatEnabledForAgent` / `resolveHeartbeatIntervalMs` from this
path would fail with no replacement contract introduced.

Restore the re-export. Costs nothing to keep; the helpers are already
public via `../infra/heartbeat-summary.ts`. SDK additions are by
default backwards-compatible (CLAUDE.md), so removing within days of
introduction violates that intent.

* changelog: note dreaming decoupling from heartbeat

Refs PR #70737.

---------

Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
Patrick Erichsen
2026-04-23 22:23:19 -07:00
committed by GitHub
parent acbceb8a76
commit aca92b2906
30 changed files with 1824 additions and 378 deletions

View File

@@ -44,10 +44,7 @@ import {
type RepairDreamingArtifactsResult,
} from "./dreaming-repair.js";
import { asRecord } from "./dreaming-shared.js";
import {
resolveDreamingBlockedReason,
resolveShortTermPromotionDreamingConfig,
} from "./dreaming.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
import {
applyShortTermPromotions,
@@ -853,10 +850,6 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`${label("Workspace")} ${info(workspacePath)}`,
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
].filter(Boolean) as string[];
const dreamingBlockedReason = resolveDreamingBlockedReason(cfg);
if (dreamingBlockedReason) {
lines.push(`${label("Dreaming status")} ${warn(`blocked - ${dreamingBlockedReason}`)}`);
}
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;

View File

@@ -384,135 +384,6 @@ describe("memory cli", () => {
});
});
it("reports dreaming blocked when another explicit heartbeat agent excludes main", async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "30m",
},
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: {
every: "1h",
},
},
],
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--agent", "main"]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining(
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
),
);
expect(close).toHaveBeenCalled();
});
it('reports dreaming blocked when main heartbeat interval is "0m"', async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "0m",
},
},
list: [{ id: "main", default: true }],
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining(
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
),
);
expect(close).toHaveBeenCalled();
});
it("reports dreaming blocked for the configured default agent when it is not main", async () => {
resolveDefaultAgentId.mockReturnValue("ops");
loadConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "0m",
},
},
list: [{ id: "ops", default: true }],
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--agent", "ops"]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining(
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "ops". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
),
);
expect(close).toHaveBeenCalled();
});
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");

View File

@@ -59,6 +59,22 @@ describe("concept vocabulary", () => {
expect(classifyConceptTagScript("qmd路由器")).toBe("mixed");
});
it("drops chat scaffolding stop words from derived concept tags", () => {
const tags = deriveConceptTags({
path: "memory/.dreams/session-corpus/2026-04-16.txt",
snippet:
"Assistant: the system should remind you about the Ollama provider setup in your workspace.",
});
expect(tags).toContain("ollama");
expect(tags).toContain("provider");
expect(tags).not.toContain("assistant");
expect(tags).not.toContain("system");
expect(tags).not.toContain("the");
expect(tags).not.toContain("you");
expect(tags).not.toContain("your");
});
it("summarizes entry coverage across latin, cjk, and mixed tags", () => {
expect(
summarizeConceptTagScriptCoverage([

View File

@@ -19,6 +19,7 @@ const LANGUAGE_STOP_WORDS = {
"agent",
"again",
"also",
"assistant",
"because",
"before",
"being",
@@ -64,6 +65,8 @@ const LANGUAGE_STOP_WORDS = {
"should",
"since",
"some",
"subagent",
"system",
"than",
"that",
"their",
@@ -73,13 +76,14 @@ const LANGUAGE_STOP_WORDS = {
"this",
"through",
"today",
"user",
"using",
"with",
"work",
"workspace",
"year",
],
english: ["and", "are", "for", "into", "its", "our", "then", "were"],
english: ["and", "are", "for", "into", "its", "our", "the", "then", "were", "you", "your"],
spanish: [
"al",
"con",

View File

@@ -197,95 +197,4 @@ describe("memory-core /dreaming command", () => {
expect(result.text).toContain("Usage: /dreaming status");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("shows a blocked line directly after enabled when main heartbeat is disabled", async () => {
const { command } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "0m",
},
},
list: [{ id: "main", default: true }],
},
});
const result = await command.handler(createCommandContext("status"));
const text = result.text ?? "";
expect(text).toContain(
'- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
);
const lines = text.split("\n");
const enabledIdx = lines.findIndex((line) => line.startsWith("- enabled:"));
const blockedIdx = lines.findIndex((line) => line.startsWith("- blocked:"));
expect(enabledIdx).toBeGreaterThan(-1);
expect(blockedIdx).toBe(enabledIdx + 1);
});
it("surfaces the blocked line on /dreaming on when main heartbeat is disabled", async () => {
const { command } = createHarness({
agents: {
defaults: {
heartbeat: {
every: "0m",
},
},
list: [{ id: "main", default: true }],
},
});
const result = await command.handler(
createCommandContext("on", {
gatewayClientScopes: ["operator.admin"],
}),
);
const text = result.text ?? "";
expect(text).toContain("Dreaming enabled.");
expect(text).toContain(
'- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
);
});
it("omits the blocked line when dreaming is enabled and main heartbeat is healthy", async () => {
const { command } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "30m",
},
},
list: [{ id: "main", default: true }],
},
});
const result = await command.handler(createCommandContext("status"));
expect(result.text).toContain("- enabled: on");
expect(result.text).not.toContain("- blocked:");
});
});

View File

@@ -2,10 +2,7 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memo
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { asRecord } from "./dreaming-shared.js";
import {
resolveDreamingBlockedReason,
resolveShortTermPromotionDreamingConfig,
} from "./dreaming.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
@@ -57,12 +54,10 @@ function formatStatus(cfg: OpenClawConfig): string {
});
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
const blockedReason = resolveDreamingBlockedReason(cfg);
return [
"Dreaming status:",
`- enabled: ${formatEnabled(dreaming.enabled)}${timezone}`,
...(blockedReason ? [`- blocked: ${blockedReason}`] : []),
`- sweep cadence: ${dreaming.frequency}`,
`- promotion policy: score>=${deep.minScore}, recalls>=${deep.minRecallCount}, uniqueQueries>=${deep.minUniqueQueries}`,
].join("\n");

View File

@@ -603,6 +603,8 @@ describe("generateAndAppendDreamNarrative", () => {
expect(subagent.run.mock.calls[0][0]).toMatchObject({
idempotencyKey: expectedSessionKey,
sessionKey: expectedSessionKey,
lane: `dreaming-narrative:${expectedSessionKey}`,
lightContext: true,
deliver: false,
});
expect(subagent.waitForRun).toHaveBeenCalledOnce();
@@ -655,12 +657,10 @@ describe("generateAndAppendDreamNarrative", () => {
expect(exists).toBe(false);
});
it("waits once more before cleanup after timeout and logs cleanup failures", async () => {
it("skips extra settle waits after timeout and still attempts cleanup", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
subagent.waitForRun
.mockResolvedValueOnce({ status: "timeout" })
.mockResolvedValueOnce({ status: "ok" });
subagent.waitForRun.mockResolvedValueOnce({ status: "timeout" });
subagent.deleteSession.mockRejectedValue(new Error("still active"));
const logger = createMockLogger();
@@ -671,8 +671,8 @@ describe("generateAndAppendDreamNarrative", () => {
logger,
});
expect(subagent.waitForRun).toHaveBeenCalledTimes(2);
expect(subagent.waitForRun.mock.calls[1][0]).toMatchObject({ timeoutMs: 120_000 });
expect(subagent.waitForRun).toHaveBeenCalledOnce();
expect(subagent.waitForRun.mock.calls[0][0]).toMatchObject({ timeoutMs: 15_000 });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("narrative session cleanup failed for rem phase"),
);

View File

@@ -27,6 +27,8 @@ type SubagentSurface = {
sessionKey: string;
message: string;
extraSystemPrompt?: string;
lane?: string;
lightContext?: boolean;
deliver?: boolean;
}) => Promise<{ runId: string }>;
waitForRun: (params: {
@@ -84,8 +86,10 @@ const NARRATIVE_SYSTEM_PROMPT = [
"- Output ONLY the diary entry. No preamble, no sign-off, no commentary.",
].join("\n");
const NARRATIVE_TIMEOUT_MS = 60_000;
const NARRATIVE_DELETE_SETTLE_TIMEOUT_MS = 120_000;
// Narrative generation is best-effort. Keep the timeout short so a stalled
// diary subagent does not leave the parent dreaming cron job "running" for
// minutes after the reports have already been written.
const NARRATIVE_TIMEOUT_MS = 15_000;
const DREAMING_SESSION_KEY_PREFIX = "dreaming-narrative-";
const DREAMING_TRANSCRIPT_RUN_MARKER = '"runId":"dreaming-narrative-';
const DREAMING_ORPHAN_MIN_AGE_MS = 300_000;
@@ -151,6 +155,8 @@ async function startNarrativeRunOrFallback(params: {
sessionKey: params.sessionKey,
message: params.message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
lane: `dreaming-narrative:${params.sessionKey}`,
lightContext: true,
deliver: false,
});
return run.runId;
@@ -855,8 +861,6 @@ export async function generateAndAppendDreamNarrative(params: {
});
const message = buildNarrativePrompt(params.data);
let runId: string | null = null;
let waitStatus: string | null = null;
try {
runId = await startNarrativeRunOrFallback({
subagent: params.subagent,
@@ -876,7 +880,6 @@ export async function generateAndAppendDreamNarrative(params: {
runId,
timeoutMs: NARRATIVE_TIMEOUT_MS,
});
waitStatus = result.status;
if (result.status !== "ok") {
params.logger.warn(
@@ -914,24 +917,6 @@ export async function generateAndAppendDreamNarrative(params: {
`memory-core: narrative generation failed for ${params.data.phase} phase: ${formatErrorMessage(err)}`,
);
} finally {
if (params.subagent && runId && waitStatus === "timeout") {
try {
const settle = await params.subagent.waitForRun({
runId,
timeoutMs: NARRATIVE_DELETE_SETTLE_TIMEOUT_MS,
});
if (settle.status !== "ok" && settle.status !== "error") {
params.logger.warn(
`memory-core: narrative cleanup wait ended with status=${settle.status} for ${params.data.phase} phase.`,
);
}
} catch (cleanupWaitErr) {
params.logger.warn(
`memory-core: narrative cleanup wait failed for ${params.data.phase} phase: ${formatErrorMessage(cleanupWaitErr)}`,
);
}
}
// Guard against subagent becoming unavailable mid-flight (throws TypeError without this).
if (params.subagent) {
try {

View File

@@ -977,6 +977,400 @@ describe("memory-core dreaming phases", () => {
]);
});
it("skips isolated cron run transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "cron-run.jsonl");
await fs.writeFile(
transcriptPath,
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:01:00.000Z",
content:
"[cron:job-1 Codex Sessions Sync] Run Codex sessions sync: 1. Convert sessions 2. Update qmd",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-05T18:02:00.000Z",
content: "Running Codex sessions sync...",
},
}),
].join("\n") + "\n",
"utf-8",
);
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:cron:job-1:run:run-1": {
sessionId: "cron-run",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
}),
"utf-8",
);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", workspace: workspaceDir }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
try {
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
}
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")),
).rejects.toMatchObject({ code: "ENOENT" });
const sessionIngestion = JSON.parse(
await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
"utf-8",
),
) as {
files: Record<
string,
{
lineCount: number;
lastContentLine: number;
contentHash: string;
}
>;
};
expect(Object.values(sessionIngestion.files)).toEqual([
expect.objectContaining({
lineCount: 0,
lastContentLine: 0,
contentHash: expect.any(String),
}),
]);
});
it("drops generated system wrapper text without suppressing paired assistant replies", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "ordinary-session.jsonl");
await fs.writeFile(
transcriptPath,
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:01:00.000Z",
content:
"System (untrusted): [2026-04-16 11:01:00 PDT] Exec completed (quiet-fo, code 0) :: Converted: 1",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:01:30.000Z",
content: "Handled internally.",
},
}),
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:02:00.000Z",
content: "What changed in the sync?",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:03:00.000Z",
content: "One new session was converted.",
},
}),
].join("\n") + "\n",
"utf-8",
);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", workspace: workspaceDir }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-16T19:00:00.000Z"));
try {
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
}
const corpus = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-16.txt"),
"utf-8",
);
expect(corpus).toContain("User: What changed in the sync?");
expect(corpus).toContain("Assistant: One new session was converted.");
expect(corpus).not.toContain("System (untrusted):");
expect(corpus).toContain("Assistant: Handled internally.");
});
it("drops archive, cron, and heartbeat chatter from fresh session corpus output", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "archived.jsonl.deleted.2026-04-16T18-06-16.529Z"),
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:01:00.000Z",
content: "[cron:job-1 Example] Run the nightly sync",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:02:00.000Z",
content: "Running the nightly sync now.",
},
}),
].join("\n") + "\n",
"utf-8",
);
await fs.writeFile(
path.join(sessionsDir, "ordinary.checkpoint.abc123.jsonl"),
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:03:00.000Z",
content: "Checkpoint chatter should stay out.",
},
}) + "\n",
"utf-8",
);
await fs.writeFile(
path.join(sessionsDir, "ordinary.jsonl"),
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:04:00.000Z",
content:
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:05:00.000Z",
content: "HEARTBEAT_OK",
},
}),
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:06:00.000Z",
content: "[cron:job-2 Example] Run the qmd sync",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:07:00.000Z",
content: "Running the qmd sync now.",
},
}),
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-16T18:08:00.000Z",
content: "Document the Ollama provider setup.",
},
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-16T18:09:00.000Z",
content: "I documented the Ollama provider setup in the workspace notes.",
},
}),
].join("\n") + "\n",
"utf-8",
);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
list: [{ id: "main", workspace: workspaceDir }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-16T19:00:00.000Z"));
try {
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
}
const corpus = await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-16.txt"),
"utf-8",
);
expect(corpus).toContain("User: Document the Ollama provider setup.");
expect(corpus).toContain(
"Assistant: I documented the Ollama provider setup in the workspace notes.",
);
expect(corpus).not.toContain("Run the nightly sync");
expect(corpus).not.toContain("Checkpoint chatter should stay out.");
expect(corpus).not.toContain("Read HEARTBEAT.md");
expect(corpus).not.toContain("HEARTBEAT_OK");
expect(corpus).not.toContain("Run the qmd sync");
});
it("ignores chat scaffolding tags when building rem reflections", () => {
const preview = __testing.previewRemDreaming({
entries: [
{
key: "memory:1",
path: "memory/.dreams/session-corpus/2026-04-16.txt",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "Assistant: I documented the Ollama provider setup.",
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: 0.6,
maxScore: 0.6,
firstRecalledAt: "2026-04-16T18:00:00.000Z",
lastRecalledAt: "2026-04-16T18:00:00.000Z",
queryHashes: ["q1"],
recallDays: ["2026-04-16"],
conceptTags: ["assistant", "the", "ollama", "provider"],
},
],
limit: 5,
minPatternStrength: 0,
});
expect(preview.reflections.join("\n")).toContain("`ollama`");
expect(preview.reflections.join("\n")).toContain("`provider`");
expect(preview.reflections.join("\n")).not.toContain("`assistant`");
expect(preview.reflections.join("\n")).not.toContain("`the`");
});
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

View File

@@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
buildSessionEntry,
listSessionFilesForAgent,
loadDreamingNarrativeTranscriptPathSetForAgent,
loadSessionTranscriptClassificationForAgent,
normalizeSessionTranscriptPathForComparison,
parseUsageCountedSessionIdFromFileName,
sessionPathForFile,
@@ -191,6 +191,8 @@ type DailySnippetChunk = {
snippet: string;
};
const REM_REFLECTION_TAG_BLACKLIST = new Set(["assistant", "user", "system", "subagent", "the"]);
function buildDailyChunkSnippet(
heading: string | null,
chunkLines: string[],
@@ -710,21 +712,26 @@ async function collectSessionIngestionBatches(params: {
agentId: string;
absolutePath: string;
generatedByDreamingNarrative: boolean;
generatedByCronRun: boolean;
sessionPath: string;
}> = [];
for (const agentId of agentIds) {
const files = await listSessionFilesForAgent(agentId);
const dreamingTranscriptPaths =
const transcriptClassification =
files.length > 0
? loadDreamingNarrativeTranscriptPathSetForAgent(agentId)
: new Set<string>();
? loadSessionTranscriptClassificationForAgent(agentId)
: {
dreamingNarrativeTranscriptPaths: new Set<string>(),
cronRunTranscriptPaths: new Set<string>(),
};
for (const absolutePath of files) {
const normalizedPath = normalizeSessionTranscriptPathForComparison(absolutePath);
sessionFiles.push({
agentId,
absolutePath,
generatedByDreamingNarrative: dreamingTranscriptPaths.has(
normalizeSessionTranscriptPathForComparison(absolutePath),
),
generatedByDreamingNarrative:
transcriptClassification.dreamingNarrativeTranscriptPaths.has(normalizedPath),
generatedByCronRun: transcriptClassification.cronRunTranscriptPaths.has(normalizedPath),
sessionPath: sessionPathForFile(absolutePath),
});
}
@@ -783,11 +790,12 @@ async function collectSessionIngestionBatches(params: {
const entry = await buildSessionEntry(file.absolutePath, {
generatedByDreamingNarrative: file.generatedByDreamingNarrative,
generatedByCronRun: file.generatedByCronRun,
});
if (!entry) {
continue;
}
if (entry.generatedByDreamingNarrative) {
if (entry.generatedByDreamingNarrative || entry.generatedByCronRun) {
nextFiles[stateKey] = {
mtimeMs: fingerprint.mtimeMs,
size: fingerprint.size,
@@ -1414,7 +1422,7 @@ function buildRemReflections(
const tagStats = new Map<string, { count: number; evidence: Set<string> }>();
for (const entry of entries) {
for (const tag of entry.conceptTags) {
if (!tag) {
if (!tag || REM_REFLECTION_TAG_BLACKLIST.has(tag.toLowerCase())) {
continue;
}
const stat = tagStats.get(tag) ?? { count: 0, evidence: new Set<string>() };
@@ -1493,6 +1501,7 @@ async function runLightDreaming(params: {
config: LightDreamingConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
detachNarratives?: boolean;
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
@@ -1553,14 +1562,27 @@ async function runLightDreaming(params: {
snippets: capped.map((e) => e.snippet).filter(Boolean),
...(themes.length > 0 ? { themes } : {}),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
if (params.detachNarratives) {
queueMicrotask(() => {
void generateAndAppendDreamNarrative({
subagent: params.subagent!,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
}).catch(() => undefined);
});
} else {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
}
}
@@ -1570,6 +1592,7 @@ async function runRemDreaming(params: {
config: RemDreamingConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
detachNarratives?: boolean;
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
@@ -1632,14 +1655,27 @@ async function runRemDreaming(params: {
.filter(Boolean),
...(themes.length > 0 ? { themes } : {}),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
if (params.detachNarratives) {
queueMicrotask(() => {
void generateAndAppendDreamNarrative({
subagent: params.subagent!,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
}).catch(() => undefined);
});
} else {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
}
}
@@ -1660,6 +1696,7 @@ export async function runDreamingSweepPhases(params: {
cfg?: DreamingHostConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
detachNarratives?: boolean;
nowMs?: number;
}): Promise<void> {
// Normalize nowMs once so all phase timestamps and narrative session keys are consistent.
@@ -1677,10 +1714,14 @@ export async function runDreamingSweepPhases(params: {
logger: params.logger,
subagent: params.subagent,
nowMs: sweepNowMs,
detachNarratives: params.detachNarratives,
});
// Defensive cleanup: ensure the light-phase narrative session is deleted even if
// generateAndAppendDreamNarrative's primary cleanup was skipped due to an error.
if (params.subagent) {
// Skip when narratives are detached: the queued subagent run hasn't read the
// session yet, so eager cleanup would race the writer and silently drop the
// diary entry. The narrative function does its own cleanup in finally{}.
if (params.subagent && !params.detachNarratives) {
const lightSessionKey = buildNarrativeSessionKey({
workspaceDir: params.workspaceDir,
phase: "light",
@@ -1702,9 +1743,11 @@ export async function runDreamingSweepPhases(params: {
logger: params.logger,
subagent: params.subagent,
nowMs: sweepNowMs,
detachNarratives: params.detachNarratives,
});
// Defensive cleanup: ensure the REM-phase narrative session is deleted.
if (params.subagent) {
// Skip when narratives are detached (see light-phase comment above).
if (params.subagent && !params.detachNarratives) {
const remSessionKey = buildNarrativeSessionKey({
workspaceDir: params.workspaceDir,
phase: "rem",
@@ -1777,6 +1820,7 @@ export function registerMemoryDreamingPhases(_api: OpenClawPluginApi): void {
export const __testing = {
runPhaseIfTriggered,
previewRemDreaming,
constants: {
LIGHT_SLEEP_EVENT_TEXT,
REM_SLEEP_EVENT_TEXT,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { includesSystemEventToken } from "./dreaming-shared.js";
const TOKEN = "__openclaw_memory_core_short_term_promotion_dream__";
describe("includesSystemEventToken", () => {
it("matches the bare token", () => {
expect(includesSystemEventToken(TOKEN, TOKEN)).toBe(true);
});
it("matches a token wrapped by an isolated-cron `[cron:<id>]` prefix", () => {
expect(includesSystemEventToken(`[cron:abc-123] ${TOKEN}`, TOKEN)).toBe(true);
});
it("matches the token on its own line within multiline content", () => {
expect(includesSystemEventToken(`leading text\n${TOKEN}\ntrailing`, TOKEN)).toBe(true);
});
it("does NOT match a user message that merely embeds the token mid-sentence", () => {
expect(
includesSystemEventToken(`please tell me about ${TOKEN} when you have time`, TOKEN),
).toBe(false);
});
it("does NOT match a user message with the token in a code-fence-style block", () => {
expect(
includesSystemEventToken(`here is a snippet:\n\`${TOKEN}\`\nwhat does that do?`, TOKEN),
).toBe(false);
});
it("does NOT match an arbitrary wrapper the runtime does not produce", () => {
expect(includesSystemEventToken(`[somewrap] ${TOKEN}`, TOKEN)).toBe(false);
});
it("returns false for empty inputs", () => {
expect(includesSystemEventToken("", TOKEN)).toBe(false);
expect(includesSystemEventToken(TOKEN, "")).toBe(false);
expect(includesSystemEventToken(" ", TOKEN)).toBe(false);
});
});

View File

@@ -18,5 +18,15 @@ export function includesSystemEventToken(cleanedBody: string, eventText: string)
if (normalizedBody === normalizedEventText) {
return true;
}
return normalizedBody.split(/\r?\n/).some((line) => line.trim() === normalizedEventText);
return normalizedBody.split(/\r?\n/).some((line) => {
const trimmed = line.trim();
if (trimmed === normalizedEventText) {
return true;
}
// Isolated cron turns wrap the payload with a `[cron:<id>] ...` prefix; strip
// that one known wrapper before matching so the dream sentinel still triggers
// without falling back to a broad substring match (which would let any user
// message embedding the token surface as a dream cron firing).
return trimmed.replace(/^\[cron:[^\]]+\]\s*/, "") === normalizedEventText;
});
}

View File

@@ -71,6 +71,7 @@ function createCronHarness(
...job,
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
...(job.payload ? { payload: { ...job.payload } } : {}),
...(job.delivery ? { delivery: { ...job.delivery } } : {}),
}));
},
async add(input) {
@@ -84,6 +85,7 @@ function createCronHarness(
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: { ...input.payload },
...(input.delivery ? { delivery: { ...input.delivery } } : {}),
createdAtMs: Date.now(),
});
return {};
@@ -104,6 +106,7 @@ function createCronHarness(
...(patch.sessionTarget ? { sessionTarget: patch.sessionTarget } : {}),
...(patch.wakeMode ? { wakeMode: patch.wakeMode } : {}),
...(patch.payload ? { payload: { ...patch.payload } } : {}),
...(patch.delivery ? { delivery: { ...patch.delivery } } : {}),
};
return {};
},
@@ -432,11 +435,15 @@ describe("short-term dreaming cron reconciliation", () => {
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]).toMatchObject({
name: constants.MANAGED_DREAMING_CRON_NAME,
sessionTarget: "main",
sessionTarget: "isolated",
wakeMode: "now",
delivery: {
mode: "none",
},
payload: {
kind: "systemEvent",
text: constants.DREAMING_SYSTEM_EVENT_TEXT,
kind: "agentTurn",
message: constants.DREAMING_SYSTEM_EVENT_TEXT,
lightContext: true,
},
schedule: {
kind: "cron",
@@ -471,6 +478,9 @@ describe("short-term dreaming cron reconciliation", () => {
kind: "systemEvent",
text: "stale-text",
},
delivery: {
mode: "announce",
},
createdAtMs: 1,
};
const duplicate: CronJobLike = {
@@ -506,8 +516,12 @@ describe("short-term dreaming cron reconciliation", () => {
id: "job-primary",
patch: {
enabled: true,
sessionTarget: "isolated",
wakeMode: "now",
schedule: desired.schedule,
delivery: {
mode: "none",
},
payload: desired.payload,
},
});
@@ -770,6 +784,9 @@ describe("gateway startup reconciliation", () => {
expr: "15 4 * * *",
tz: "UTC",
},
delivery: {
mode: "none",
},
});
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("created managed dreaming cron job"),
@@ -1538,6 +1555,53 @@ describe("gateway startup reconciliation", () => {
clearInternalHooks();
}
});
it("handles managed dreaming cron triggers without a queued heartbeat event", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: false,
},
},
},
},
},
} as OpenClawConfig,
pluginConfig: {},
logger,
runtime: {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
const result = await beforeAgentReply(
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
{ trigger: "cron", workspaceDir: ".", sessionKey: "cron:memory-dreaming" },
);
expect(result).toEqual({
handled: true,
reason: "memory-core: short-term dreaming disabled",
});
} finally {
clearInternalHooks();
}
});
});
describe("short-term dreaming trigger", () => {
@@ -1635,6 +1699,51 @@ describe("short-term dreaming trigger", () => {
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("applies promotions when the managed dreaming token is wrapped by the cron label", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-cron-wrapper-");
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: [
"[cron:e795558c-a273-4124-ba88-d4916688d977 Memory Dreaming Promotion] __openclaw_memory_core_short_term_promotion_dream__",
"Current time: Thursday, April 16th, 2026 - 3:10 PM (America/Los_Angeles) / 2026-04-16 22:10 UTC",
].join("\n"),
trigger: "cron",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("keeps one-off recalls out of long-term memory under default thresholds", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-strict-");
@@ -1687,7 +1796,7 @@ describe("short-term dreaming trigger", () => {
expect(memoryText).toBe("");
});
it("ignores non-heartbeat triggers", async () => {
it("ignores non-cron, non-heartbeat triggers", async () => {
const logger = createLogger();
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
@@ -1708,6 +1817,108 @@ describe("short-term dreaming trigger", () => {
expect(result).toBeUndefined();
});
it("applies promotions when the managed dreaming isolated cron job fires", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-cron-");
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "cron",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("writes dream diary prose for managed cron dreaming", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-cron-no-narrative-");
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const subagent = {
run: vi.fn(async () => ({ runId: "narrative-run-1" })),
waitForRun: vi.fn(async () => ({ status: "ok" })),
getSessionMessages: vi.fn(async () => ({
messages: [{ role: "assistant", content: "A diary entry." }],
})),
deleteSession: vi.fn(async () => {}),
};
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "cron",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
subagent,
});
expect(result?.handled).toBe(true);
expect(subagent.run).toHaveBeenCalled();
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
await vi.waitFor(async () => {
expect(subagent.waitForRun).toHaveBeenCalled();
expect(subagent.getSessionMessages).toHaveBeenCalled();
expect(subagent.deleteSession).toHaveBeenCalled();
const dreamsText = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(dreamsText).toContain("A diary entry.");
});
});
it("skips dreaming promotion cleanly when limit is zero", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-limit-zero-");

View File

@@ -1,9 +1,4 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime";
import {
isHeartbeatEnabledForAgent,
peekSystemEventEntries,
resolveHeartbeatIntervalMs,
} from "openclaw/plugin-sdk/infra-runtime";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/infra-runtime";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_MEMORY_DREAMING_FREQUENCY as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
@@ -34,7 +29,6 @@ import {
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const CRON_SESSION_TARGET_MAIN = "main" as const;
const LEGACY_LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
const LEGACY_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
@@ -47,15 +41,20 @@ const HEARTBEAT_ISOLATED_SESSION_SUFFIX = ":heartbeat";
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
type CronSchedule = { kind: "cron"; expr: string; tz?: string };
type CronPayload = { kind: "systemEvent"; text: string };
type CronPayload =
| { kind: "systemEvent"; text: string }
| { kind: "agentTurn"; message: string; lightContext?: boolean };
type ManagedCronJobCreate = {
name: string;
description: string;
enabled: boolean;
schedule: CronSchedule;
sessionTarget: typeof CRON_SESSION_TARGET_MAIN;
sessionTarget: "main" | "isolated";
wakeMode: "now";
payload: CronPayload;
delivery?: {
mode: "none";
};
};
type ManagedCronJobPatch = {
@@ -63,9 +62,12 @@ type ManagedCronJobPatch = {
description?: string;
enabled?: boolean;
schedule?: CronSchedule;
sessionTarget?: typeof CRON_SESSION_TARGET_MAIN;
sessionTarget?: "main" | "isolated";
wakeMode?: "now";
payload?: CronPayload;
delivery?: {
mode: "none";
};
};
type ManagedCronJobLike = {
@@ -83,6 +85,11 @@ type ManagedCronJobLike = {
payload?: {
kind?: string;
text?: string;
message?: string;
lightContext?: boolean;
};
delivery?: {
mode?: string;
};
createdAtMs?: number;
};
@@ -155,23 +162,41 @@ function buildManagedDreamingCronJob(
expr: config.cron,
...(config.timezone ? { tz: config.timezone } : {}),
},
sessionTarget: CRON_SESSION_TARGET_MAIN,
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "systemEvent",
text: DREAMING_SYSTEM_EVENT_TEXT,
kind: "agentTurn",
message: DREAMING_SYSTEM_EVENT_TEXT,
lightContext: true,
},
// Dreaming is a maintenance sweep, not a user-facing announce job.
delivery: {
mode: "none",
},
};
}
function resolveManagedDreamingPayloadToken(
payload: ManagedCronJobLike["payload"],
): string | undefined {
const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(payload?.kind));
if (payloadKind === "systemevent") {
return normalizeTrimmedString(payload?.text);
}
if (payloadKind === "agentturn") {
return normalizeTrimmedString(payload?.message);
}
return undefined;
}
function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
const description = normalizeTrimmedString(job.description);
if (description?.includes(MANAGED_DREAMING_CRON_TAG)) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadText = normalizeTrimmedString(job.payload?.text);
return name === MANAGED_DREAMING_CRON_NAME && payloadText === DREAMING_SYSTEM_EVENT_TEXT;
const payloadToken = resolveManagedDreamingPayloadToken(job.payload);
return name === MANAGED_DREAMING_CRON_NAME && payloadToken === DREAMING_SYSTEM_EVENT_TEXT;
}
function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
@@ -255,8 +280,8 @@ function buildManagedDreamingPatch(
}
const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.sessionTarget));
if (sessionTarget !== "main") {
patch.sessionTarget = "main";
if (sessionTarget !== desired.sessionTarget) {
patch.sessionTarget = desired.sessionTarget;
}
const wakeMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.wakeMode));
if (wakeMode !== "now") {
@@ -264,10 +289,21 @@ function buildManagedDreamingPatch(
}
const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.payload?.kind));
const payloadText = normalizeTrimmedString(job.payload?.text);
if (payloadKind !== "systemevent" || !compareOptionalStrings(payloadText, desired.payload.text)) {
const payloadToken = resolveManagedDreamingPayloadToken(job.payload);
const desiredPayloadToken =
desired.payload.kind === "systemEvent" ? desired.payload.text : desired.payload.message;
const payloadNeedsUpdate =
payloadKind !== normalizeLowercaseStringOrEmpty(desired.payload.kind) ||
!compareOptionalStrings(payloadToken, desiredPayloadToken) ||
(desired.payload.kind === "agentTurn" &&
job.payload?.lightContext !== desired.payload.lightContext);
if (payloadNeedsUpdate) {
patch.payload = desired.payload;
}
const deliveryMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.delivery?.mode));
if (deliveryMode !== "none") {
patch.delivery = desired.delivery;
}
return Object.keys(patch).length > 0 ? patch : null;
}
@@ -358,27 +394,6 @@ export function resolveShortTermPromotionDreamingConfig(params: {
};
}
export function resolveDreamingBlockedReason(cfg: OpenClawConfig): string | null {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
if (!dreaming.enabled) {
return null;
}
const defaultAgentId = resolveDefaultAgentId(cfg);
// Mirror the managed dreaming wake path in server-cron: the job carries no
// agentId/sessionKey, so the wake uses defaults-only heartbeat. Not using
// resolveHeartbeatSummaryForAgent since it would apply the per-agent override
// and diverge from actual runtime behavior.
const enabledForDefault = isHeartbeatEnabledForAgent(cfg, defaultAgentId);
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, cfg.agents?.defaults?.heartbeat);
if (enabledForDefault && intervalMs != null) {
return null;
}
return `dreaming is enabled but will not run because heartbeat is disabled for "${defaultAgentId}". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting`;
}
export async function reconcileShortTermDreamingCronJob(params: {
cron: CronServiceLike | null;
config: ShortTermPromotionDreamingConfig;
@@ -473,7 +488,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
}): Promise<{ handled: true; reason: string } | undefined> {
if (params.trigger !== "heartbeat") {
if (params.trigger !== "heartbeat" && params.trigger !== "cron") {
return undefined;
}
if (!includesSystemEventToken(params.cleanedBody, DREAMING_SYSTEM_EVENT_TEXT)) {
@@ -521,6 +536,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
let totalApplied = 0;
let failedWorkspaces = 0;
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
const detachNarratives = params.trigger === "cron";
for (const workspaceDir of workspaces) {
try {
const sweepNowMs = Date.now();
@@ -530,6 +546,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
cfg: params.cfg,
logger: params.logger,
subagent: params.subagent,
detachNarratives,
nowMs: sweepNowMs,
});
@@ -608,14 +625,27 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
snippets: candidates.map((c) => c.snippet).filter(Boolean),
promotions: applied.appliedCandidates.map((c) => c.snippet).filter(Boolean),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
logger: params.logger,
});
if (detachNarratives) {
queueMicrotask(() => {
void generateAndAppendDreamNarrative({
subagent: params.subagent!,
workspaceDir,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
logger: params.logger,
}).catch(() => undefined);
});
} else {
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
}
} catch (err) {
failedWorkspaces += 1;
@@ -736,17 +766,21 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
api.on("before_agent_reply", async (event, ctx) => {
try {
if (ctx.trigger !== "heartbeat") {
if (ctx.trigger !== "heartbeat" && ctx.trigger !== "cron") {
return undefined;
}
const currentConfig = resolveCurrentConfig();
const config = await reconcileManagedDreamingCron({
reason: "runtime",
});
if (
!hasPendingManagedDreamingCronEvent(ctx.sessionKey) ||
!includesSystemEventToken(event.cleanedBody, DREAMING_SYSTEM_EVENT_TEXT)
) {
const hasManagedDreamingToken = includesSystemEventToken(
event.cleanedBody,
DREAMING_SYSTEM_EVENT_TEXT,
);
const isManagedHeartbeatTrigger =
ctx.trigger === "heartbeat" && hasPendingManagedDreamingCronEvent(ctx.sessionKey);
const isManagedCronTrigger = ctx.trigger === "cron";
if (!hasManagedDreamingToken || (!isManagedHeartbeatTrigger && !isManagedCronTrigger)) {
return undefined;
}
return await runShortTermDreamingPromotionIfTriggered({