Skip to content

Commit da50a45

Browse files
authored
fix(memory-core): route dreaming corpus through session corpus metadata (#96517)
1 parent ff332d3 commit da50a45

6 files changed

Lines changed: 242 additions & 10 deletions

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,78 @@ describe("memory-core dreaming phases", () => {
19871987
expect(newOccurrences).toBe(1);
19881988
});
19891989

1990+
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => {
1991+
const workspaceDir = await createDreamingWorkspace();
1992+
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
1993+
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
1994+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
1995+
await fs.mkdir(sessionsDir, { recursive: true });
1996+
const archivePath = path.join(
1997+
sessionsDir,
1998+
"archived-only.jsonl.deleted.2026-04-06T01-00-00.000Z",
1999+
);
2000+
await fs.writeFile(
2001+
archivePath,
2002+
[
2003+
JSON.stringify({
2004+
type: "message",
2005+
message: {
2006+
role: "user",
2007+
timestamp: "2026-04-05T18:01:00.000Z",
2008+
content: [{ type: "text", text: "Archived session should not be dreamed." }],
2009+
},
2010+
}),
2011+
].join("\n") + "\n",
2012+
"utf-8",
2013+
);
2014+
const mtime = new Date("2026-04-06T01:05:00.000Z");
2015+
await fs.utimes(archivePath, mtime, mtime);
2016+
2017+
const { beforeAgentReply } = createHarness(
2018+
{
2019+
agents: {
2020+
defaults: {
2021+
workspace: workspaceDir,
2022+
},
2023+
},
2024+
plugins: {
2025+
entries: {
2026+
"memory-core": {
2027+
config: {
2028+
dreaming: {
2029+
enabled: true,
2030+
phases: {
2031+
light: {
2032+
enabled: true,
2033+
limit: 20,
2034+
lookbackDays: 7,
2035+
},
2036+
},
2037+
},
2038+
},
2039+
},
2040+
},
2041+
},
2042+
},
2043+
workspaceDir,
2044+
);
2045+
2046+
try {
2047+
await withDreamingTestClock(async () => {
2048+
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
2049+
});
2050+
} finally {
2051+
vi.unstubAllEnvs();
2052+
}
2053+
2054+
await expectPathMissing(
2055+
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
2056+
);
2057+
2058+
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
2059+
expect(Object.keys(sessionIngestion.files)).toHaveLength(0);
2060+
});
2061+
19902062
it("buckets session snippets by per-message day rather than file mtime", async () => {
19912063
const workspaceDir = await createDreamingWorkspace();
19922064
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,12 @@ async function collectSessionIngestionBatches(params: {
848848
for (const agentId of agentIds) {
849849
for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) {
850850
const absolutePath = entry.sessionFile;
851-
if (isCheckpointSessionTranscriptPath(absolutePath)) {
851+
if (
852+
// Dreaming learns only from the live corpus. Retained reset/delete
853+
// archives stay in the shared corpus for QMD and memory_search.
854+
entry.artifactKind === "archive-artifact" ||
855+
isCheckpointSessionTranscriptPath(absolutePath)
856+
) {
852857
continue;
853858
}
854859
sessionFiles.push({

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,91 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
158158
});
159159
});
160160

161+
it("classifies active entries through cron parentage chains", async () => {
162+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
163+
fsSync.mkdirSync(sessionsDir, { recursive: true });
164+
const cronPath = path.join(sessionsDir, "cron-run.jsonl");
165+
const spawnedChildPath = path.join(sessionsDir, "spawned-child.jsonl");
166+
const keyedChildPath = path.join(sessionsDir, "keyed-child.jsonl");
167+
const orphanChildPath = path.join(sessionsDir, "orphan-child.jsonl");
168+
const normalPath = path.join(sessionsDir, "normal-child.jsonl");
169+
for (const filePath of [
170+
cronPath,
171+
spawnedChildPath,
172+
keyedChildPath,
173+
orphanChildPath,
174+
normalPath,
175+
]) {
176+
fsSync.writeFileSync(filePath, "");
177+
}
178+
fsSync.writeFileSync(
179+
path.join(sessionsDir, "sessions.json"),
180+
JSON.stringify({
181+
"agent:main:cron:job-1:run:run-1": {
182+
sessionFile: "cron-run.jsonl",
183+
sessionId: "cron-run",
184+
},
185+
"agent:main:subagent:spawned-child": {
186+
sessionFile: "spawned-child.jsonl",
187+
sessionId: "spawned-child",
188+
spawnedBy: "agent:main:cron:job-1:run:run-1",
189+
},
190+
"agent:main:subagent:keyed-child": {
191+
parentSessionKey: "agent:main:subagent:spawned-child",
192+
sessionFile: "keyed-child.jsonl",
193+
sessionId: "keyed-child",
194+
},
195+
"agent:main:subagent:orphan-child": {
196+
sessionFile: "orphan-child.jsonl",
197+
sessionId: "orphan-child",
198+
spawnedBy: "agent:main:cron:job-1:run:missing",
199+
},
200+
"agent:main:subagent:normal-child": {
201+
sessionFile: "normal-child.jsonl",
202+
sessionId: "normal-child",
203+
spawnedBy: "agent:main:chat:manual",
204+
},
205+
}),
206+
);
207+
208+
const classification = loadSessionTranscriptClassificationForAgent("main");
209+
210+
expect(classification.cronRunTranscriptPaths).toEqual(
211+
new Set(
212+
[cronPath, spawnedChildPath, keyedChildPath, orphanChildPath].map((filePath) =>
213+
path.resolve(filePath),
214+
),
215+
),
216+
);
217+
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual(
218+
expect.arrayContaining([
219+
expect.objectContaining({
220+
generatedByCronRun: true,
221+
sessionFile: spawnedChildPath,
222+
sessionKey: "agent:main:subagent:spawned-child",
223+
}),
224+
expect.objectContaining({
225+
generatedByCronRun: true,
226+
sessionFile: keyedChildPath,
227+
sessionKey: "agent:main:subagent:keyed-child",
228+
}),
229+
expect.objectContaining({
230+
generatedByCronRun: true,
231+
sessionFile: orphanChildPath,
232+
sessionKey: "agent:main:subagent:orphan-child",
233+
}),
234+
expect.objectContaining({
235+
sessionFile: normalPath,
236+
sessionKey: "agent:main:subagent:normal-child",
237+
}),
238+
]),
239+
);
240+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
241+
expect(entries.find((entry) => entry.sessionFile === normalPath)?.generatedByCronRun).toBe(
242+
undefined,
243+
);
244+
});
245+
161246
it("keeps archive classification when the active transcript is missing", async () => {
162247
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
163248
fsSync.mkdirSync(sessionsDir, { recursive: true });

packages/memory-host-sdk/src/host/session-transcript-corpus.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ function isDreamingNarrativeSessionKeyLike(value: unknown): boolean {
5858
return typeof value === "string" && isDreamingNarrativeSessionStoreKey(value);
5959
}
6060

61-
function hasCronRunSessionKey(value: unknown): boolean {
62-
return typeof value === "string" && isCronRunSessionKey(value);
63-
}
64-
6561
function normalizeComparablePath(pathname: string): string {
6662
const resolved = path.resolve(pathname);
6763
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
@@ -163,6 +159,7 @@ function resolveSessionStoreTranscriptCorpusPath(
163159
function classifySessionEntry(
164160
sessionKey: string,
165161
entry: SessionEntry,
162+
cronGeneratedSessionKeys: ReadonlySet<string>,
166163
): {
167164
generatedByDreamingNarrative: boolean;
168165
generatedByCronRun: boolean;
@@ -171,10 +168,69 @@ function classifySessionEntry(
171168
generatedByDreamingNarrative:
172169
isDreamingNarrativeSessionStoreKey(sessionKey) ||
173170
isDreamingNarrativeSessionKeyLike(entry.spawnedBy),
174-
generatedByCronRun: isCronRunSessionKey(sessionKey) || hasCronRunSessionKey(entry.spawnedBy),
171+
generatedByCronRun: cronGeneratedSessionKeys.has(sessionKey),
175172
};
176173
}
177174

175+
function readParentSessionKeys(entry: SessionEntry | undefined): string[] {
176+
const keys = new Set<string>();
177+
for (const value of [entry?.parentSessionKey, entry?.spawnedBy]) {
178+
if (typeof value !== "string") {
179+
continue;
180+
}
181+
const trimmed = value.trim();
182+
if (trimmed) {
183+
keys.add(trimmed);
184+
}
185+
}
186+
return [...keys];
187+
}
188+
189+
function collectCronGeneratedSessionKeys(
190+
summaries: readonly SessionEntrySummary[],
191+
): ReadonlySet<string> {
192+
// Build the cron-generated closure once so active entries and archive
193+
// artifacts share the same lineage classification.
194+
const entriesByKey = new Map(summaries.map((summary) => [summary.sessionKey, summary.entry]));
195+
const cronGeneratedKeys = new Set<string>();
196+
const cache = new Map<string, boolean>();
197+
const resolving = new Set<string>();
198+
199+
const isCronGenerated = (sessionKey: string, entry: SessionEntry | undefined): boolean => {
200+
if (isCronRunSessionKey(sessionKey)) {
201+
cache.set(sessionKey, true);
202+
cronGeneratedKeys.add(sessionKey);
203+
return true;
204+
}
205+
const cached = cache.get(sessionKey);
206+
if (cached !== undefined) {
207+
return cached;
208+
}
209+
if (resolving.has(sessionKey)) {
210+
return false;
211+
}
212+
213+
resolving.add(sessionKey);
214+
const generated = readParentSessionKeys(entry).some(
215+
(parentKey) =>
216+
// Parent rows can be pruned before child rows; a cron-shaped parent key
217+
// still carries cron lineage without requiring a store entry.
218+
isCronRunSessionKey(parentKey) || isCronGenerated(parentKey, entriesByKey.get(parentKey)),
219+
);
220+
resolving.delete(sessionKey);
221+
cache.set(sessionKey, generated);
222+
if (generated) {
223+
cronGeneratedKeys.add(sessionKey);
224+
}
225+
return generated;
226+
};
227+
228+
for (const summary of summaries) {
229+
isCronGenerated(summary.sessionKey, summary.entry);
230+
}
231+
return cronGeneratedKeys;
232+
}
233+
178234
function isRegularSessionTranscriptFile(absPath: string): boolean {
179235
try {
180236
return fsSync.lstatSync(absPath).isFile();
@@ -187,6 +243,7 @@ function toSessionStoreCorpusEntry(
187243
agentId: string,
188244
sessionsDir: string,
189245
summary: SessionEntrySummary,
246+
cronGeneratedSessionKeys: ReadonlySet<string>,
190247
): SessionTranscriptCorpusEntry | null {
191248
const sessionFile = resolveSessionStoreTranscriptCorpusPath(agentId, sessionsDir, summary.entry);
192249
if (!sessionFile || !isUsageCountedSessionTranscriptFileName(path.basename(sessionFile))) {
@@ -200,7 +257,11 @@ function toSessionStoreCorpusEntry(
200257
return null;
201258
}
202259
const sessionKey = summary.sessionKey.trim();
203-
const classification = classifySessionEntry(summary.sessionKey, summary.entry);
260+
const classification = classifySessionEntry(
261+
summary.sessionKey,
262+
summary.entry,
263+
cronGeneratedSessionKeys,
264+
);
204265
return {
205266
agentId,
206267
artifactKind: "active-session",
@@ -299,11 +360,13 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
299360
const activeEntryOwnersByPath = new Map<string, string>();
300361
const artifactDirsByPath = new Map<string, string>();
301362
rememberArtifactDir(artifactDirsByPath, sessionsDir);
302-
for (const summary of listSessionEntries({
363+
const sessionEntries = listSessionEntries({
303364
agentId: normalizedAgentId,
304365
hydrateSkillPromptRefs: false,
305366
storePath,
306-
})) {
367+
});
368+
const cronGeneratedSessionKeys = collectCronGeneratedSessionKeys(sessionEntries);
369+
for (const summary of sessionEntries) {
307370
const sessionKey = isSharedFixedStore
308371
? summary.sessionKey
309372
: canonicalizeMainSessionAlias({
@@ -316,7 +379,12 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
316379
sessionKey,
317380
...(isSharedFixedStore ? {} : { fallbackAgentId: normalizedAgentId }),
318381
});
319-
const entry = toSessionStoreCorpusEntry(ownerAgentId, sessionsDir, summary);
382+
const entry = toSessionStoreCorpusEntry(
383+
ownerAgentId,
384+
sessionsDir,
385+
summary,
386+
cronGeneratedSessionKeys,
387+
);
320388
if (!entry) {
321389
continue;
322390
}

scripts/check-session-accessor-boundary.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const migratedBundledPluginSessionAccessorFiles = new Set([
128128
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
129129
"extensions/discord/src/monitor/thread-session-close.ts",
130130
"extensions/feishu/src/reasoning-preview.ts",
131+
"extensions/memory-core/src/dreaming-phases.ts",
131132
"extensions/memory-core/src/dreaming-narrative.ts",
132133
"extensions/mattermost/src/mattermost/model-picker.ts",
133134
"extensions/telegram/src/bot-handlers.runtime.ts",

test/scripts/check-session-accessor-boundary.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe("session accessor boundary guard", () => {
8282
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
8383
"extensions/discord/src/monitor/thread-session-close.ts",
8484
"extensions/feishu/src/reasoning-preview.ts",
85+
"extensions/memory-core/src/dreaming-phases.ts",
8586
"extensions/memory-core/src/dreaming-narrative.ts",
8687
"extensions/mattermost/src/mattermost/model-picker.ts",
8788
"extensions/telegram/src/bot-handlers.runtime.ts",

0 commit comments

Comments
 (0)