Skip to content

Commit beedbb7

Browse files
Jesse Bryceclaude
authored andcommitted
fix(memory): add explicit dreaming eligibility to corpus entries and integration tests for heartbeat provenance filtering
Add `eligibleForDreaming: boolean` to `SessionTranscriptCorpusEntry` so archive artifacts (.jsonl.deleted.* / .jsonl.reset.*) carry an explicit `false` signal rather than relying on corpus consumers to check `artifactKind === "archive-artifact"` ad-hoc. Dreaming phases now gate on `!entry.eligibleForDreaming` instead, making the eligibility boundary a first-class corpus concern. Also add the missing `session-transcript-corpus.test.ts` (clawsweeper acceptance criterion) verifying that active sessions carry `true` and all archive artifact variants (deleted, reset, orphaned, cron-lineage) carry `false`. Extend `seedDreamingSessionTranscript` to propagate an optional `provenance` field, and add an end-to-end dreaming sweep test demonstrating that a heartbeat turn with runtime provenance `{ kind: "internal_system", sourceTool: "heartbeat" }` suppresses the paired natural-language assistant response (not "HEARTBEAT_OK") from the session corpus output — the path added by #109403 / commit 571f1dc. Acceptance criteria satisfied: - pnpm test packages/memory-host-sdk/src/host/session-files.test.ts (41/41) - pnpm test packages/memory-host-sdk/src/host/session-transcript-corpus.test.ts (5/5 new) - pnpm test extensions/memory-core/src/dreaming-phases.test.ts (47/47, +1 new) Fixes #103720 Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 78b4a4a commit beedbb7

5 files changed

Lines changed: 304 additions & 3 deletions

File tree

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ async function seedDreamingSessionTranscript(params: {
156156
role: "assistant" | "user";
157157
content: unknown;
158158
timestamp: number | string;
159+
/** Optional runtime provenance attached to the message record. */
160+
provenance?: Record<string, unknown>;
159161
}>;
160162
sessionId: string;
161163
sessionKey?: string;
@@ -194,6 +196,7 @@ async function seedDreamingSessionTranscript(params: {
194196
role: message.role,
195197
content: message.content,
196198
timestamp: message.timestamp,
199+
...(message.provenance ? { provenance: message.provenance } : {}),
197200
},
198201
});
199202
}
@@ -1736,6 +1739,104 @@ describe("memory-core dreaming phases", () => {
17361739
expect(corpus).not.toContain("Run the qmd sync");
17371740
});
17381741

