diff --git a/CHANGELOG.md b/CHANGELOG.md index 65987e45ba6..068d317f6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai. - Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry. - Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd. +- Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek. - Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan. - Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820. - Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien. diff --git a/docs/concepts/dreaming.md b/docs/concepts/dreaming.md index 04d7a11debb..3d8d7d1b1fa 100644 --- a/docs/concepts/dreaming.md +++ b/docs/concepts/dreaming.md @@ -111,6 +111,8 @@ Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/ When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep. +The sweep includes the primary runtime workspace and any configured agent workspaces, deduped by path, so subagent workspace fan-out does not exclude the main agent's `DREAMS.md` and memory state. + Default cadence behavior: | Setting | Default | diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index d37ea2190e5..61ea3b14252 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -691,6 +691,97 @@ describe("memory-core dreaming phases", () => { ); }); + it("keeps primary session transcripts out of configured subagent workspaces", async () => { + const workspaceDir = await createDreamingWorkspace(); + const subagentWorkspaceDir = await createDreamingWorkspace(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + + const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main"); + const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(subagentSessionsDir, { recursive: true }); + await fs.writeFile( + path.join(mainSessionsDir, "main-session.jsonl"), + [ + JSON.stringify({ + type: "message", + message: { + role: "user", + timestamp: "2026-04-05T18:01:00.000Z", + content: [{ type: "text", text: "Main workspace should stay in main dreams." }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + await fs.writeFile( + path.join(subagentSessionsDir, "subagent-session.jsonl"), + [ + JSON.stringify({ + type: "message", + message: { + role: "user", + timestamp: "2026-04-05T18:02:00.000Z", + content: [{ type: "text", text: "CEO workspace should stay in CEO dreams." }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + const { beforeAgentReply } = createHarness( + { + agents: { + defaults: { + workspace: workspaceDir, + }, + list: [{ id: "agi-ceo", workspace: subagentWorkspaceDir }], + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + phases: { + light: { + enabled: true, + limit: 20, + lookbackDays: 7, + }, + }, + }, + }, + }, + }, + }, + }, + workspaceDir, + ); + + try { + await withDreamingTestClock(async () => { + await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); + }); + } finally { + vi.unstubAllEnvs(); + } + + const mainCorpus = await fs.readFile( + path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"), + "utf-8", + ); + const subagentCorpus = await fs.readFile( + path.join(subagentWorkspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"), + "utf-8", + ); + expect(mainCorpus).toContain("Main workspace should stay in main dreams."); + expect(mainCorpus).not.toContain("CEO workspace should stay in CEO dreams."); + expect(subagentCorpus).toContain("CEO workspace should stay in CEO dreams."); + expect(subagentCorpus).not.toContain("Main workspace should stay in main dreams."); + }); + it("redacts sensitive session content before writing session corpus", async () => { const workspaceDir = await createDreamingWorkspace(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index c91446f6d53..66d0f9ce9dc 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -112,9 +112,14 @@ function resolveWorkspaces(params: { cfg?: DreamingHostConfig; fallbackWorkspaceDir?: string; }): string[] { + const fallbackWorkspaceDir = normalizeTrimmedString(params.fallbackWorkspaceDir); const workspaceCandidates = params.cfg ? resolveMemoryDreamingWorkspaces( params.cfg as Parameters[0], + { + primaryWorkspaceDir: fallbackWorkspaceDir, + primaryAgentId: "main", + }, ).map((entry) => entry.workspaceDir) : []; const seen = new Set(); @@ -125,7 +130,6 @@ function resolveWorkspaces(params: { seen.add(workspaceDir); return true; }); - const fallbackWorkspaceDir = normalizeTrimmedString(params.fallbackWorkspaceDir); if (workspaces.length === 0 && fallbackWorkspaceDir) { workspaces.push(fallbackWorkspaceDir); } @@ -641,13 +645,22 @@ function buildSessionRenderedLine(params: { return `[${source}] ${params.snippet}`.slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS + 64); } -function resolveSessionAgentsForWorkspace(cfg: DreamingHostConfig, workspaceDir: string): string[] { +function resolveSessionAgentsForWorkspace(params: { + cfg: DreamingHostConfig; + workspaceDir: string; + primaryWorkspaceDir?: string; +}): string[] { + const { cfg, workspaceDir, primaryWorkspaceDir } = params; if (!cfg) { return []; } const target = normalizeWorkspaceKey(workspaceDir); const workspaces = resolveMemoryDreamingWorkspaces( cfg as Parameters[0], + { + primaryWorkspaceDir, + primaryAgentId: "main", + }, ); const match = workspaces.find((entry) => normalizeWorkspaceKey(entry.workspaceDir) === target); if (!match) { @@ -706,6 +719,7 @@ async function appendSessionCorpusLines(params: { async function collectSessionIngestionBatches(params: { workspaceDir: string; cfg?: DreamingHostConfig; + primaryWorkspaceDir?: string; lookbackDays: number; nowMs: number; timezone?: string; @@ -720,7 +734,11 @@ async function collectSessionIngestionBatches(params: { Object.keys(params.state.seenMessages).length > 0, }; } - const agentIds = resolveSessionAgentsForWorkspace(params.cfg, params.workspaceDir); + const agentIds = resolveSessionAgentsForWorkspace({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + primaryWorkspaceDir: params.primaryWorkspaceDir, + }); const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays); const batchByDay = new Map(); const nextFiles: Record = {}; @@ -1003,6 +1021,7 @@ async function collectSessionIngestionBatches(params: { async function ingestSessionTranscriptSignals(params: { workspaceDir: string; cfg?: DreamingHostConfig; + primaryWorkspaceDir?: string; lookbackDays: number; nowMs: number; timezone?: string; @@ -1011,6 +1030,7 @@ async function ingestSessionTranscriptSignals(params: { const collected = await collectSessionIngestionBatches({ workspaceDir: params.workspaceDir, cfg: params.cfg, + primaryWorkspaceDir: params.primaryWorkspaceDir, lookbackDays: params.lookbackDays, nowMs: params.nowMs, timezone: params.timezone, @@ -1520,6 +1540,7 @@ export function previewRemDreaming(params: { async function runLightDreaming(params: { workspaceDir: string; cfg?: DreamingHostConfig; + primaryWorkspaceDir?: string; config: LightDreamingConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; @@ -1537,6 +1558,7 @@ async function runLightDreaming(params: { await ingestSessionTranscriptSignals({ workspaceDir: params.workspaceDir, cfg: params.cfg, + primaryWorkspaceDir: params.primaryWorkspaceDir, lookbackDays: params.config.lookbackDays, nowMs, timezone: params.config.timezone, @@ -1617,6 +1639,7 @@ async function runLightDreaming(params: { async function runRemDreaming(params: { workspaceDir: string; cfg?: DreamingHostConfig; + primaryWorkspaceDir?: string; config: RemDreamingConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; @@ -1634,6 +1657,7 @@ async function runRemDreaming(params: { await ingestSessionTranscriptSignals({ workspaceDir: params.workspaceDir, cfg: params.cfg, + primaryWorkspaceDir: params.primaryWorkspaceDir, lookbackDays: params.config.lookbackDays, nowMs, timezone: params.config.timezone, @@ -1766,9 +1790,10 @@ async function runPhaseIfTriggered( if (!params.config.enabled) { return { handled: true, reason: `memory-core: ${params.phase} dreaming disabled` }; } + const primaryWorkspaceDir = normalizeTrimmedString(params.workspaceDir); const workspaces = resolveWorkspaces({ cfg: params.cfg, - fallbackWorkspaceDir: params.workspaceDir, + fallbackWorkspaceDir: primaryWorkspaceDir, }); if (workspaces.length === 0) { params.logger.warn( @@ -1786,6 +1811,7 @@ async function runPhaseIfTriggered( await runLightDreaming({ workspaceDir, cfg: params.cfg, + primaryWorkspaceDir, config: params.config, logger: params.logger, subagent: params.subagent, @@ -1794,6 +1820,7 @@ async function runPhaseIfTriggered( await runRemDreaming({ workspaceDir, cfg: params.cfg, + primaryWorkspaceDir, config: params.config, logger: params.logger, subagent: params.subagent, diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index 14553eac822..0c53810fa2f 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -2316,11 +2316,27 @@ describe("short-term dreaming trigger", () => { it("fans out one dreaming run across configured agent workspaces", async () => { const logger = createLogger(); const workspaceRoot = await createTempWorkspace("memory-dreaming-multi-"); + const mainWorkspace = path.join(workspaceRoot, "main"); const alphaWorkspace = path.join(workspaceRoot, "alpha"); const betaWorkspace = path.join(workspaceRoot, "beta"); + await writeDailyMemoryNote(mainWorkspace, "2026-04-02", ["Main workspace note."]); await writeDailyMemoryNote(alphaWorkspace, "2026-04-02", ["Alpha backup note."]); await writeDailyMemoryNote(betaWorkspace, "2026-04-02", ["Beta router note."]); + await recordShortTermRecalls({ + workspaceDir: mainWorkspace, + query: "main workspace", + results: [ + { + path: "memory/2026-04-02.md", + startLine: 1, + endLine: 1, + score: 0.9, + snippet: "Main workspace note.", + source: "memory", + }, + ], + }); await recordShortTermRecalls({ workspaceDir: alphaWorkspace, query: "alpha backup", @@ -2353,7 +2369,7 @@ describe("short-term dreaming trigger", () => { const result = await runShortTermDreamingPromotionIfTriggered({ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT, trigger: "heartbeat", - workspaceDir: alphaWorkspace, + workspaceDir: mainWorkspace, cfg: { agents: { defaults: { @@ -2387,6 +2403,9 @@ describe("short-term dreaming trigger", () => { }); expect(result?.handled).toBe(true); + expect(await fs.readFile(path.join(mainWorkspace, "MEMORY.md"), "utf-8")).toContain( + "Main workspace note.", + ); expect(await fs.readFile(path.join(alphaWorkspace, "MEMORY.md"), "utf-8")).toContain( "Alpha backup note.", ); @@ -2394,7 +2413,7 @@ describe("short-term dreaming trigger", () => { "Beta router note.", ); expect(logger.info).toHaveBeenCalledWith( - "memory-core: dreaming promotion complete (workspaces=2, candidates=2, applied=2, failed=0).", + "memory-core: dreaming promotion complete (workspaces=3, candidates=3, applied=3, failed=0).", ); }); }); diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index a50d2800ff0..73ddee33279 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -511,8 +511,12 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { const recencyHalfLifeDays = params.config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS; + const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir); const workspaceCandidates = params.cfg - ? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir) + ? resolveMemoryDreamingWorkspaces(params.cfg, { + primaryWorkspaceDir: fallbackWorkspaceDir, + primaryAgentId: "main", + }).map((entry) => entry.workspaceDir) : []; const seenWorkspaces = new Set(); const workspaces = workspaceCandidates.filter((workspaceDir) => { @@ -522,7 +526,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { seenWorkspaces.add(workspaceDir); return true; }); - const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir); if (workspaces.length === 0 && fallbackWorkspaceDir) { workspaces.push(fallbackWorkspaceDir); } diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index c1587ed49f6..a1aa14101b0 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -472,10 +472,7 @@ describe("doctor.memory.status", () => { enabled: true, }, }, - list: [ - { id: "main", workspace: mainWorkspaceDir }, - { id: "alpha", workspace: alphaWorkspaceDir }, - ], + list: [{ id: "alpha", workspace: alphaWorkspaceDir }], }, plugins: { entries: { @@ -649,7 +646,7 @@ describe("doctor.memory.status", () => { expect.objectContaining({ dreaming: expect.objectContaining({ shortTermCount: 0, - promotedTotal: 0, + promotedTotal: 1, phases: expect.objectContaining({ deep: expect.objectContaining({ managedCronPresent: false, diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 95e05fcb4de..4b4dec10b05 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -905,9 +905,10 @@ export const doctorHandlers: GatewayRequestHandlers = { const nowMs = Date.now(); const dreamingConfig = resolveDreamingConfig(cfg); const workspaceDir = normalizeTrimmedString((status as Record).workspaceDir); - const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map( - (entry) => entry.workspaceDir, - ); + const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg, { + primaryWorkspaceDir: workspaceDir, + primaryAgentId: resolveDefaultAgentId(cfg), + }).map((entry) => entry.workspaceDir); const allWorkspaces = configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : []; const storeStats = diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index 71eea31324b..58119a8062d 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -175,6 +175,37 @@ describe("memory dreaming host helpers", () => { ]); }); + it("includes the runtime primary workspace alongside configured subagent workspaces", () => { + const cfg = { + agents: { + list: [ + { id: "agi-ceo", workspace: "/workspace/agi-ceo" }, + { id: "agi-cdo", workspace: "/workspace/agi-cdo" }, + ], + }, + } as OpenClawConfig; + + expect( + resolveMemoryDreamingWorkspaces(cfg, { + primaryWorkspaceDir: "/workspace/main", + primaryAgentId: "main", + }), + ).toEqual([ + { + workspaceDir: "/workspace/agi-ceo", + agentIds: ["agi-ceo"], + }, + { + workspaceDir: "/workspace/agi-cdo", + agentIds: ["agi-cdo"], + }, + { + workspaceDir: "/workspace/main", + agentIds: ["main"], + }, + ]); + }); + it("uses default agent fallback and timezone-aware day helpers", () => { const cfg = { agents: { diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index 7c852b9e03c..095b751d7a9 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -146,6 +146,11 @@ export type MemoryDreamingWorkspace = { agentIds: string[]; }; +export type MemoryDreamingWorkspaceOptions = { + primaryWorkspaceDir?: string | null; + primaryAgentId?: string | null; +}; + const DEFAULT_MEMORY_LIGHT_DREAMING_SOURCES: MemoryLightDreamingSource[] = [ "daily", "sessions", @@ -603,7 +608,10 @@ export function isSameMemoryDreamingDay( ); } -export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] { +export function resolveMemoryDreamingWorkspaces( + cfg: OpenClawConfig, + options: MemoryDreamingWorkspaceOptions = {}, +): MemoryDreamingWorkspace[] { const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; const agentIds: string[] = []; const seenAgents = new Set(); @@ -623,18 +631,29 @@ export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDrea } const byWorkspace = new Map(); - for (const agentId of agentIds) { - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim(); + const addWorkspace = (workspaceDirRaw: string | undefined, agentIdRaw: string): void => { + const workspaceDir = workspaceDirRaw?.trim(); if (!workspaceDir) { - continue; + return; } + const agentId = normalizeOptionalLowercaseString(agentIdRaw) || resolveDefaultAgentId(cfg); const key = normalizePathForComparison(workspaceDir); const existing = byWorkspace.get(key); if (existing) { - existing.agentIds.push(agentId); - continue; + if (!existing.agentIds.includes(agentId)) { + existing.agentIds.push(agentId); + } + return; } byWorkspace.set(key, { workspaceDir, agentIds: [agentId] }); + }; + + for (const agentId of agentIds) { + addWorkspace(resolveAgentWorkspaceDir(cfg, agentId), agentId); } + addWorkspace( + options.primaryWorkspaceDir ?? undefined, + options.primaryAgentId ?? resolveDefaultAgentId(cfg), + ); return [...byWorkspace.values()]; }