Skip to content

Commit e38ec47

Browse files
authored
Merge 9977e88 into 1bd04ac
2 parents 1bd04ac + 9977e88 commit e38ec47

5 files changed

Lines changed: 376 additions & 8 deletions

File tree

extensions/memory-core/src/dreaming-dreams-file.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
55
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
66
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
77
import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown";
8-
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
8+
import { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
99

1010
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
1111
const DEEP_START_MARKER = "<!-- openclaw:dreaming:deep:start -->";
@@ -19,7 +19,7 @@ type DreamsFileLockEntry = {
1919

2020
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
2121

22-
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
22+
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
2323
for (const name of DREAMS_FILENAMES) {
2424
const target = path.join(workspaceDir, name);
2525
try {
@@ -34,11 +34,27 @@ async function resolveDreamsPath(workspaceDir: string): Promise<string> {
3434
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
3535
}
3636

37-
async function readDreamsFile(dreamsPath: string): Promise<string> {
37+
function isEmptyDreamsReadError(err: unknown): boolean {
38+
const code = extractErrorCode(err);
39+
if (
40+
code === "ENOENT" ||
41+
code === "ENOTDIR" ||
42+
code === "not-found" ||
43+
code === "not-file" ||
44+
code === "path-alias" ||
45+
code === "path-mismatch" ||
46+
code === "symlink"
47+
) {
48+
return true;
49+
}
50+
return err instanceof Error && err.message === "path must be a regular file";
51+
}
52+
53+
export async function readDreamsFile(dreamsPath: string): Promise<string> {
3854
try {
39-
return await fs.readFile(dreamsPath, "utf-8");
55+
return (await readRegularFile({ filePath: dreamsPath })).buffer.toString("utf-8");
4056
} catch (err) {
41-
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
57+
if (isEmptyDreamsReadError(err)) {
4258
return "";
4359
}
4460
throw err;

extensions/memory-core/src/dreaming-narrative.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
formatNarrativeDate,
2222
formatBackfillDiaryDate,
2323
generateAndAppendDreamNarrative,
24+
readRecentDreamDiaryEntries,
2425
removeBackfillDiaryEntries,
2526
runDetachedDreamNarrative,
2627
type NarrativePhaseData,
@@ -133,6 +134,19 @@ describe("buildNarrativePrompt", () => {
133134
expect(prompt).toContain("snippet-11");
134135
expect(prompt).not.toContain("snippet-12");
135136
});
137+
138+
it("includes current sweep and recent diary context", () => {
139+
const prompt = buildNarrativePrompt({
140+
phase: "light",
141+
snippets: ["Later workspace routing notes surfaced."],
142+
currentDate: "April 6, 2026, 9:00 AM UTC",
143+
recentDiaryEntries: ["The first meeting memory already filled the page."],
144+
});
145+
expect(prompt).toContain("Diary continuity context");
146+
expect(prompt).toContain("Current sweep: April 6, 2026, 9:00 AM UTC");
147+
expect(prompt).toContain("The first meeting memory already filled the page.");
148+
expect(prompt).toContain("do not replay the same first-day framing");
149+
});
136150
});
137151

138152
describe("extractNarrativeText", () => {
@@ -388,6 +402,77 @@ describe("appendNarrativeEntry", () => {
388402
expect(secondIdx).toBeLessThan(end);
389403
});
390404

405+
it("reads recent diary entries without timestamps or markers", async () => {
406+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
407+
await appendNarrativeEntry({
408+
workspaceDir,
409+
narrative: "The first meeting memory already filled the page.",
410+
nowMs: Date.parse("2026-04-04T03:00:00Z"),
411+
timezone: "UTC",
412+
});
413+
await appendNarrativeEntry({
414+
workspaceDir,
415+
narrative: "A later routing note flickered in the margins.",
416+
nowMs: Date.parse("2026-04-05T03:00:00Z"),
417+
timezone: "UTC",
418+
});
419+
420+
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 1 })).resolves.toEqual([
421+
"A later routing note flickered in the margins.",
422+
]);
423+
});
424+
425+
it("skips symlinked DREAMS.md when building recent diary context", async () => {
426+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
427+
const targetPath = path.join(workspaceDir, "target-dreams.md");
428+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
429+
const symlinkTargetDiary = "Symlink target diary text must not enter the prompt.";
430+
await fs.writeFile(
431+
targetPath,
432+
[
433+
"# Dream Diary",
434+
"",
435+
"<!-- openclaw:dreaming:diary:start -->",
436+
"---",
437+
"",
438+
"*April 5, 2026, 3:00 AM UTC*",
439+
"",
440+
symlinkTargetDiary,
441+
"",
442+
"<!-- openclaw:dreaming:diary:end -->",
443+
"",
444+
].join("\n"),
445+
"utf-8",
446+
);
447+
await fs.symlink(targetPath, dreamsPath);
448+
449+
const entries = await readRecentDreamDiaryEntries({ workspaceDir, limit: 3 });
450+
expect(entries).toEqual([]);
451+
const prompt = buildNarrativePrompt({
452+
phase: "light",
453+
snippets: ["A fresh routing memory arrived."],
454+
recentDiaryEntries: entries,
455+
});
456+
expect(prompt).not.toContain(symlinkTargetDiary);
457+
});
458+
459+
it("skips non-file DREAMS.md when reading recent diary context", async () => {
460+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
461+
await fs.mkdir(path.join(workspaceDir, "DREAMS.md"));
462+
463+
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]);
464+
});
465+
466+
it("treats unreadable DREAMS.md as empty recent diary context", async () => {
467+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
468+
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "unreadable", "utf-8");
469+
vi.spyOn(fs, "access").mockRejectedValueOnce(
470+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
471+
);
472+
473+
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]);
474+
});
475+
391476
it("prepends diary before existing managed blocks", async () => {
392477
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
393478
const dreamsPath = path.join(workspaceDir, "DREAMS.md");

extensions/memory-core/src/dreaming-narrative.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
resolveStorePath,
2121
updateSessionStore,
2222
} from "openclaw/plugin-sdk/session-store-runtime";
23-
import { updateDreamsFile } from "./dreaming-dreams-file.js";
23+
import { readDreamsFile, resolveDreamsPath, updateDreamsFile } from "./dreaming-dreams-file.js";
2424

2525
// ── Types ──────────────────────────────────────────────────────────────
2626

@@ -54,6 +54,8 @@ export type NarrativePhaseData = {
5454
themes?: string[];
5555
/** Snippets that were promoted to durable memory (deep). */
5656
promotions?: string[];
57+
currentDate?: string;
58+
recentDiaryEntries?: string[];
5759
};
5860

5961
type Logger = {
@@ -110,6 +112,8 @@ const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
110112
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
111113
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
112114
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
115+
const RECENT_DIARY_CONTEXT_LIMIT = 3;
116+
const RECENT_DIARY_CONTEXT_MAX_CHARS = 360;
113117
const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for(
114118
"openclaw.memoryCore.dreamingNarrative.sessionLocks",
115119
);
@@ -305,6 +309,27 @@ export function buildNarrativePrompt(data: NarrativePhaseData): string {
305309
}
306310
}
307311

312+
const currentDate = data.currentDate?.trim();
313+
const recentDiaryEntries = (data.recentDiaryEntries ?? [])
314+
.map(clampDiaryContextEntry)
315+
.filter((entry) => entry.length > 0)
316+
.slice(0, RECENT_DIARY_CONTEXT_LIMIT);
317+
if (currentDate || recentDiaryEntries.length > 0) {
318+
lines.push("\nDiary continuity context:");
319+
if (currentDate) {
320+
lines.push(`- Current sweep: ${currentDate}`);
321+
}
322+
if (recentDiaryEntries.length > 0) {
323+
lines.push("- Recent diary entries already written:");
324+
for (const entry of recentDiaryEntries) {
325+
lines.push(` - ${entry}`);
326+
}
327+
}
328+
lines.push(
329+
"- Prefer a fresh angle; do not replay the same first-day framing unless newer fragments change it.",
330+
);
331+
}
332+
308333
return lines.join("\n");
309334
}
310335

