Skip to content

Commit 329fa44

Browse files
committed
fix(memory-core): write deep sleep summaries to dreams
1 parent aef1fad commit 329fa44

5 files changed

Lines changed: 265 additions & 111 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Memory Core helpers for safe managed DREAMS.md updates.
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
5+
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
6+
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
7+
import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown";
8+
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
9+
10+
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
11+
const DEEP_START_MARKER = "<!-- openclaw:dreaming:deep:start -->";
12+
const DEEP_END_MARKER = "<!-- openclaw:dreaming:deep:end -->";
13+
const DREAMS_FILE_LOCKS_KEY = Symbol.for("openclaw.memoryCore.dreamingNarrative.fileLocks");
14+
15+
type DreamsFileLockEntry = {
16+
withLock: ReturnType<typeof createAsyncLock>;
17+
refs: number;
18+
};
19+
20+
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
21+
22+
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
23+
for (const name of DREAMS_FILENAMES) {
24+
const target = path.join(workspaceDir, name);
25+
try {
26+
await fs.access(target);
27+
return target;
28+
} catch (err) {
29+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
30+
throw err;
31+
}
32+
}
33+
}
34+
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
35+
}
36+
37+
async function readDreamsFile(dreamsPath: string): Promise<string> {
38+
try {
39+
return await fs.readFile(dreamsPath, "utf-8");
40+
} catch (err) {
41+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
42+
return "";
43+
}
44+
throw err;
45+
}
46+
}
47+
48+
async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
49+
const stat = await fs.lstat(dreamsPath).catch((err: unknown) => {
50+
if (extractErrorCode(err) === "ENOENT") {
51+
return null;
52+
}
53+
throw err;
54+
});
55+
if (!stat) {
56+
return;
57+
}
58+
if (stat.isSymbolicLink()) {
59+
throw new Error("Refusing to write symlinked DREAMS.md");
60+
}
61+
if (!stat.isFile()) {
62+
throw new Error("Refusing to write non-file DREAMS.md");
63+
}
64+
}
65+
66+
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
67+
await assertSafeDreamsPath(dreamsPath);
68+
await replaceFileAtomic({
69+
filePath: dreamsPath,
70+
content,
71+
mode: 0o600,
72+
preserveExistingMode: true,
73+
tempPrefix: `${path.basename(dreamsPath)}.dreams`,
74+
throwOnCleanupError: true,
75+
});
76+
}
77+
78+
export async function updateDreamsFile<T>(params: {
79+
workspaceDir: string;
80+
updater: (
81+
existing: string,
82+
dreamsPath: string,
83+
) =>
84+
| Promise<{ content: string; result: T; shouldWrite?: boolean }>
85+
| {
86+
content: string;
87+
result: T;
88+
shouldWrite?: boolean;
89+
};
90+
}): Promise<T> {
91+
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
92+
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
93+
let lockEntry = dreamsFileLocks.get(dreamsPath);
94+
if (!lockEntry) {
95+
lockEntry = { withLock: createAsyncLock(), refs: 0 };
96+
dreamsFileLocks.set(dreamsPath, lockEntry);
97+
}
98+
lockEntry.refs += 1;
99+
try {
100+
return await lockEntry.withLock(async () => {
101+
const existing = await readDreamsFile(dreamsPath);
102+
const { content, result, shouldWrite = true } = await params.updater(existing, dreamsPath);
103+
if (shouldWrite) {
104+
await writeDreamsFileAtomic(dreamsPath, content.endsWith("\n") ? content : `${content}\n`);
105+
}
106+
return result;
107+
});
108+
} finally {
109+
lockEntry.refs -= 1;
110+
if (lockEntry.refs <= 0 && dreamsFileLocks.get(dreamsPath) === lockEntry) {
111+
dreamsFileLocks.delete(dreamsPath);
112+
}
113+
}
114+
}
115+
116+
export async function updateDeepDreamsFile(params: {
117+
workspaceDir: string;
118+
bodyLines: string[];
119+
}): Promise<string> {
120+
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
121+
return await updateDreamsFile({
122+
workspaceDir: params.workspaceDir,
123+
updater: (existing, dreamsPath) => ({
124+
content: replaceManagedMarkdownBlock({
125+
original: existing,
126+
heading: "## Deep Sleep",
127+
startMarker: DEEP_START_MARKER,
128+
endMarker: DEEP_END_MARKER,
129+
body,
130+
}),
131+
result: dreamsPath,
132+
}),
133+
});
134+
}

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

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,117 @@ describe("dreaming markdown storage", () => {
160160
expect(content).toContain("# Deep Sleep");
161161
expect(content).toContain("- Promoted: durable preference");
162162

163-
await expectPathMissing(path.join(workspaceDir, "DREAMS.md"));
163+
const dreamsContent = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
164+
expect(dreamsContent).toContain("## Deep Sleep");
165+
expect(dreamsContent).toContain("<!-- openclaw:dreaming:deep:start -->");
166+
expect(dreamsContent).toContain("- Promoted: durable preference");
167+
});
168+
169+
it("writes the deep summary to DREAMS.md without a separate report in inline mode", async () => {
170+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
171+
172+
const reportPath = await writeDeepDreamingReport({
173+
workspaceDir,
174+
bodyLines: ["- Ranked 3 candidate(s) for durable promotion."],
175+
storage: {
176+
mode: "inline",
177+
separateReports: false,
178+
},
179+
nowMs: Date.parse("2026-04-05T10:00:00Z"),
180+
timezone: "UTC",
181+
});
182+
183+
expect(reportPath).toBeUndefined();
184+
await expectPathMissing(path.join(workspaceDir, "memory", "dreaming", "deep", "2026-04-05.md"));
185+
const dreamsContent = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
186+
expect(dreamsContent).toContain("## Deep Sleep");
187+
expect(dreamsContent).toContain("- Ranked 3 candidate(s) for durable promotion.");
188+
});
189+
190+
it("replaces the managed deep summary while preserving the diary block", async () => {
191+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
192+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
193+
await fs.writeFile(
194+
dreamsPath,
195+
[
196+
"# Dream Diary",
197+
"",
198+
"<!-- openclaw:dreaming:diary:start -->",
199+
"",
200+
"---",
201+
"",
202+
"*April 4, 2026, 3:00 AM*",
203+
"",
204+
"The old diary entry stays.",
205+
"",
206+
"<!-- openclaw:dreaming:diary:end -->",
207+
"",
208+
"## Deep Sleep",
209+
"<!-- openclaw:dreaming:deep:start -->",
210+
"- Old summary.",
211+
"<!-- openclaw:dreaming:deep:end -->",
212+
"",
213+
].join("\n"),
214+
"utf-8",
215+
);
216+
217+
await writeDeepDreamingReport({
218+
workspaceDir,
219+
bodyLines: ["- New summary."],
220+
storage: {
221+
mode: "inline",
222+
separateReports: false,
223+
},
224+
nowMs,
225+
timezone,
226+
});
227+
228+
const dreamsContent = await fs.readFile(dreamsPath, "utf-8");
229+
expect(dreamsContent).toContain("The old diary entry stays.");
230+
expect(dreamsContent).toContain("- New summary.");
231+
expect(dreamsContent).not.toContain("- Old summary.");
232+
});
233+
234+
it("reuses existing lowercase dreams.md for deep summaries", async () => {
235+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
236+
const lowercasePath = path.join(workspaceDir, "dreams.md");
237+
await fs.writeFile(lowercasePath, "# Existing dreams\n", "utf-8");
238+
239+
await writeDeepDreamingReport({
240+
workspaceDir,
241+
bodyLines: ["- Lowercase target."],
242+
storage: {
243+
mode: "inline",
244+
separateReports: false,
245+
},
246+
nowMs,
247+
timezone,
248+
});
249+
250+
const dreamsContent = await fs.readFile(lowercasePath, "utf-8");
251+
expect(dreamsContent).toContain("# Existing dreams");
252+
expect(dreamsContent).toContain("- Lowercase target.");
253+
});
254+
255+
it("refuses to overwrite a symlinked DREAMS.md for deep summaries", async () => {
256+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
257+
const targetPath = path.join(workspaceDir, "outside.txt");
258+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
259+
await fs.writeFile(targetPath, "outside\n", "utf-8");
260+
await fs.symlink(targetPath, dreamsPath);
261+
262+
await expect(
263+
writeDeepDreamingReport({
264+
workspaceDir,
265+
bodyLines: ["- Do not escape workspace."],
266+
storage: {
267+
mode: "inline",
268+
separateReports: false,
269+
},
270+
nowMs,
271+
timezone,
272+
}),
273+
).rejects.toThrow("Refusing to write symlinked DREAMS.md");
274+
await expect(fs.readFile(targetPath, "utf-8")).resolves.toBe("outside\n");
164275
});
165276
});

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
replaceManagedMarkdownBlock,
1212
withTrailingNewline,
1313
} from "openclaw/plugin-sdk/memory-host-markdown";
14+
import { updateDeepDreamsFile } from "./dreaming-dreams-file.js";
1415
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
1516

1617
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
@@ -130,19 +131,24 @@ export async function writeDeepDreamingReport(params: {
130131
timezone?: string;
131132
storage: MemoryDreamingStorageConfig;
132133
}): Promise<string | undefined> {
133-
if (!shouldWriteSeparate(params.storage)) {
134-
return undefined;
135-
}
136134
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
137-
const reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
138-
await fs.mkdir(path.dirname(reportPath), { recursive: true });
139135
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
140-
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
136+
const inlinePath = await updateDeepDreamsFile({
137+
workspaceDir: params.workspaceDir,
138+
bodyLines: params.bodyLines,
139+
});
140+
let reportPath: string | undefined;
141+
if (shouldWriteSeparate(params.storage)) {
142+
reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
143+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
144+
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
145+
}
141146
await appendMemoryHostEvent(params.workspaceDir, {
142147
type: "memory.dream.completed",
143148
timestamp: resolveMemoryCoreTimestamp(nowMs),
144149
phase: "deep",
145-
reportPath,
150+
inlinePath,
151+
...(reportPath ? { reportPath } : {}),
146152
lineCount: params.bodyLines.length,
147153
storageMode: params.storage.mode,
148154
});

0 commit comments

Comments
 (0)