fix: reuse hashed dreaming cleanup key

This commit is contained in:
Josh Lehman
2026-04-16 11:40:44 -07:00
parent 4cc69c38cf
commit 4aa4f778e3
3 changed files with 122 additions and 39 deletions

View File

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

View File

@@ -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 () => {

View File

@@ -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<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
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<typeof generateAndAppendDreamNarrative>[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<typeof resolveMemoryDreamingWorkspaces>[0],
).map((entry) => entry.workspaceDir)
: [];
const seen = new Set<string>();
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<typeof resolveMemoryDreamingWorkspaces>[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<typeof generateAndAppendDreamNarrative>[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<typeof generateAndAppendDreamNarrative>[0]["subagent"];
nowMs?: number;
@@ -1636,7 +1646,7 @@ async function runRemDreaming(params: {
export async function runDreamingSweepPhases(params: {
workspaceDir: string;
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
cfg?: DreamingHostConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[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.
});
}
}
}