fix(memory-core): route dreaming corpus through session corpus metadata (#96517)

This commit is contained in:
Josh Lehman 2026-06-24 11:29:26 -07:00 committed by GitHub
parent ff332d3819
commit da50a450d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 242 additions and 10 deletions

View file

@ -1987,6 +1987,78 @@ describe("memory-core dreaming phases", () => {
expect(newOccurrences).toBe(1);
});
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", 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 archivePath = path.join(
sessionsDir,
"archived-only.jsonl.deleted.2026-04-06T01-00-00.000Z",
);
await fs.writeFile(
archivePath,
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:01:00.000Z",
content: [{ type: "text", text: "Archived session should not be dreamed." }],
},
}),
].join("\n") + "\n",
"utf-8",
);
const mtime = new Date("2026-04-06T01:05:00.000Z");
await fs.utimes(archivePath, mtime, mtime);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
try {
await withDreamingTestClock(async () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
}
await expectPathMissing(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
);
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
expect(Object.keys(sessionIngestion.files)).toHaveLength(0);
});
it("buckets session snippets by per-message day rather than file mtime", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

View file

@ -848,7 +848,12 @@ async function collectSessionIngestionBatches(params: {
for (const agentId of agentIds) {
for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) {
const absolutePath = entry.sessionFile;
if (isCheckpointSessionTranscriptPath(absolutePath)) {
if (
// Dreaming learns only from the live corpus. Retained reset/delete
// archives stay in the shared corpus for QMD and memory_search.
entry.artifactKind === "archive-artifact" ||
isCheckpointSessionTranscriptPath(absolutePath)
) {
continue;
}
sessionFiles.push({

View file

@ -158,6 +158,91 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
});
});
it("classifies active entries through cron parentage chains", async () => {
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
fsSync.mkdirSync(sessionsDir, { recursive: true });
const cronPath = path.join(sessionsDir, "cron-run.jsonl");
const spawnedChildPath = path.join(sessionsDir, "spawned-child.jsonl");
const keyedChildPath = path.join(sessionsDir, "keyed-child.jsonl");
const orphanChildPath = path.join(sessionsDir, "orphan-child.jsonl");
const normalPath = path.join(sessionsDir, "normal-child.jsonl");
for (const filePath of [
cronPath,
spawnedChildPath,
keyedChildPath,
orphanChildPath,
normalPath,
]) {
fsSync.writeFileSync(filePath, "");
}
fsSync.writeFileSync(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:cron:job-1:run:run-1": {
sessionFile: "cron-run.jsonl",
sessionId: "cron-run",
},
"agent:main:subagent:spawned-child": {
sessionFile: "spawned-child.jsonl",
sessionId: "spawned-child",
spawnedBy: "agent:main:cron:job-1:run:run-1",
},
"agent:main:subagent:keyed-child": {
parentSessionKey: "agent:main:subagent:spawned-child",
sessionFile: "keyed-child.jsonl",
sessionId: "keyed-child",
},
"agent:main:subagent:orphan-child": {
sessionFile: "orphan-child.jsonl",
sessionId: "orphan-child",
spawnedBy: "agent:main:cron:job-1:run:missing",
},
"agent:main:subagent:normal-child": {
sessionFile: "normal-child.jsonl",
sessionId: "normal-child",
spawnedBy: "agent:main:chat:manual",
},
}),
);
const classification = loadSessionTranscriptClassificationForAgent("main");
expect(classification.cronRunTranscriptPaths).toEqual(
new Set(
[cronPath, spawnedChildPath, keyedChildPath, orphanChildPath].map((filePath) =>
path.resolve(filePath),
),
),
);
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
generatedByCronRun: true,
sessionFile: spawnedChildPath,
sessionKey: "agent:main:subagent:spawned-child",
}),
expect.objectContaining({
generatedByCronRun: true,
sessionFile: keyedChildPath,
sessionKey: "agent:main:subagent:keyed-child",
}),
expect.objectContaining({
generatedByCronRun: true,
sessionFile: orphanChildPath,
sessionKey: "agent:main:subagent:orphan-child",
}),
expect.objectContaining({
sessionFile: normalPath,
sessionKey: "agent:main:subagent:normal-child",
}),
]),
);
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
expect(entries.find((entry) => entry.sessionFile === normalPath)?.generatedByCronRun).toBe(
undefined,
);
});
it("keeps archive classification when the active transcript is missing", async () => {
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
fsSync.mkdirSync(sessionsDir, { recursive: true });

View file

@ -58,10 +58,6 @@ function isDreamingNarrativeSessionKeyLike(value: unknown): boolean {
return typeof value === "string" && isDreamingNarrativeSessionStoreKey(value);
}
function hasCronRunSessionKey(value: unknown): boolean {
return typeof value === "string" && isCronRunSessionKey(value);
}
function normalizeComparablePath(pathname: string): string {
const resolved = path.resolve(pathname);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
@ -163,6 +159,7 @@ function resolveSessionStoreTranscriptCorpusPath(
function classifySessionEntry(
sessionKey: string,
entry: SessionEntry,
cronGeneratedSessionKeys: ReadonlySet<string>,
): {
generatedByDreamingNarrative: boolean;
generatedByCronRun: boolean;
@ -171,10 +168,69 @@ function classifySessionEntry(
generatedByDreamingNarrative:
isDreamingNarrativeSessionStoreKey(sessionKey) ||
isDreamingNarrativeSessionKeyLike(entry.spawnedBy),
generatedByCronRun: isCronRunSessionKey(sessionKey) || hasCronRunSessionKey(entry.spawnedBy),
generatedByCronRun: cronGeneratedSessionKeys.has(sessionKey),
};
}
function readParentSessionKeys(entry: SessionEntry | undefined): string[] {
const keys = new Set<string>();
for (const value of [entry?.parentSessionKey, entry?.spawnedBy]) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
keys.add(trimmed);
}
}
return [...keys];
}
function collectCronGeneratedSessionKeys(
summaries: readonly SessionEntrySummary[],
): ReadonlySet<string> {
// Build the cron-generated closure once so active entries and archive
// artifacts share the same lineage classification.
const entriesByKey = new Map(summaries.map((summary) => [summary.sessionKey, summary.entry]));
const cronGeneratedKeys = new Set<string>();
const cache = new Map<string, boolean>();
const resolving = new Set<string>();
const isCronGenerated = (sessionKey: string, entry: SessionEntry | undefined): boolean => {
if (isCronRunSessionKey(sessionKey)) {
cache.set(sessionKey, true);
cronGeneratedKeys.add(sessionKey);
return true;
}
const cached = cache.get(sessionKey);
if (cached !== undefined) {
return cached;
}
if (resolving.has(sessionKey)) {
return false;
}
resolving.add(sessionKey);
const generated = readParentSessionKeys(entry).some(
(parentKey) =>
// Parent rows can be pruned before child rows; a cron-shaped parent key
// still carries cron lineage without requiring a store entry.
isCronRunSessionKey(parentKey) || isCronGenerated(parentKey, entriesByKey.get(parentKey)),
);
resolving.delete(sessionKey);
cache.set(sessionKey, generated);
if (generated) {
cronGeneratedKeys.add(sessionKey);
}
return generated;
};
for (const summary of summaries) {
isCronGenerated(summary.sessionKey, summary.entry);
}
return cronGeneratedKeys;
}
function isRegularSessionTranscriptFile(absPath: string): boolean {
try {
return fsSync.lstatSync(absPath).isFile();
@ -187,6 +243,7 @@ function toSessionStoreCorpusEntry(
agentId: string,
sessionsDir: string,
summary: SessionEntrySummary,
cronGeneratedSessionKeys: ReadonlySet<string>,
): SessionTranscriptCorpusEntry | null {
const sessionFile = resolveSessionStoreTranscriptCorpusPath(agentId, sessionsDir, summary.entry);
if (!sessionFile || !isUsageCountedSessionTranscriptFileName(path.basename(sessionFile))) {
@ -200,7 +257,11 @@ function toSessionStoreCorpusEntry(
return null;
}
const sessionKey = summary.sessionKey.trim();
const classification = classifySessionEntry(summary.sessionKey, summary.entry);
const classification = classifySessionEntry(
summary.sessionKey,
summary.entry,
cronGeneratedSessionKeys,
);
return {
agentId,
artifactKind: "active-session",
@ -299,11 +360,13 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
const activeEntryOwnersByPath = new Map<string, string>();
const artifactDirsByPath = new Map<string, string>();
rememberArtifactDir(artifactDirsByPath, sessionsDir);
for (const summary of listSessionEntries({
const sessionEntries = listSessionEntries({
agentId: normalizedAgentId,
hydrateSkillPromptRefs: false,
storePath,
})) {
});
const cronGeneratedSessionKeys = collectCronGeneratedSessionKeys(sessionEntries);
for (const summary of sessionEntries) {
const sessionKey = isSharedFixedStore
? summary.sessionKey
: canonicalizeMainSessionAlias({
@ -316,7 +379,12 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
sessionKey,
...(isSharedFixedStore ? {} : { fallbackAgentId: normalizedAgentId }),
});
const entry = toSessionStoreCorpusEntry(ownerAgentId, sessionsDir, summary);
const entry = toSessionStoreCorpusEntry(
ownerAgentId,
sessionsDir,
summary,
cronGeneratedSessionKeys,
);
if (!entry) {
continue;
}

View file

@ -128,6 +128,7 @@ export const migratedBundledPluginSessionAccessorFiles = new Set([
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
"extensions/discord/src/monitor/thread-session-close.ts",
"extensions/feishu/src/reasoning-preview.ts",
"extensions/memory-core/src/dreaming-phases.ts",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/mattermost/src/mattermost/model-picker.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",

View file

@ -82,6 +82,7 @@ describe("session accessor boundary guard", () => {
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
"extensions/discord/src/monitor/thread-session-close.ts",
"extensions/feishu/src/reasoning-preview.ts",
"extensions/memory-core/src/dreaming-phases.ts",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/mattermost/src/mattermost/model-picker.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",