@@ -435,6 +460,78 @@ function splitDiaryBlocks(diaryContent: string): string[] {
435460
.filter((block) => block.length > 0);
436461
}
437462

463+
function clampDiaryContextEntry(entry: string): string {
464+
const normalized = entry.replace(/\s+/g, " ").trim();
465+
if (normalized.length <= RECENT_DIARY_CONTEXT_MAX_CHARS) {
466+
return normalized;
467+
}
468+
return `${normalized.slice(0, RECENT_DIARY_CONTEXT_MAX_CHARS).trimEnd()}...`;
469+
}
470+
471+
function normalizeDiaryBlockBody(block: string): string {
472+
const bodyLines: string[] = [];
473+
for (const line of block.split("\n")) {
474+
const trimmed = line.trim();
475+
if (!trimmed || trimmed.startsWith("<!--") || trimmed.startsWith("#")) {
476+
continue;
477+
}
478+
if (trimmed.startsWith("*") && trimmed.endsWith("*") && trimmed.length > 2) {
479+
continue;
480+
}
481+
bodyLines.push(trimmed);
482+
}
483+
return clampDiaryContextEntry(bodyLines.join(" "));
484+
}
485+
486+
function isOptionalDiaryContextReadError(err: unknown): boolean {
487+
const code = extractErrorCode(err);
488+
if (
489+
code === "EACCES" ||
490+
code === "EPERM" ||
491+
code === "ENOENT" ||
492+
code === "ENOTDIR" ||
493+
code === "not-found" ||
494+
code === "not-file" ||
495+
code === "path-alias" ||
496+
code === "path-mismatch" ||
497+
code === "symlink"
498+
) {
499+
return true;
500+
}
501+
return err instanceof Error && err.message === "path must be a regular file";
502+
}
503+
504+
export async function readRecentDreamDiaryEntries(params: {
505+
workspaceDir: string;
506+
limit?: number;
507+
}): Promise<string[]> {
508+
const limit = Math.max(0, Math.floor(params.limit ?? RECENT_DIARY_CONTEXT_LIMIT));
509+
if (limit === 0) {
510+
return [];
511+
}
512+
let existing: string;
513+
try {
514+
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
515+
existing = await readDreamsFile(dreamsPath);
516+
} catch (err) {
517+
if (isOptionalDiaryContextReadError(err)) {
518+
return [];
519+
}
520+
throw err;
521+
}
522+
const startIdx = existing.indexOf(DIARY_START_MARKER);
523+
const endIdx = existing.indexOf(DIARY_END_MARKER);
524+
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
525+
return [];
526+
}
527+
const inner = existing.slice(startIdx + DIARY_START_MARKER.length, endIdx);
528+
return splitDiaryBlocks(inner)
529+
.map(normalizeDiaryBlockBody)
530+
.filter((entry) => entry.length > 0)
531+
.slice(-limit)
532+
.toReversed();
533+
}
534+
438535
function normalizeDiaryBlockFingerprint(block: string): string {
439536
const lines = block
440537
.split("\n")

0 commit comments

Comments
 (0)