mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(memory-core): route dreaming corpus through session corpus metadata (#96517)
This commit is contained in:
parent
ff332d3819
commit
da50a450d2
6 changed files with 242 additions and 10 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue