Skip to content

Commit a1b01f0

Browse files
authored
fix(memory-core): skip dreaming transcript ingestion via session store (#67315)
Merged via squash. Prepared head SHA: 87c09b2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 5dcf526 commit a1b01f0

6 files changed

Lines changed: 273 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ Docs: https://docs.openclaw.ai
1010

1111
- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)
1212
- Agents/replay recovery: classify the provider wording `401 input item ID does not belong to this connection` as replay-invalid, so users get the existing `/new` session reset guidance instead of a raw 401-style failure. (#66475) Thanks @dallylee.
13+
- fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.
14+
- fix(matrix): block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.
15+
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
16+
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
17+
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.
18+
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
19+
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.
20+
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
21+
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
22+
- Memory-core/dreaming: skip dreaming narrative transcripts from session-store metadata before bootstrap records land so dream diary prompt/prose lines do not pollute session ingestion. (#67315) thanks @jalehman.
1323

1424
## 2026.4.15-beta.1
1525

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,119 @@ describe("memory-core dreaming phases", () => {
720720
]);
721721
});
722722

723+
it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => {
724+
const workspaceDir = await createDreamingWorkspace();
725+
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
726+
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
727+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
728+
await fs.mkdir(sessionsDir, { recursive: true });
729+
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
730+
await fs.writeFile(
731+
transcriptPath,
732+
[
733+
JSON.stringify({
734+
type: "message",
735+
message: {
736+
role: "user",
737+
timestamp: "2026-04-05T18:01:00.000Z",
738+
content: [
739+
{ type: "text", text: "Write a dream diary entry from these memory fragments." },
740+
],
741+
},
742+
}),
743+
JSON.stringify({
744+
type: "message",
745+
message: {
746+
role: "assistant",
747+
timestamp: "2026-04-05T18:02:00.000Z",
748+
content: [{ type: "text", text: "I drift through the same archive again." }],
749+
},
750+
}),
751+
].join("\n") + "\n",
752+
"utf-8",
753+
);
754+
await fs.writeFile(
755+
path.join(sessionsDir, "sessions.json"),
756+
JSON.stringify({
757+
"agent:main:dreaming-narrative-light-1775894400455": {
758+
sessionId: "dreaming-narrative",
759+
sessionFile: transcriptPath,
760+
updatedAt: Date.parse("2026-04-05T18:05:00.000Z"),
761+
},
762+
}),
763+
"utf-8",
764+
);
765+
const mtime = new Date("2026-04-05T18:05:00.000Z");
766+
await fs.utimes(transcriptPath, mtime, mtime);
767+
768+
const { beforeAgentReply } = createHarness(
769+
{
770+
agents: {
771+
defaults: {
772+
workspace: workspaceDir,
773+
},
774+
list: [{ id: "main", workspace: workspaceDir }],
775+
},
776+
plugins: {
777+
entries: {
778+
"memory-core": {
779+
config: {
780+
dreaming: {
781+
enabled: true,
782+
phases: {
783+
light: {
784+
enabled: true,
785+
limit: 20,
786+
lookbackDays: 7,
787+
},
788+
},
789+
},
790+
},
791+
},
792+
},
793+
},
794+
},
795+
workspaceDir,
796+
);
797+
798+
try {
799+
await beforeAgentReply(
800+
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
801+
{ trigger: "heartbeat", workspaceDir },
802+
);
803+
} finally {
804+
vi.unstubAllEnvs();
805+
}
806+
807+
await expect(
808+
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")),
809+
).rejects.toMatchObject({ code: "ENOENT" });
810+
811+
const sessionIngestion = JSON.parse(
812+
await fs.readFile(
813+
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
814+
"utf-8",
815+
),
816+
) as {
817+
files: Record<
818+
string,
819+
{
820+
lineCount: number;
821+
lastContentLine: number;
822+
contentHash: string;
823+
}
824+
>;
825+
};
826+
expect(Object.keys(sessionIngestion.files)).toHaveLength(1);
827+
expect(Object.values(sessionIngestion.files)).toEqual([
828+
expect.objectContaining({
829+
lineCount: 0,
830+
lastContentLine: 0,
831+
contentHash: expect.any(String),
832+
}),
833+
]);
834+
});
835+
723836
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
724837
const workspaceDir = await createDreamingWorkspace();
725838
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memo
66
import {
77
buildSessionEntry,
88
listSessionFilesForAgent,
9+
loadDreamingNarrativeTranscriptPathSetForAgent,
10+
normalizeSessionTranscriptPathForComparison,
911
parseUsageCountedSessionIdFromFileName,
1012
sessionPathForFile,
1113
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
@@ -688,13 +690,25 @@ async function collectSessionIngestionBatches(params: {
688690
const nextSeenMessages: Record<string, string[]> = { ...params.state.seenMessages };
689691
let changed = false;
690692

691-
const sessionFiles: Array<{ agentId: string; absolutePath: string; sessionPath: string }> = [];
693+
const sessionFiles: Array<{
694+
agentId: string;
695+
absolutePath: string;
696+
generatedByDreamingNarrative: boolean;
697+
sessionPath: string;
698+
}> = [];
692699
for (const agentId of agentIds) {
693700
const files = await listSessionFilesForAgent(agentId);
701+
const dreamingTranscriptPaths =
702+
files.length > 0
703+
? loadDreamingNarrativeTranscriptPathSetForAgent(agentId)
704+
: new Set<string>();
694705
for (const absolutePath of files) {
695706
sessionFiles.push({
696707
agentId,
697708
absolutePath,
709+
generatedByDreamingNarrative: dreamingTranscriptPaths.has(
710+
normalizeSessionTranscriptPathForComparison(absolutePath),
711+
),
698712
sessionPath: sessionPathForFile(absolutePath),
699713
});
700714
}
@@ -751,7 +765,9 @@ async function collectSessionIngestionBatches(params: {
751765
continue;
752766
}
753767

754-
const entry = await buildSessionEntry(file.absolutePath);
768+
const entry = await buildSessionEntry(file.absolutePath, {
769+
generatedByDreamingNarrative: file.generatedByDreamingNarrative,
770+
});
755771
if (!entry) {
756772
continue;
757773
}

src/memory-host-sdk/engine-qmd.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ export { extractKeywords, isQueryStopWordToken } from "./host/query-expansion.js
44
export {
55
buildSessionEntry,
66
listSessionFilesForAgent,
7+
loadDreamingNarrativeTranscriptPathSetForAgent,
8+
normalizeSessionTranscriptPathForComparison,
79
sessionPathForFile,
10+
type BuildSessionEntryOptions,
811
type SessionFileEntry,
912
} from "./host/session-files.js";
1013
export { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js";

src/memory-host-sdk/host/session-files.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,50 @@ describe("buildSessionEntry", () => {
175175
expect(entry?.generatedByDreamingNarrative).toBe(true);
176176
});
177177

178+
it("flags dreaming narrative transcripts from the sibling session store before bootstrap lands", async () => {
179+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
180+
await fs.mkdir(sessionsDir, { recursive: true });
181+
const filePath = path.join(sessionsDir, "dreaming-session.jsonl");
182+
await fs.writeFile(
183+
filePath,
184+
[
185+
JSON.stringify({
186+
type: "message",
187+
message: {
188+
role: "user",
189+
content:
190+
"Write a dream diary entry from these memory fragments:\n- Candidate: durable note",
191+
},
192+
}),
193+
JSON.stringify({
194+
type: "message",
195+
message: {
196+
role: "assistant",
197+
content: "A drifting archive breathed in moonlight.",
198+
},
199+
}),
200+
].join("\n"),
201+
);
202+
await fs.writeFile(
203+
path.join(sessionsDir, "sessions.json"),
204+
JSON.stringify({
205+
"agent:main:dreaming-narrative-light-1775894400455": {
206+
sessionId: "dreaming-session",
207+
sessionFile: filePath,
208+
updatedAt: Date.now(),
209+
},
210+
}),
211+
"utf-8",
212+
);
213+
214+
const entry = await buildSessionEntry(filePath);
215+
216+
expect(entry).not.toBeNull();
217+
expect(entry?.generatedByDreamingNarrative).toBe(true);
218+
expect(entry?.content).toBe("");
219+
expect(entry?.lineMap).toEqual([]);
220+
});
221+
178222
it("does not flag ordinary transcripts that quote the dream-diary prompt", async () => {
179223
const jsonlLines = [
180224
JSON.stringify({

src/memory-host-sdk/host/session-files.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { isUsageCountedSessionTranscriptFileName } from "../../config/sessions/artifacts.js";
44
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
5+
import { loadSessionStore } from "../../config/sessions/store-load.js";
56
import { redactSensitiveText } from "../../logging/redact.js";
67
import { createSubsystemLogger } from "../../logging/subsystem.js";
78
import { hashText } from "./internal.js";
@@ -24,6 +25,11 @@ export type SessionFileEntry = {
2425
generatedByDreamingNarrative?: boolean;
2526
};
2627

28+
export type BuildSessionEntryOptions = {
29+
/** Optional preclassification from a caller-managed dreaming transcript lookup. */
30+
generatedByDreamingNarrative?: boolean;
31+
};
32+
2733
function isDreamingNarrativeBootstrapRecord(record: unknown): boolean {
2834
if (!record || typeof record !== "object" || Array.isArray(record)) {
2935
return false;
@@ -78,6 +84,79 @@ function isDreamingNarrativeGeneratedRecord(record: unknown): boolean {
7884
return hasDreamingNarrativeRunId(nested.runId) || hasDreamingNarrativeRunId(nested.sessionKey);
7985
}
8086

87+
function isDreamingNarrativeSessionStoreKey(sessionKey: string): boolean {
88+
const trimmed = sessionKey.trim();
89+
if (!trimmed) {
90+
return false;
91+
}
92+
const firstSeparator = trimmed.indexOf(":");
93+
if (firstSeparator < 0) {
94+
return trimmed.startsWith(DREAMING_NARRATIVE_RUN_PREFIX);
95+
}
96+
const secondSeparator = trimmed.indexOf(":", firstSeparator + 1);
97+
const sessionSegment = secondSeparator < 0 ? trimmed : trimmed.slice(secondSeparator + 1);
98+
return sessionSegment.startsWith(DREAMING_NARRATIVE_RUN_PREFIX);
99+
}
100+
101+
function normalizeComparablePath(pathname: string): string {
102+
const resolved = path.resolve(pathname);
103+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
104+
}
105+
106+
export function normalizeSessionTranscriptPathForComparison(pathname: string): string {
107+
return normalizeComparablePath(pathname);
108+
}
109+
110+
function resolveSessionStoreTranscriptPath(
111+
sessionsDir: string,
112+
entry: { sessionFile?: unknown; sessionId?: unknown } | undefined,
113+
): string | null {
114+
if (typeof entry?.sessionFile === "string" && entry.sessionFile.trim().length > 0) {
115+
const sessionFile = entry.sessionFile.trim();
116+
const resolved = path.isAbsolute(sessionFile)
117+
? sessionFile
118+
: path.resolve(sessionsDir, sessionFile);
119+
return normalizeComparablePath(resolved);
120+
}
121+
if (typeof entry?.sessionId === "string" && entry.sessionId.trim().length > 0) {
122+
return normalizeComparablePath(path.join(sessionsDir, `${entry.sessionId.trim()}.jsonl`));
123+
}
124+
return null;
125+
}
126+
127+
export function loadDreamingNarrativeTranscriptPathSetForSessionsDir(
128+
sessionsDir: string,
129+
): ReadonlySet<string> {
130+
const storePath = path.join(sessionsDir, "sessions.json");
131+
const store = loadSessionStore(storePath);
132+
const dreamingTranscriptPaths = new Set<string>();
133+
for (const [sessionKey, entry] of Object.entries(store)) {
134+
if (!isDreamingNarrativeSessionStoreKey(sessionKey)) {
135+
continue;
136+
}
137+
const transcriptPath = resolveSessionStoreTranscriptPath(sessionsDir, entry);
138+
if (transcriptPath) {
139+
dreamingTranscriptPaths.add(transcriptPath);
140+
}
141+
}
142+
return dreamingTranscriptPaths;
143+
}
144+
145+
export function loadDreamingNarrativeTranscriptPathSetForAgent(
146+
agentId: string,
147+
): ReadonlySet<string> {
148+
return loadDreamingNarrativeTranscriptPathSetForSessionsDir(
149+
resolveSessionTranscriptsDirForAgent(agentId),
150+
);
151+
}
152+
153+
function isDreamingNarrativeTranscriptFromSessionStore(absPath: string): boolean {
154+
const sessionsDir = path.dirname(absPath);
155+
const normalizedAbsPath = normalizeComparablePath(absPath);
156+
const dreamingTranscriptPaths = loadDreamingNarrativeTranscriptPathSetForSessionsDir(sessionsDir);
157+
return dreamingTranscriptPaths.has(normalizedAbsPath);
158+
}
159+
81160
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
82161
const dir = resolveSessionTranscriptsDirForAgent(agentId);
83162
try {
@@ -153,15 +232,19 @@ function parseSessionTimestampMs(
153232
return 0;
154233
}
155234

156-
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
235+
export async function buildSessionEntry(
236+
absPath: string,
237+
opts: BuildSessionEntryOptions = {},
238+
): Promise<SessionFileEntry | null> {
157239
try {
158240
const stat = await fs.stat(absPath);
159241
const raw = await fs.readFile(absPath, "utf-8");
160242
const lines = raw.split("\n");
161243
const collected: string[] = [];
162244
const lineMap: number[] = [];
163245
const messageTimestampsMs: number[] = [];
164-
let generatedByDreamingNarrative = false;
246+
let generatedByDreamingNarrative =
247+
opts.generatedByDreamingNarrative ?? isDreamingNarrativeTranscriptFromSessionStore(absPath);
165248
for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
166249
const line = lines[jsonlIdx];
167250
if (!line.trim()) {

0 commit comments

Comments
 (0)