fix(memory-core): ignore managed dreaming blocks during daily ingestion (#61720) (thanks @MonkeyLeeT)

This commit is contained in:
Ted Li
2026-04-06 05:22:54 -07:00
committed by GitHub
parent 10554644aa
commit 23730229e1
3 changed files with 214 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
## 2026.4.5

View File

@@ -11,6 +11,28 @@ import {
import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const DREAMING_TEST_BASE_TIME = new Date("2026-04-05T10:00:00.000Z");
const DREAMING_TEST_DAY = "2026-04-05";
const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 2,
},
},
},
},
},
},
},
};
function createHarness(config: OpenClawConfig, workspaceDir?: string) {
let beforeAgentReply:
@@ -55,7 +77,124 @@ function createHarness(config: OpenClawConfig, workspaceDir?: string) {
return { beforeAgentReply, logger };
}
function setDreamingTestTime(offsetMinutes = 0) {
vi.setSystemTime(new Date(DREAMING_TEST_BASE_TIME.getTime() + offsetMinutes * 60_000));
}
async function withDreamingTestClock(run: () => Promise<void>) {
vi.useFakeTimers();
try {
await run();
} finally {
vi.useRealTimers();
}
}
async function writeDailyNote(workspaceDir: string, lines: string[]): Promise<void> {
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "memory", `${DREAMING_TEST_DAY}.md`),
lines.join("\n"),
"utf-8",
);
}
function createLightDreamingHarness(workspaceDir: string) {
return createHarness(LIGHT_DREAMING_TEST_CONFIG, workspaceDir);
}
async function triggerLightDreaming(
beforeAgentReply: NonNullable<ReturnType<typeof createHarness>["beforeAgentReply"]>,
workspaceDir: string,
offsetMinutes: number,
): Promise<void> {
setDreamingTestTime(offsetMinutes);
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
}
async function readCandidateSnippets(workspaceDir: string, nowIso: string): Promise<string[]> {
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse(nowIso),
});
return candidates.map((candidate) => candidate.snippet);
}
describe("memory-core dreaming phases", () => {
it("does not re-ingest managed light dreaming blocks from daily notes", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-phases-");
await withDreamingTestClock(async () => {
await writeDailyNote(workspaceDir, [
`# ${DREAMING_TEST_DAY}`,
"",
"- Move backups to S3 Glacier.",
"- Keep retention at 365 days.",
]);
const { beforeAgentReply } = createLightDreamingHarness(workspaceDir);
const candidateCounts: number[] = [];
const candidateSnippets: string[][] = [];
for (let run = 0; run < 3; run += 1) {
await triggerLightDreaming(beforeAgentReply, workspaceDir, run + 1);
candidateSnippets.push(
await readCandidateSnippets(workspaceDir, `2026-04-05T10:0${run + 1}:00.000Z`),
);
candidateCounts.push(candidateSnippets.at(-1)?.length ?? 0);
}
expect(candidateCounts).toEqual([1, 1, 1]);
expect(candidateSnippets).toEqual([
["Move backups to S3 Glacier.; Keep retention at 365 days."],
["Move backups to S3 Glacier.; Keep retention at 365 days."],
["Move backups to S3 Glacier.; Keep retention at 365 days."],
]);
const dailyContent = await fs.readFile(
path.join(workspaceDir, "memory", `${DREAMING_TEST_DAY}.md`),
"utf-8",
);
expect(dailyContent).toContain("## Light Sleep");
expect(dailyContent.match(/^- Candidate:/gm)).toHaveLength(1);
expect(dailyContent).not.toContain("Light Sleep: Candidate:");
});
});
it("stops stripping a malformed managed block at the next section boundary", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-phases-");
await withDreamingTestClock(async () => {
await writeDailyNote(workspaceDir, [
`# ${DREAMING_TEST_DAY}`,
"",
"- Move backups to S3 Glacier.",
"",
"## Light Sleep",
"<!-- openclaw:dreaming:light:start -->",
"- Candidate: Old staged summary.",
"",
"## Ops",
"- Rotate access keys.",
"",
"## Light Sleep",
"<!-- openclaw:dreaming:light:start -->",
"- Candidate: Fresh staged summary.",
"<!-- openclaw:dreaming:light:end -->",
]);
const { beforeAgentReply } = createLightDreamingHarness(workspaceDir);
await triggerLightDreaming(beforeAgentReply, workspaceDir, 1);
expect(await readCandidateSnippets(workspaceDir, "2026-04-05T10:01:00.000Z")).toContain(
"Ops: Rotate access keys.",
);
});
});
it("checkpoints daily ingestion and skips unchanged daily files", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-phases-");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });

View File

@@ -86,6 +86,18 @@ const DAILY_INGESTION_MIN_SNIPPET_CHARS = 8;
const DAILY_INGESTION_MAX_CHUNK_LINES = 4;
const GENERIC_DAY_HEADING_RE =
/^(?:(?:mon|monday|tue|tues|tuesday|wed|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday|sun|sunday)(?:,\s+)?)?(?:(?:jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+\d{1,2}(?:st|nd|rd|th)?(?:,\s*\d{4})?|\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|\d{4}[/-]\d{2}[/-]\d{2})$/i;
const MANAGED_DAILY_DREAMING_BLOCKS = [
{
heading: "## Light Sleep",
startMarker: "<!-- openclaw:dreaming:light:start -->",
endMarker: "<!-- openclaw:dreaming:light:end -->",
},
{
heading: "## REM Sleep",
startMarker: "<!-- openclaw:dreaming:rem:start -->",
endMarker: "<!-- openclaw:dreaming:rem:end -->",
},
] as const;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -476,6 +488,67 @@ function buildDailySnippetChunks(lines: string[], limit: number): DailySnippetCh
return chunks.slice(0, limit);
}
function findManagedDailyDreamingHeadingIndex(
lines: string[],
startIndex: number,
heading: string,
): number | null {
for (let index = startIndex - 1; index >= 0; index -= 1) {
const trimmed = lines[index]?.trim() ?? "";
if (!trimmed) {
continue;
}
return trimmed === heading ? index : null;
}
return null;
}
function isManagedDailyDreamingBoundary(
line: string,
blockByStartMarker: ReadonlyMap<string, (typeof MANAGED_DAILY_DREAMING_BLOCKS)[number]>,
): boolean {
const trimmed = line.trim();
return /^#{1,6}\s+/.test(trimmed) || blockByStartMarker.has(trimmed);
}
function stripManagedDailyDreamingLines(lines: string[]): string[] {
const blockByStartMarker: ReadonlyMap<string, (typeof MANAGED_DAILY_DREAMING_BLOCKS)[number]> =
new Map(MANAGED_DAILY_DREAMING_BLOCKS.map((block) => [block.startMarker, block]));
const sanitized = [...lines];
for (let index = 0; index < sanitized.length; index += 1) {
const block = blockByStartMarker.get(sanitized[index]?.trim() ?? "");
if (!block) {
continue;
}
let stripUntilIndex = -1;
for (let cursor = index + 1; cursor < sanitized.length; cursor += 1) {
const line = sanitized[cursor];
const trimmed = line?.trim() ?? "";
if (trimmed === block.endMarker) {
stripUntilIndex = cursor;
break;
}
if (line && isManagedDailyDreamingBoundary(line, blockByStartMarker)) {
stripUntilIndex = cursor - 1;
break;
}
}
if (stripUntilIndex < index) {
continue;
}
const headingIndex = findManagedDailyDreamingHeadingIndex(lines, index, block.heading);
const startIndex = headingIndex ?? index;
for (let cursor = startIndex; cursor <= stripUntilIndex; cursor += 1) {
sanitized[cursor] = "";
}
index = stripUntilIndex;
}
return sanitized;
}
function entryWithinLookback(entry: ShortTermRecallEntry, cutoffMs: number): boolean {
const byDay = (entry.recallDays ?? []).some((day) => isDayWithinLookback(day, cutoffMs));
if (byDay) {
@@ -640,7 +713,7 @@ async function collectDailyIngestionBatches(params: {
if (!raw) {
continue;
}
const lines = raw.split(/\r?\n/);
const lines = stripManagedDailyDreamingLines(raw.split(/\r?\n/));
const chunks = buildDailySnippetChunks(lines, perFileCap);
const results: MemorySearchResult[] = [];
for (const chunk of chunks) {