fix: include primary dreaming workspace

This commit is contained in:
Peter Steinberger
2026-05-02 09:24:54 +01:00
parent 5f6adaf157
commit 99f1db33bf
10 changed files with 213 additions and 22 deletions

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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");

View File

@@ -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<typeof resolveMemoryDreamingWorkspaces>[0],
{
primaryWorkspaceDir: fallbackWorkspaceDir,
primaryAgentId: "main",
},
).map((entry) => entry.workspaceDir)
: [];
const seen = new Set<string>();
@@ -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<typeof resolveMemoryDreamingWorkspaces>[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<string, SessionIngestionMessage[]>();
const nextFiles: Record<string, SessionIngestionFileState> = {};
@@ -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<typeof generateAndAppendDreamNarrative>[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<typeof generateAndAppendDreamNarrative>[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,

View File

@@ -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).",
);
});
});

View File

@@ -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<string>();
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);
}

View File

@@ -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,

View File

@@ -905,9 +905,10 @@ export const doctorHandlers: GatewayRequestHandlers = {
const nowMs = Date.now();
const dreamingConfig = resolveDreamingConfig(cfg);
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).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 =

View File

@@ -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: {

View File

@@ -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<string>();
@@ -623,18 +631,29 @@ export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDrea
}
const byWorkspace = new Map<string, MemoryDreamingWorkspace>();
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()];
}