diff --git a/CHANGELOG.md b/CHANGELOG.md index 10960c8984e..43dc04af70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -326,6 +326,7 @@ Docs: https://docs.openclaw.ai - Agents/auth-profiles: do not record request-shape (`format`) rejections as auth-profile health failures, so a single per-session transcript-shape error (such as a prefill-strict 400 "conversation must end with a user message") no longer triggers a profile-wide cooldown that blocks every other healthy session sharing the same auth profile. Refs #77228. (#77280) Thanks @openperf. - CLI/update: stop dev-channel source updates immediately when `git fetch` fails, so tag conflicts cannot keep preflight, rebase, or build steps running against stale refs while the Gateway is still on the old runtime. (#77845) Thanks @obviyus. - Config/recovery: chmod restored `openclaw.json` back to owner-only (`0600`) after suspicious-read backup recovery on POSIX hosts, so a previously world-readable config mode cannot persist into a freshly restored credential-bearing config. (#77488) Thanks @drobison00. +- Memory/dreaming: persist last dreaming-ingestion calendar day per daily note in `daily-ingestion.json` so unchanged notes are still re-ingested once per dreaming day for promotion signals toward deep thresholds. Fixes #76225. (#76359) Thanks @neeravmakwana. ## 2026.5.3-1 diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 61ea3b14252..0d7e12d107d 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -2586,17 +2586,6 @@ describe("memory-core dreaming phases", () => { expect(after1).toHaveLength(1); expect(after1[0]?.dailyCount).toBe(1); - // Clear the daily ingestion checkpoint so the file is re-read on the second - // sweep (simulating a new day where the same lookback window still covers - // this file). - const dailyStatePath = path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json"); - try { - await fs.unlink(dailyStatePath); - } catch { - // ignore if not created - } - - // Second ingestion on 2026-04-06 (next day). const day2Ms = Date.parse("2026-04-06T10:00:00.000Z"); const { beforeAgentReply: reply2 } = createHarness(configForTest, workspaceDir); await withDreamingTestClock(async () => { @@ -2615,8 +2604,6 @@ describe("memory-core dreaming phases", () => { nowMs: day2Ms, }); expect(after2).toHaveLength(1); - // With the fix, dailyCount should be 2 because the ingestion date changed. - // Before the fix, it stayed at 1 because dayBucket was the file date. expect(after2[0]?.dailyCount).toBe(2); }); }); diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 66d0f9ce9dc..9e7d68aab83 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -72,6 +72,7 @@ type RunPhaseIfTriggeredParams = { ); const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__"; const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__"; +const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; const DAILY_MEMORY_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/; const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join("memory", ".dreams", "daily-ingestion.json"); const DAILY_INGESTION_SCORE = 0.62; @@ -386,6 +387,7 @@ type DailyIngestionBatch = { type DailyIngestionFileState = { mtimeMs: number; size: number; + lastDreamingDayIngested?: string; }; type DailyIngestionState = { @@ -417,9 +419,11 @@ function normalizeDailyIngestionState(raw: unknown): DailyIngestionState { if (!Number.isFinite(mtimeMs) || mtimeMs < 0 || !Number.isFinite(size) || size < 0) { continue; } + const lastDreamingDayIngested = normalizeMemoryDay(file.lastDreamingDayIngested); files[key] = { mtimeMs: Math.floor(mtimeMs), size: Math.floor(size), + ...(lastDreamingDayIngested ? { lastDreamingDayIngested } : {}), }; } return { @@ -428,6 +432,14 @@ function normalizeDailyIngestionState(raw: unknown): DailyIngestionState { }; } +function normalizeMemoryDay(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const day = value.trim(); + return MEMORY_DAY_RE.test(day) ? day : undefined; +} + async function readDailyIngestionState(workspaceDir: string): Promise { const statePath = resolveDailyIngestionStatePath(workspaceDir); try { @@ -1065,6 +1077,7 @@ async function collectDailyIngestionBatches(params: { lookbackDays: number; limit: number; nowMs: number; + ingestionDreamingDay: string; state: DailyIngestionState; }): Promise { const memoryDir = path.join(params.workspaceDir, "memory"); @@ -1119,11 +1132,15 @@ async function collectDailyIngestionBatches(params: { previous !== undefined && previous.mtimeMs === fingerprint.mtimeMs && previous.size === fingerprint.size; - if (!unchanged) { - changed = true; - } else { + const previousDreamingDay = normalizeMemoryDay(previous?.lastDreamingDayIngested); + if (unchanged && previousDreamingDay === params.ingestionDreamingDay) { + nextFiles[relativePath] = { + ...fingerprint, + lastDreamingDayIngested: previousDreamingDay, + }; continue; } + changed = true; const raw = await fs.readFile(filePath, "utf-8").catch((err: unknown) => { if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { @@ -1155,6 +1172,10 @@ async function collectDailyIngestionBatches(params: { } batches.push({ day: file.day, results }); total += results.length; + nextFiles[relativePath] = { + ...fingerprint, + lastDreamingDayIngested: params.ingestionDreamingDay, + }; if (total >= totalCap) { break; } @@ -1189,14 +1210,15 @@ async function ingestDailyMemorySignals(params: { timezone?: string; }): Promise { const state = await readDailyIngestionState(params.workspaceDir); + const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone); const collected = await collectDailyIngestionBatches({ workspaceDir: params.workspaceDir, lookbackDays: params.lookbackDays, limit: params.limit, nowMs: params.nowMs, + ingestionDreamingDay: ingestionDayBucket, state, }); - const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone); for (const batch of collected.batches) { await recordShortTermRecalls({ workspaceDir: params.workspaceDir,