diff --git a/extensions/memory-core/src/dreaming-dreams-file.ts b/extensions/memory-core/src/dreaming-dreams-file.ts index af5c66e9d89a..03c9eef1ce83 100644 --- a/extensions/memory-core/src/dreaming-dreams-file.ts +++ b/extensions/memory-core/src/dreaming-dreams-file.ts @@ -5,7 +5,7 @@ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime"; import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime"; import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton"; import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown"; -import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; +import { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const; const DEEP_START_MARKER = ""; @@ -19,7 +19,7 @@ type DreamsFileLockEntry = { const dreamsFileLocks = resolveGlobalMap(DREAMS_FILE_LOCKS_KEY); -async function resolveDreamsPath(workspaceDir: string): Promise { +export async function resolveDreamsPath(workspaceDir: string): Promise { for (const name of DREAMS_FILENAMES) { const target = path.join(workspaceDir, name); try { @@ -34,11 +34,27 @@ async function resolveDreamsPath(workspaceDir: string): Promise { return path.join(workspaceDir, DREAMS_FILENAMES[0]); } -async function readDreamsFile(dreamsPath: string): Promise { +function isEmptyDreamsReadError(err: unknown): boolean { + const code = extractErrorCode(err); + if ( + code === "ENOENT" || + code === "ENOTDIR" || + code === "not-found" || + code === "not-file" || + code === "path-alias" || + code === "path-mismatch" || + code === "symlink" + ) { + return true; + } + return err instanceof Error && err.message === "path must be a regular file"; +} + +export async function readDreamsFile(dreamsPath: string): Promise { try { - return await fs.readFile(dreamsPath, "utf-8"); + return (await readRegularFile({ filePath: dreamsPath })).buffer.toString("utf-8"); } catch (err) { - if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + if (isEmptyDreamsReadError(err)) { return ""; } throw err; diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index 1e7c140caf99..e854cdf9010f 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -21,6 +21,7 @@ import { formatNarrativeDate, formatBackfillDiaryDate, generateAndAppendDreamNarrative, + readRecentDreamDiaryEntries, removeBackfillDiaryEntries, runDetachedDreamNarrative, type NarrativePhaseData, @@ -133,6 +134,19 @@ describe("buildNarrativePrompt", () => { expect(prompt).toContain("snippet-11"); expect(prompt).not.toContain("snippet-12"); }); + + it("includes current sweep and recent diary context", () => { + const prompt = buildNarrativePrompt({ + phase: "light", + snippets: ["Later workspace routing notes surfaced."], + currentDate: "April 6, 2026, 9:00 AM UTC", + recentDiaryEntries: ["The first meeting memory already filled the page."], + }); + expect(prompt).toContain("Diary continuity context"); + expect(prompt).toContain("Current sweep: April 6, 2026, 9:00 AM UTC"); + expect(prompt).toContain("The first meeting memory already filled the page."); + expect(prompt).toContain("do not replay the same first-day framing"); + }); }); describe("extractNarrativeText", () => { @@ -388,6 +402,77 @@ describe("appendNarrativeEntry", () => { expect(secondIdx).toBeLessThan(end); }); + it("reads recent diary entries without timestamps or markers", async () => { + const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + await appendNarrativeEntry({ + workspaceDir, + narrative: "The first meeting memory already filled the page.", + nowMs: Date.parse("2026-04-04T03:00:00Z"), + timezone: "UTC", + }); + await appendNarrativeEntry({ + workspaceDir, + narrative: "A later routing note flickered in the margins.", + nowMs: Date.parse("2026-04-05T03:00:00Z"), + timezone: "UTC", + }); + + await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 1 })).resolves.toEqual([ + "A later routing note flickered in the margins.", + ]); + }); + + it("skips symlinked DREAMS.md when building recent diary context", async () => { + const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + const targetPath = path.join(workspaceDir, "target-dreams.md"); + const dreamsPath = path.join(workspaceDir, "DREAMS.md"); + const symlinkTargetDiary = "Symlink target diary text must not enter the prompt."; + await fs.writeFile( + targetPath, + [ + "# Dream Diary", + "", + "", + "---", + "", + "*April 5, 2026, 3:00 AM UTC*", + "", + symlinkTargetDiary, + "", + "", + "", + ].join("\n"), + "utf-8", + ); + await fs.symlink(targetPath, dreamsPath); + + const entries = await readRecentDreamDiaryEntries({ workspaceDir, limit: 3 }); + expect(entries).toEqual([]); + const prompt = buildNarrativePrompt({ + phase: "light", + snippets: ["A fresh routing memory arrived."], + recentDiaryEntries: entries, + }); + expect(prompt).not.toContain(symlinkTargetDiary); + }); + + it("skips non-file DREAMS.md when reading recent diary context", async () => { + const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + await fs.mkdir(path.join(workspaceDir, "DREAMS.md")); + + await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]); + }); + + it("treats unreadable DREAMS.md as empty recent diary context", async () => { + const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "unreadable", "utf-8"); + vi.spyOn(fs, "access").mockRejectedValueOnce( + Object.assign(new Error("permission denied"), { code: "EACCES" }), + ); + + await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]); + }); + it("prepends diary before existing managed blocks", async () => { const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); const dreamsPath = path.join(workspaceDir, "DREAMS.md"); diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 6af34efe34f7..ad23b6fed97f 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -20,7 +20,7 @@ import { resolveStorePath, updateSessionStore, } from "openclaw/plugin-sdk/session-store-runtime"; -import { updateDreamsFile } from "./dreaming-dreams-file.js"; +import { readDreamsFile, resolveDreamsPath, updateDreamsFile } from "./dreaming-dreams-file.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -54,6 +54,8 @@ export type NarrativePhaseData = { themes?: string[]; /** Snippets that were promoted to durable memory (deep). */ promotions?: string[]; + currentDate?: string; + recentDiaryEntries?: string[]; }; type Logger = { @@ -110,6 +112,8 @@ const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; const DIARY_START_MARKER = ""; const DIARY_END_MARKER = ""; const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry"; +const RECENT_DIARY_CONTEXT_LIMIT = 3; +const RECENT_DIARY_CONTEXT_MAX_CHARS = 360; const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for( "openclaw.memoryCore.dreamingNarrative.sessionLocks", ); @@ -305,6 +309,27 @@ export function buildNarrativePrompt(data: NarrativePhaseData): string { } } + const currentDate = data.currentDate?.trim(); + const recentDiaryEntries = (data.recentDiaryEntries ?? []) + .map(clampDiaryContextEntry) + .filter((entry) => entry.length > 0) + .slice(0, RECENT_DIARY_CONTEXT_LIMIT); + if (currentDate || recentDiaryEntries.length > 0) { + lines.push("\nDiary continuity context:"); + if (currentDate) { + lines.push(`- Current sweep: ${currentDate}`); + } + if (recentDiaryEntries.length > 0) { + lines.push("- Recent diary entries already written:"); + for (const entry of recentDiaryEntries) { + lines.push(` - ${entry}`); + } + } + lines.push( + "- Prefer a fresh angle; do not replay the same first-day framing unless newer fragments change it.", + ); + } + return lines.join("\n"); } @@ -435,6 +460,78 @@ function splitDiaryBlocks(diaryContent: string): string[] { .filter((block) => block.length > 0); } +function clampDiaryContextEntry(entry: string): string { + const normalized = entry.replace(/\s+/g, " ").trim(); + if (normalized.length <= RECENT_DIARY_CONTEXT_MAX_CHARS) { + return normalized; + } + return `${normalized.slice(0, RECENT_DIARY_CONTEXT_MAX_CHARS).trimEnd()}...`; +} + +function normalizeDiaryBlockBody(block: string): string { + const bodyLines: string[] = []; + for (const line of block.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("", + ...staleSnippets.flatMap((snippet, index) => [ + "---", + "", + `*April ${index + 1}, 2026, 10:00 AM UTC*`, + "", + snippet, + "", + ]), + "", + "", + ].join("\n"), + "utf-8", + ); + const subagent = createMockNarrativeSubagent("A later routing note finally took the page."); + const testConfig: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + userTimezone: "UTC", + }, + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + timezone: "UTC", + storage: { mode: "inline", separateReports: false }, + phases: { + light: { + enabled: true, + limit: 1, + lookbackDays: 7, + }, + rem: { + enabled: false, + limit: 0, + lookbackDays: 7, + }, + }, + }, + }, + }, + }, + }, + }; + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + await runDreamingSweepPhases({ + workspaceDir, + cfg: testConfig, + pluginConfig: resolveMemoryCorePluginConfig(testConfig), + logger, + subagent, + nowMs, + }); + + const message = firstNarrativeRun(subagent).message; + expect(message).toContain("Later routing notes: queue hydration changed after plugin reload."); + expect(message).toContain("Recent diary entries already written"); + expect(message).not.toContain("\n- 初次见面时,我第一次醒来并认识了主人。"); + }); + it("triggers light dreaming when the token is embedded in a reminder body", 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 962688b3d80b..993851e532fa 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -24,6 +24,7 @@ import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/strin import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; import { generateAndAppendDreamNarrative, + readRecentDreamDiaryEntries, type NarrativePhaseData, runDetachedDreamNarrative, } from "./dreaming-narrative.js"; @@ -112,6 +113,8 @@ const SESSION_INGESTION_MIN_MESSAGES_PER_FILE = 12; const SESSION_INGESTION_MAX_TRACKED_MESSAGES_PER_SESSION = 4096; const SESSION_INGESTION_MAX_TRACKED_SCOPES = 2048; const SESSION_CHECKPOINT_TRANSCRIPT_FILENAME_RE = /\.checkpoint\..+\.jsonl$/i; +const LIGHT_DIARY_HISTORY_LIMIT = 4; +const LIGHT_DIARY_SNIPPET_SIMILARITY_THRESHOLD = 0.35; 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 = [ @@ -1476,6 +1479,46 @@ function dedupeEntries(entries: ShortTermRecallEntry[], threshold: number): Shor return deduped; } +function normalizeDiaryCoverageText(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function isEntryCoveredByRecentDiary( + entry: ShortTermRecallEntry, + recentDiaryEntries: readonly string[], +): boolean { + const snippet = normalizeDiaryCoverageText(entry.snippet); + if (!snippet) { + return false; + } + return recentDiaryEntries.some((diaryEntry) => { + const diaryText = normalizeDiaryCoverageText(diaryEntry); + return ( + diaryText.includes(snippet) || + snippetSimilarity(entry.snippet, diaryEntry) >= LIGHT_DIARY_SNIPPET_SIMILARITY_THRESHOLD + ); + }); +} + +function prioritizeLightEntriesByDiaryCoverage( + entries: ShortTermRecallEntry[], + recentDiaryEntries: readonly string[], +): ShortTermRecallEntry[] { + if (recentDiaryEntries.length === 0) { + return entries; + } + const fresh: ShortTermRecallEntry[] = []; + const covered: ShortTermRecallEntry[] = []; + for (const entry of entries) { + if (isEntryCoveredByRecentDiary(entry, recentDiaryEntries)) { + covered.push(entry); + } else { + fresh.push(entry); + } + } + return [...fresh, ...covered]; +} + function buildLightDreamingBody(entries: ShortTermRecallEntry[]): string[] { if (entries.length === 0) { return ["- No notable updates."]; @@ -1660,18 +1703,21 @@ async function runLightDreaming(params: { lookbackDays: params.config.lookbackDays, }), }); - const entries = dedupeEntries( - recentEntries - .toSorted((a, b) => { - const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt); - if (byTime !== 0) { - return byTime; - } - return b.recallCount - a.recallCount; - }) - .slice(0, params.config.limit), + const rankedEntries = dedupeEntries( + recentEntries.toSorted((a, b) => { + const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt); + if (byTime !== 0) { + return byTime; + } + return b.recallCount - a.recallCount; + }), params.config.dedupeSimilarity, ); + const recentDiaryEntries = await readRecentDreamDiaryEntries({ + workspaceDir: params.workspaceDir, + limit: LIGHT_DIARY_HISTORY_LIMIT, + }); + const entries = prioritizeLightEntriesByDiaryCoverage(rankedEntries, recentDiaryEntries); const capped = entries.slice(0, params.config.limit); const bodyLines = buildLightDreamingBody(capped); await writeDailyDreamingPhaseBlock({ @@ -1699,7 +1745,9 @@ async function runLightDreaming(params: { const data: NarrativePhaseData = { phase: "light", snippets: capped.map((e) => e.snippet).filter(Boolean), + currentDate: formatMemoryDreamingDay(nowMs, params.config.timezone), ...(themes.length > 0 ? { themes } : {}), + ...(recentDiaryEntries.length > 0 ? { recentDiaryEntries } : {}), }; if (params.detachNarratives) { runDetachedDreamNarrative({