From 4aa4f778e301ded7aeac0fb903b9389a921a4a3d Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 16 Apr 2026 11:40:44 -0700 Subject: [PATCH] fix: reuse hashed dreaming cleanup key --- .../memory-core/src/dreaming-narrative.ts | 5 +- .../memory-core/src/dreaming-phases.test.ts | 68 +++++++++++++- extensions/memory-core/src/dreaming-phases.ts | 88 +++++++++++-------- 3 files changed, 122 insertions(+), 39 deletions(-) diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 58b8ab74b6a..c8470b23f7f 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -177,7 +177,10 @@ async function startNarrativeRunOrFallback(params: { } } -function buildNarrativeSessionKey(params: { +/** + * Build the deterministic subagent session key used for dream narratives. + */ +export function buildNarrativeSessionKey(params: { workspaceDir: string; phase: NarrativePhaseData["phase"]; nowMs: number; diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 8b171c98348..76f970bbc9a 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core"; @@ -8,7 +9,7 @@ import { resolveMemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import { describe, expect, it, vi } from "vitest"; -import { __testing } from "./dreaming-phases.js"; +import { __testing, runDreamingSweepPhases } from "./dreaming-phases.js"; import { rankShortTermPromotionCandidates, recordShortTermRecalls, @@ -187,6 +188,71 @@ async function readCandidateSnippets(workspaceDir: string, nowIso: string): Prom } describe("memory-core dreaming phases", () => { + it("uses the hashed narrative session key for sweep-level fallback cleanup", async () => { + const workspaceDir = await createDreamingWorkspace(); + await writeDailyNote(workspaceDir, [ + `# ${DREAMING_TEST_DAY}`, + "", + "- Move backups to S3 Glacier.", + "- Keep retention at 365 days.", + ]); + const testConfig: OpenClawConfig = { + ...LIGHT_DREAMING_TEST_CONFIG, + agents: { + defaults: { + workspace: workspaceDir, + userTimezone: "UTC", + }, + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + timezone: "UTC", + phases: { + light: { + enabled: true, + limit: 20, + lookbackDays: 2, + }, + rem: { + enabled: false, + limit: 0, + lookbackDays: 2, + }, + }, + }, + }, + }, + }, + }, + }; + const subagent = createMockNarrativeSubagent("The archive hummed softly."); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + 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}-${nowMs}`; + + await runDreamingSweepPhases({ + workspaceDir, + cfg: testConfig, + pluginConfig: resolveMemoryCorePluginConfig(testConfig), + logger, + subagent, + nowMs, + }); + + expect(subagent.deleteSession).toHaveBeenCalledTimes(2); + expect(subagent.deleteSession.mock.calls[0]?.[0]).toEqual({ sessionKey: expectedSessionKey }); + expect(subagent.deleteSession.mock.calls[1]?.[0]).toEqual({ sessionKey: expectedSessionKey }); + }); + it("does not re-ingest managed light dreaming blocks from daily notes", async () => { const workspaceDir = await createDreamingWorkspace(); await withDreamingTestClock(async () => { diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 99abdb14047..bcae2c04b67 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; import { buildSessionEntry, listSessionFilesForAgent, @@ -17,11 +17,13 @@ import { resolveMemoryDreamingWorkspaces, resolveMemoryLightDreamingConfig, resolveMemoryRemDreamingConfig, - type MemoryLightDreamingConfig, - type MemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; -import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js"; +import { + buildNarrativeSessionKey, + generateAndAppendDreamNarrative, + type NarrativePhaseData, +} from "./dreaming-narrative.js"; import { asRecord, formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js"; import { readShortTermRecallEntries, @@ -31,26 +33,39 @@ import { } from "./short-term-promotion.js"; type Logger = Pick; +type DreamingHostConfig = unknown; type DreamingPhaseStorageConfig = { timezone?: string; storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; }; +type LightDreamingConfig = DreamingPhaseStorageConfig & { + enabled: boolean; + lookbackDays: number; + limit: number; + dedupeSimilarity: number; +}; +type RemDreamingConfig = DreamingPhaseStorageConfig & { + enabled: boolean; + lookbackDays: number; + limit: number; + minPatternStrength: number; +}; type RunPhaseIfTriggeredParams = { cleanedBody: string; trigger?: string; workspaceDir?: string; - cfg?: OpenClawConfig; + cfg?: DreamingHostConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; eventText: string; } & ( | { phase: "light"; - config: MemoryLightDreamingConfig & DreamingPhaseStorageConfig; + config: LightDreamingConfig; } | { phase: "rem"; - config: MemoryRemDreamingConfig & DreamingPhaseStorageConfig; + config: RemDreamingConfig; } ); const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__"; @@ -91,11 +106,13 @@ const MANAGED_DAILY_DREAMING_BLOCKS = [ ] as const; function resolveWorkspaces(params: { - cfg?: OpenClawConfig; + cfg?: DreamingHostConfig; fallbackWorkspaceDir?: string; }): string[] { const workspaceCandidates = params.cfg - ? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir) + ? resolveMemoryDreamingWorkspaces( + params.cfg as Parameters[0], + ).map((entry) => entry.workspaceDir) : []; const seen = new Set(); const workspaces = workspaceCandidates.filter((workspaceDir) => { @@ -603,15 +620,14 @@ function buildSessionRenderedLine(params: { return `[${source}] ${params.snippet}`.slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS + 64); } -function resolveSessionAgentsForWorkspace( - cfg: OpenClawConfig | undefined, - workspaceDir: string, -): string[] { +function resolveSessionAgentsForWorkspace(cfg: DreamingHostConfig, workspaceDir: string): string[] { if (!cfg) { return []; } const target = normalizeWorkspaceKey(workspaceDir); - const workspaces = resolveMemoryDreamingWorkspaces(cfg); + const workspaces = resolveMemoryDreamingWorkspaces( + cfg as Parameters[0], + ); const match = workspaces.find((entry) => normalizeWorkspaceKey(entry.workspaceDir) === target); if (!match) { return []; @@ -1473,11 +1489,8 @@ export function previewRemDreaming(params: { async function runLightDreaming(params: { workspaceDir: string; - cfg?: OpenClawConfig; - config: MemoryLightDreamingConfig & { - timezone?: string; - storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; - }; + cfg?: DreamingHostConfig; + config: LightDreamingConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; nowMs?: number; @@ -1553,11 +1566,8 @@ async function runLightDreaming(params: { async function runRemDreaming(params: { workspaceDir: string; - cfg?: OpenClawConfig; - config: MemoryRemDreamingConfig & { - timezone?: string; - storage: { mode: "inline" | "separate" | "both"; separateReports: boolean }; - }; + cfg?: DreamingHostConfig; + config: RemDreamingConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; nowMs?: number; @@ -1636,7 +1646,7 @@ async function runRemDreaming(params: { export async function runDreamingSweepPhases(params: { workspaceDir: string; pluginConfig?: Record; - cfg?: OpenClawConfig; + cfg?: DreamingHostConfig; logger: Logger; subagent?: Parameters[0]["subagent"]; nowMs?: number; @@ -1660,12 +1670,14 @@ export async function runDreamingSweepPhases(params: { // 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) { - const lightSessionKey = `dreaming-narrative-light-${sweepNowMs}`; - await params.subagent - .deleteSession({ sessionKey: lightSessionKey }) - .catch(() => { - // Swallow errors — this is best-effort cleanup. - }); + const lightSessionKey = buildNarrativeSessionKey({ + workspaceDir: params.workspaceDir, + phase: "light", + nowMs: sweepNowMs, + }); + await params.subagent.deleteSession({ sessionKey: lightSessionKey }).catch(() => { + // Swallow errors — this is best-effort cleanup. + }); } } @@ -1684,12 +1696,14 @@ export async function runDreamingSweepPhases(params: { }); // Defensive cleanup: ensure the REM-phase narrative session is deleted. if (params.subagent) { - const remSessionKey = `dreaming-narrative-rem-${sweepNowMs}`; - await params.subagent - .deleteSession({ sessionKey: remSessionKey }) - .catch(() => { - // Swallow errors — this is best-effort cleanup. - }); + const remSessionKey = buildNarrativeSessionKey({ + workspaceDir: params.workspaceDir, + phase: "rem", + nowMs: sweepNowMs, + }); + await params.subagent.deleteSession({ sessionKey: remSessionKey }).catch(() => { + // Swallow errors — this is best-effort cleanup. + }); } } }