mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 04:01:05 +00:00
fix(memory-core): ignore managed dreaming blocks during daily ingestion (#61720) (thanks @MonkeyLeeT)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user