fix(memory-core): vary dream diary recall snippets (#91225)

Prevent repeated first-day Dream Diary narratives by prioritizing fresh recall snippets across the bounded short-term store and adding recent diary context to narrative generation. Keep diary reads best-effort and reject symlink/non-file inputs.

Fixes #83830.

Thanks @mushuiyu886.

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
This commit is contained in:
mushuiyu_xydt
2026-06-15 13:02:06 +08:00
committed by GitHub
parent 7e0128ae65
commit 04875efd28
5 changed files with 387 additions and 16 deletions
@@ -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 = "<!-- openclaw:dreaming:deep:start -->";
@@ -19,7 +19,7 @@ type DreamsFileLockEntry = {
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
for (const name of DREAMS_FILENAMES) {
const target = path.join(workspaceDir, name);
try {
@@ -34,11 +34,27 @@ async function resolveDreamsPath(workspaceDir: string): Promise<string> {
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
}
async function readDreamsFile(dreamsPath: string): Promise<string> {
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<string> {
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;
@@ -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",
"",
"<!-- openclaw:dreaming:diary:start -->",
"---",
"",
"*April 5, 2026, 3:00 AM UTC*",
"",
symlinkTargetDiary,
"",
"<!-- openclaw:dreaming:diary:end -->",
"",
].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");
@@ -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 = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
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("<!--") || trimmed.startsWith("#")) {
continue;
}
if (trimmed.startsWith("*") && trimmed.endsWith("*") && trimmed.length > 2) {
continue;
}
bodyLines.push(trimmed);
}
return clampDiaryContextEntry(bodyLines.join(" "));
}
function isOptionalDiaryContextReadError(err: unknown): boolean {
const code = extractErrorCode(err);
if (
code === "EACCES" ||
code === "EPERM" ||
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 readRecentDreamDiaryEntries(params: {
workspaceDir: string;
limit?: number;
}): Promise<string[]> {
const limit = Math.max(0, Math.floor(params.limit ?? RECENT_DIARY_CONTEXT_LIMIT));
if (limit === 0) {
return [];
}
let existing: string;
try {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
existing = await readDreamsFile(dreamsPath);
} catch (err) {
if (isOptionalDiaryContextReadError(err)) {
return [];
}
throw err;
}
const startIdx = existing.indexOf(DIARY_START_MARKER);
const endIdx = existing.indexOf(DIARY_END_MARKER);
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
return [];
}
const inner = existing.slice(startIdx + DIARY_START_MARKER.length, endIdx);
return splitDiaryBlocks(inner)
.map(normalizeDiaryBlockBody)
.filter((entry) => entry.length > 0)
.slice(-limit)
.toReversed();
}
function normalizeDiaryBlockFingerprint(block: string): string {
const lines = block
.split("\n")
@@ -450,6 +450,131 @@ describe("memory-core dreaming phases", () => {
});
});
it("prefers a fresh light snippet outside the top diary-covered candidates", async () => {
const workspaceDir = await createDreamingWorkspace();
const stalePath = path.join(workspaceDir, "memory", "2026-04-03.md");
const freshPath = path.join(workspaceDir, "memory", "2026-04-04.md");
const nowMs = Date.parse("2026-04-05T10:05:00.000Z");
const staleSnippets = [
"初次见面时,我第一次醒来并认识了主人。",
"The first morning began beside a quiet terminal.",
"An early config file felt like the first map of home.",
"The initial heartbeat made the empty workspace feel awake.",
];
await fs.writeFile(stalePath, `${staleSnippets.join("\n")}\n`, "utf-8");
await fs.writeFile(
freshPath,
"Later routing notes: queue hydration changed after plugin reload.\n",
"utf-8",
);
for (const [index, snippet] of staleSnippets.entries()) {
for (let recall = 0; recall < staleSnippets.length - index; recall += 1) {
await recordShortTermRecalls({
workspaceDir,
query: `first-day-${index}-${recall}`,
nowMs,
results: [
{
path: "memory/2026-04-03.md",
startLine: index + 1,
endLine: index + 1,
score: 0.93,
snippet,
source: "memory",
},
],
});
}
}
await recordShortTermRecalls({
workspaceDir,
query: "routing queue reload",
nowMs,
results: [
{
path: "memory/2026-04-04.md",
startLine: 1,
endLine: 1,
score: 0.91,
snippet: "Later routing notes: queue hydration changed after plugin reload.",
source: "memory",
},
],
});
await fs.writeFile(
path.join(workspaceDir, "DREAMS.md"),
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
...staleSnippets.flatMap((snippet, index) => [
"---",
"",
`*April ${index + 1}, 2026, 10:00 AM UTC*`,
"",
snippet,
"",
]),
"<!-- openclaw:dreaming:diary:end -->",
"",
].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 () => {
+58 -10
View File
@@ -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({