diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 64f14f0777e..ce5f97ce1cc 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -720,6 +720,119 @@ describe("memory-core dreaming phases", () => { ]); }); + it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => { + const workspaceDir = await createDreamingWorkspace(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl"); + await fs.writeFile( + transcriptPath, + [ + JSON.stringify({ + type: "message", + message: { + role: "user", + timestamp: "2026-04-05T18:01:00.000Z", + content: [ + { type: "text", text: "Write a dream diary entry from these memory fragments." }, + ], + }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + timestamp: "2026-04-05T18:02:00.000Z", + content: [{ type: "text", text: "I drift through the same archive again." }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + await fs.writeFile( + path.join(sessionsDir, "sessions.json"), + JSON.stringify({ + "agent:main:dreaming-narrative-light-1775894400455": { + sessionId: "dreaming-narrative", + sessionFile: transcriptPath, + updatedAt: Date.parse("2026-04-05T18:05:00.000Z"), + }, + }), + "utf-8", + ); + const mtime = new Date("2026-04-05T18:05:00.000Z"); + await fs.utimes(transcriptPath, mtime, mtime); + + const { beforeAgentReply } = createHarness( + { + agents: { + defaults: { + workspace: workspaceDir, + }, + list: [{ id: "main", workspace: workspaceDir }], + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + phases: { + light: { + enabled: true, + limit: 20, + lookbackDays: 7, + }, + }, + }, + }, + }, + }, + }, + }, + workspaceDir, + ); + + try { + await beforeAgentReply( + { cleanedBody: "__openclaw_memory_core_light_sleep__" }, + { trigger: "heartbeat", workspaceDir }, + ); + } finally { + vi.unstubAllEnvs(); + } + + await expect( + fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + const sessionIngestion = JSON.parse( + await fs.readFile( + path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"), + "utf-8", + ), + ) as { + files: Record< + string, + { + lineCount: number; + lastContentLine: number; + contentHash: string; + } + >; + }; + expect(Object.keys(sessionIngestion.files)).toHaveLength(1); + expect(Object.values(sessionIngestion.files)).toEqual([ + expect.objectContaining({ + lineCount: 0, + lastContentLine: 0, + contentHash: expect.any(String), + }), + ]); + }); + it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => { const workspaceDir = await createDreamingWorkspace(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); diff --git a/src/memory-host-sdk/host/session-files.test.ts b/src/memory-host-sdk/host/session-files.test.ts index c585698b96c..fd5d9a22efb 100644 --- a/src/memory-host-sdk/host/session-files.test.ts +++ b/src/memory-host-sdk/host/session-files.test.ts @@ -175,6 +175,50 @@ describe("buildSessionEntry", () => { expect(entry?.generatedByDreamingNarrative).toBe(true); }); + it("flags dreaming narrative transcripts from the sibling session store before bootstrap lands", async () => { + const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const filePath = path.join(sessionsDir, "dreaming-session.jsonl"); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + type: "message", + message: { + role: "user", + content: + "Write a dream diary entry from these memory fragments:\n- Candidate: durable note", + }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + content: "A drifting archive breathed in moonlight.", + }, + }), + ].join("\n"), + ); + await fs.writeFile( + path.join(sessionsDir, "sessions.json"), + JSON.stringify({ + "agent:main:dreaming-narrative-light-1775894400455": { + sessionId: "dreaming-session", + sessionFile: filePath, + updatedAt: Date.now(), + }, + }), + "utf-8", + ); + + const entry = await buildSessionEntry(filePath); + + expect(entry).not.toBeNull(); + expect(entry?.generatedByDreamingNarrative).toBe(true); + expect(entry?.content).toBe(""); + expect(entry?.lineMap).toEqual([]); + }); + it("does not flag ordinary transcripts that quote the dream-diary prompt", async () => { const jsonlLines = [ JSON.stringify({ diff --git a/src/memory-host-sdk/host/session-files.ts b/src/memory-host-sdk/host/session-files.ts index 221f21f8ca5..61dc8fe5d51 100644 --- a/src/memory-host-sdk/host/session-files.ts +++ b/src/memory-host-sdk/host/session-files.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isUsageCountedSessionTranscriptFileName } from "../../config/sessions/artifacts.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; +import { loadSessionStore } from "../../config/sessions/store-load.js"; import { redactSensitiveText } from "../../logging/redact.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { hashText } from "./internal.js"; @@ -78,6 +79,59 @@ function isDreamingNarrativeGeneratedRecord(record: unknown): boolean { return hasDreamingNarrativeRunId(nested.runId) || hasDreamingNarrativeRunId(nested.sessionKey); } +function isDreamingNarrativeSessionStoreKey(sessionKey: string): boolean { + const trimmed = sessionKey.trim(); + if (!trimmed) { + return false; + } + const firstSeparator = trimmed.indexOf(":"); + if (firstSeparator < 0) { + return trimmed.startsWith(DREAMING_NARRATIVE_RUN_PREFIX); + } + const secondSeparator = trimmed.indexOf(":", firstSeparator + 1); + const sessionSegment = secondSeparator < 0 ? trimmed : trimmed.slice(secondSeparator + 1); + return sessionSegment.startsWith(DREAMING_NARRATIVE_RUN_PREFIX); +} + +function normalizeComparablePath(pathname: string): string { + const resolved = path.resolve(pathname); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function resolveSessionStoreTranscriptPath( + sessionsDir: string, + entry: { sessionFile?: unknown; sessionId?: unknown } | undefined, +): string | null { + if (typeof entry?.sessionFile === "string" && entry.sessionFile.trim().length > 0) { + const sessionFile = entry.sessionFile.trim(); + const resolved = path.isAbsolute(sessionFile) + ? sessionFile + : path.resolve(sessionsDir, sessionFile); + return normalizeComparablePath(resolved); + } + if (typeof entry?.sessionId === "string" && entry.sessionId.trim().length > 0) { + return normalizeComparablePath(path.join(sessionsDir, `${entry.sessionId.trim()}.jsonl`)); + } + return null; +} + +function isDreamingNarrativeTranscriptFromSessionStore(absPath: string): boolean { + const sessionsDir = path.dirname(absPath); + const storePath = path.join(sessionsDir, "sessions.json"); + const normalizedAbsPath = normalizeComparablePath(absPath); + const store = loadSessionStore(storePath); + for (const [sessionKey, entry] of Object.entries(store)) { + if (!isDreamingNarrativeSessionStoreKey(sessionKey)) { + continue; + } + const transcriptPath = resolveSessionStoreTranscriptPath(sessionsDir, entry); + if (transcriptPath === normalizedAbsPath) { + return true; + } + } + return false; +} + export async function listSessionFilesForAgent(agentId: string): Promise { const dir = resolveSessionTranscriptsDirForAgent(agentId); try { @@ -161,7 +215,7 @@ export async function buildSessionEntry(absPath: string): Promise