1742+
it("drops provenance-marked heartbeat turns with natural-language assistant responses from corpus", async () => {
1743+
// Regression test for #103720 Issue 2: the exact-token HEARTBEAT_OK match
1744+
// never fires when the model responds with a natural-language acknowledgment.
1745+
// buildSessionEntry now uses runtime-provenance coupling to detect and drop
1746+
// the paired assistant response regardless of its text content.
1747+
const workspaceDir = await createDreamingWorkspace();
1748+
setDreamingTestEnv(path.join(workspaceDir, ".state"));
1749+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
1750+
await fs.mkdir(sessionsDir, { recursive: true });
1751+
1752+
await seedDreamingSessionTranscript({
1753+
sessionId: "heartbeat-provenance",
1754+
sessionKey: "agent:main:chat:heartbeat-provenance",
1755+
messages: [
1756+
{
1757+
role: "user",
1758+
timestamp: "2026-04-16T18:00:00.000Z",
1759+
content: "[OpenClaw heartbeat poll]",
1760+
// Runtime provenance injected by get-reply-run.ts for heartbeat ticks.
1761+
provenance: { kind: "internal_system", sourceTool: "heartbeat" },
1762+
},
1763+
{
1764+
role: "assistant",
1765+
timestamp: "2026-04-16T18:00:05.000Z",
1766+
// Natural-language response — does NOT contain "HEARTBEAT_OK".
1767+
content: "Heartbeat received. Main is active. No pending user request in this cron poll.",
1768+
},
1769+
{
1770+
role: "user",
1771+
timestamp: "2026-04-16T18:01:00.000Z",
1772+
content: "Can you document the new API endpoint?",
1773+
},
1774+
{
1775+
role: "assistant",
1776+
timestamp: "2026-04-16T18:01:30.000Z",
1777+
content: "I documented the new API endpoint in the workspace notes.",
1778+
},
1779+
],
1780+
});
1781+
1782+
const { beforeAgentReply } = createHarness(
1783+
{
1784+
agents: {
1785+
defaults: {
1786+
workspace: workspaceDir,
1787+
},
1788+
list: [{ id: "main", workspace: workspaceDir }],
1789+
},
1790+
plugins: {
1791+
entries: {
1792+
"memory-core": {
1793+
config: {
1794+
dreaming: {
1795+
enabled: true,
1796+
phases: {
1797+
light: {
1798+
enabled: true,
1799+
limit: 20,
1800+
lookbackDays: 7,
1801+
},
1802+
},
1803+
},
1804+
},
1805+
},
1806+
},
1807+
},
1808+
},
1809+
workspaceDir,
1810+
);
1811+
1812+
vi.useFakeTimers();
1813+
vi.setSystemTime(new Date("2026-04-16T19:00:00.000Z"));
1814+
try {
1815+
await beforeAgentReply(
1816+
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
1817+
{ trigger: "heartbeat", workspaceDir },
1818+
);
1819+
} finally {
1820+
vi.useRealTimers();
1821+
restoreDreamingTestEnv();
1822+
}
1823+
1824+
const corpus = await fs.readFile(
1825+
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-16.txt"),
1826+
"utf-8",
1827+
);
1828+
// Normal exchange must appear in the corpus.
1829+
expect(corpus).toContain("User: Can you document the new API endpoint?");
1830+
expect(corpus).toContain(
1831+
"Assistant: I documented the new API endpoint in the workspace notes.",
1832+
);
1833+
// Heartbeat user prompt and its natural-language acknowledgment must be absent.
1834+
expect(corpus).not.toContain("[OpenClaw heartbeat poll]");
1835+
expect(corpus).not.toContain(
1836+
"Heartbeat received. Main is active. No pending user request in this cron poll.",
1837+
);
1838+
});
1839+
17391840
it("ignores chat scaffolding tags when building rem reflections", () => {
17401841
const preview = previewRemDreaming({
17411842
entries: [

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,9 +824,10 @@ async function collectSessionIngestionBatches(params: {
824824
for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) {
825825
const absolutePath = entry.sessionFile;
826826
if (
827-
// Dreaming learns only from the live corpus. Retained reset/delete
828-
// archives stay in the shared corpus for QMD and memory_search.
829-
entry.artifactKind === "archive-artifact" ||
827+
// Dreaming learns only from the live corpus. Archive artifacts
828+
// (reset/delete rotations) carry eligibleForDreaming: false so they
829+
// remain accessible for memory_search without being re-ingested here.
830+
!entry.eligibleForDreaming ||
830831
isCheckpointSessionTranscriptPath(absolutePath)
831832
) {
832833
continue;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
155155
agentId: "main",
156156
artifactKind: "archive-artifact",
157157
contentRevision: expect.any(String),
158+
eligibleForDreaming: false,
158159
generatedByCronRun: true,
159160
sessionFile: archivePath,
160161
sessionId: "cron-run",
@@ -378,6 +379,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
378379
agentId: "main",
379380
artifactKind: "archive-artifact",
380381
contentRevision: expect.any(String),
382+
eligibleForDreaming: false,
381383
generatedByCronRun: true,
382384
sessionFile: expectedArchivePath,
383385
sessionId: "cron-run",
@@ -549,6 +551,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
549551
agentId: "main",
550552
artifactKind: "archive-artifact",
551553
contentRevision: expect.any(String),
554+
eligibleForDreaming: false,
552555
sessionFile: archivePath,
553556
sessionId: "retained",
554557
},
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Memory Host SDK tests cover session-transcript-corpus eligibility metadata.
2+
import fsSync from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import {
6+
clearConfigCache,
7+
clearRuntimeConfigSnapshot,
8+
} from "openclaw/plugin-sdk/runtime-config-snapshot";
9+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
10+
import {
11+
persistSessionTranscriptTurn,
12+
upsertSessionEntry,
13+
} from "../../../../src/config/sessions/session-accessor.js";
14+
import {
15+
listSessionTranscriptCorpusEntriesForAgent,
16+
type SessionTranscriptCorpusEntry,
17+
} from "./session-transcript-corpus.js";
18+
19+
function captureStateDirEnv() {
20+
const stateDir = process.env.OPENCLAW_STATE_DIR;
21+
return {
22+
restore() {
23+
if (stateDir === undefined) {
24+
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
25+
} else {
26+
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
27+
}
28+
},
29+
};
30+
}
31+
32+
let fixtureRoot: string;
33+
let tmpDir: string;
34+
let envSnapshot: ReturnType<typeof captureStateDirEnv> | undefined;
35+
let fixtureId = 0;
36+
37+
beforeAll(() => {
38+
fixtureRoot = fsSync.mkdtempSync(path.join(os.tmpdir(), "session-transcript-corpus-test-"));
39+
});
40+
41+
afterAll(() => {
42+
fsSync.rmSync(fixtureRoot, { recursive: true, force: true });
43+
});
44+
45+
beforeEach(() => {
46+
tmpDir = path.join(fixtureRoot, `case-${fixtureId++}`);
47+
fsSync.mkdirSync(tmpDir, { recursive: true });
48+
envSnapshot = captureStateDirEnv();
49+
Reflect.set(process.env, "OPENCLAW_STATE_DIR", tmpDir);
50+
clearRuntimeConfigSnapshot();
51+
clearConfigCache();
52+
});
53+
54+
afterEach(() => {
55+
envSnapshot?.restore();
56+
envSnapshot = undefined;
57+
clearRuntimeConfigSnapshot();
58+
clearConfigCache();
59+
});
60+
61+
function findEntry(
62+
entries: SessionTranscriptCorpusEntry[],
63+
predicate: (e: SessionTranscriptCorpusEntry) => boolean,
64+
): SessionTranscriptCorpusEntry | undefined {
65+
return entries.find(predicate);
66+
}
67+
68+
describe("SessionTranscriptCorpusEntry eligibleForDreaming", () => {
69+
it("marks SQLite-backed active sessions as eligible for dreaming", async () => {
70+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
71+
const storePath = path.join(sessionsDir, "sessions.json");
72+
const sessionKey = "agent:main:chat:corpus-eligible-1";
73+
const sessionId = "corpus-eligible-1";
74+
fsSync.mkdirSync(sessionsDir, { recursive: true });
75+
76+
await upsertSessionEntry(
77+
{ agentId: "main", sessionKey, storePath },
78+
{ sessionId, updatedAt: Date.now() },
79+
);
80+
await persistSessionTranscriptTurn(
81+
{ agentId: "main", sessionId, sessionKey, storePath },
82+
{
83+
messages: [
84+
{ message: { role: "user", content: "Hello from corpus test", timestamp: Date.now() } },
85+
],
86+
touchSessionEntry: true,
87+
updateMode: "none",
88+
},
89+
);
90+
91+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
92+
const entry = findEntry(entries, (e) => e.sessionId === sessionId);
93+
expect(entry).toMatchObject({
94+
agentId: "main",
95+
artifactKind: "active-session",
96+
eligibleForDreaming: true,
97+
sessionId,
98+
transcriptSource: "sqlite",
99+
});
100+
});
101+
102+
it("marks deleted archive artifacts as ineligible for dreaming", async () => {
103+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
104+
fsSync.mkdirSync(sessionsDir, { recursive: true });
105+
106+
const archivePath = path.join(sessionsDir, "ordinary.jsonl.deleted.2026-04-01T12-00-00.000Z");
107+
fsSync.writeFileSync(archivePath, "");
108+
fsSync.writeFileSync(path.join(sessionsDir, "sessions.json"), "{}");
109+
110+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
111+
const entry = findEntry(entries, (e) => e.sessionId === "ordinary");
112+
expect(entry).toMatchObject({
113+
agentId: "main",
114+
artifactKind: "archive-artifact",
115+
eligibleForDreaming: false,
116+
sessionId: "ordinary",
117+
});
118+
});
119+
120+
it("marks reset archive artifacts as ineligible for dreaming", async () => {
121+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
122+
fsSync.mkdirSync(sessionsDir, { recursive: true });
123+
124+
const archivePath = path.join(sessionsDir, "ordinary.jsonl.reset.2026-04-01T12-00-00.000Z");
125+
fsSync.writeFileSync(archivePath, "");
126+
fsSync.writeFileSync(path.join(sessionsDir, "sessions.json"), "{}");
127+
128+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
129+
const entry = findEntry(entries, (e) => e.sessionId === "ordinary");
130+
expect(entry).toMatchObject({
131+
agentId: "main",
132+
artifactKind: "archive-artifact",
133+
eligibleForDreaming: false,
134+
sessionId: "ordinary",
135+
});
136+
});
137+
138+
it("marks orphaned deleted archive as ineligible for dreaming when no active session exists", async () => {
139+
// Simulates the reported scenario: deleted archive files with no matching
140+
// sessions.json entry must not enter the Dreaming corpus.
141+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
142+
fsSync.mkdirSync(sessionsDir, { recursive: true });
143+
144+
const archivePath = path.join(sessionsDir, "orphaned.jsonl.deleted.2026-04-01T12-00-00.000Z");
145+
fsSync.writeFileSync(archivePath, "");
146+
// sessions.json does NOT contain a matching entry for "orphaned"
147+
fsSync.writeFileSync(path.join(sessionsDir, "sessions.json"), "{}");
148+
149+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
150+
const entry = findEntry(entries, (e) => e.sessionId === "orphaned");
151+
// Orphaned archive artifact is present in corpus for memory_search but
152+
// must be excluded from Dreaming ingestion.
153+
expect(entry).toBeDefined();
154+
expect(entry?.artifactKind).toBe("archive-artifact");
155+
expect(entry?.eligibleForDreaming).toBe(false);
156+
});
157+
158+
it("marks cron-session archive artifact as ineligible for dreaming while preserving lineage", async () => {
159+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
160+
const storePath = path.join(sessionsDir, "sessions.json");
161+
fsSync.mkdirSync(sessionsDir, { recursive: true });
162+
163+
const activePath = path.join(sessionsDir, "cron-run.jsonl");
164+
const archivePath = path.join(sessionsDir, "cron-run.jsonl.deleted.2026-04-01T12-00-00.000Z");
165+
fsSync.writeFileSync(activePath, "");
166+
fsSync.writeFileSync(archivePath, "");
167+
await upsertSessionEntry(
168+
{ agentId: "main", sessionKey: "agent:main:cron:job-1:run:run-1", storePath },
169+
{ sessionFile: "cron-run.jsonl", sessionId: "cron-run", updatedAt: 1 },
170+
);
171+
172+
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
173+
const archiveEntry = findEntry(
174+
entries,
175+
(e) => e.sessionId === "cron-run" && e.artifactKind === "archive-artifact",
176+
);
177+
// Archive inherits cron lineage AND is ineligible for Dreaming.
178+
expect(archiveEntry).toMatchObject({
179+
artifactKind: "archive-artifact",
180+
eligibleForDreaming: false,
181+
generatedByCronRun: true,
182+
sessionId: "cron-run",
183+
});
184+
});
185+
});

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export type SessionTranscriptCorpusEntry = {
3838
generatedByDreamingNarrative?: boolean;
3939
/** True when this transcript belongs to an isolated cron run session. */
4040
generatedByCronRun?: boolean;
41+
/**
42+
* False when this transcript should be excluded from Dreaming ingestion.
43+
* Archive artifacts (.jsonl.deleted.* / .jsonl.reset.*) are retained in the
44+
* shared corpus for memory_search but must not be re-ingested by Dreaming,
45+
* which learns only from the live session corpus.
46+
*/
47+
eligibleForDreaming: boolean;
4148
};
4249

4350
function fileContentRevision(filePath: string): string | undefined {
@@ -305,6 +312,7 @@ function toSessionStoreCorpusEntry(
305312
return {
306313
agentId,
307314
artifactKind: "active-session",
315+
eligibleForDreaming: true,
308316
sessionFile: source.sessionFile,
309317
sessionId: source.sessionId,
310318
...(contentRevision ? { contentRevision } : {}),
@@ -383,6 +391,9 @@ function toArtifactCorpusEntry(
383391
return {
384392
agentId,
385393
artifactKind: "archive-artifact",
394+
// Archive artifacts remain in the corpus for memory_search but must not be
395+
// ingested by Dreaming, which learns only from the live session corpus.
396+
eligibleForDreaming: false,
386397
sessionFile: artifactPath,
387398
sessionId,
388399
...(contentRevision ? { contentRevision } : {}),

0 commit comments

Comments
 (0)