Skip to content

Commit 3149034

Browse files
authored
fix(memory-core): prevent staged dream candidates from leaking into MEMORY.md (#68774)
* fix(memory-core): prevent staged dream candidates from leaking into MEMORY.md * fix(memory-core): correct PromotionComponents shape in dream-fence test fixture
1 parent 706699b commit 3149034

2 files changed

Lines changed: 192 additions & 1 deletion

File tree

extensions/memory-core/src/short-term-promotion.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,147 @@ describe("short-term promotion", () => {
11181118
).toBe(true);
11191119
});
11201120

1121+
it("treats snippets with metadata prefix before the Candidate marker as contaminated", () => {
1122+
expect(
1123+
__testing.isContaminatedDreamingSnippet(
1124+
"- - status: staged - Candidate: User: [cron:26fb656d] run thing - confidence: 0.00 - evidence: memory/.dreams/session-corpus/2026-04-12.txt:25-25 - recalls: 0 - status: staged",
1125+
),
1126+
).toBe(true);
1127+
});
1128+
1129+
it("treats snippets with confidence prefix before the Candidate marker as contaminated", () => {
1130+
expect(
1131+
__testing.isContaminatedDreamingSnippet(
1132+
"confidence: 0.58 - Candidate: Assistant: Mason shipped the enforcement pass. - evidence: memory/.dreams/session-corpus/2026-04-11.txt:167-167 - recalls: 0 - status: staged",
1133+
),
1134+
).toBe(true);
1135+
});
1136+
1137+
it("does not treat prose that mentions the word Candidate as contaminated", () => {
1138+
expect(
1139+
__testing.isContaminatedDreamingSnippet(
1140+
"The Candidate profile for Josh Rhoden shows he runs SEU's network admin team; stack is Cisco plus Meraki.",
1141+
),
1142+
).toBe(false);
1143+
});
1144+
1145+
describe("lineRangeOverlapsDreamingFence", () => {
1146+
it("returns true when the range falls inside a Light Sleep fence", () => {
1147+
const lines = [
1148+
"# Daily note",
1149+
"## Notes",
1150+
"Real durable content.",
1151+
"## Light Sleep",
1152+
"<!-- openclaw:dreaming:light:start -->",
1153+
"- Candidate: some staged dream content",
1154+
"<!-- openclaw:dreaming:light:end -->",
1155+
"## After",
1156+
"More real content.",
1157+
];
1158+
// Line 6 (1-indexed) sits between the fence markers.
1159+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 6, 6)).toBe(true);
1160+
});
1161+
1162+
it("returns false when the range sits entirely outside any dreaming fence", () => {
1163+
const lines = [
1164+
"# Daily note",
1165+
"Real durable content.",
1166+
"<!-- openclaw:dreaming:rem:start -->",
1167+
"staged dream content",
1168+
"<!-- openclaw:dreaming:rem:end -->",
1169+
"More real content.",
1170+
];
1171+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 2, 2)).toBe(false);
1172+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 6, 6)).toBe(false);
1173+
});
1174+
1175+
it("returns true when the range straddles a fence boundary", () => {
1176+
const lines = [
1177+
"real line 1",
1178+
"<!-- openclaw:dreaming:diary:start -->",
1179+
"dream line",
1180+
"<!-- openclaw:dreaming:diary:end -->",
1181+
"real line 5",
1182+
];
1183+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 2, 4)).toBe(true);
1184+
});
1185+
1186+
it("recovers after a fence end so later real content is not flagged", () => {
1187+
const lines = [
1188+
"<!-- openclaw:dreaming:light:start -->",
1189+
"dream",
1190+
"<!-- openclaw:dreaming:light:end -->",
1191+
"real line 4",
1192+
"<!-- openclaw:dreaming:rem:start -->",
1193+
"more dream",
1194+
"<!-- openclaw:dreaming:rem:end -->",
1195+
"real line 8",
1196+
];
1197+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 4, 4)).toBe(false);
1198+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 8, 8)).toBe(false);
1199+
expect(__testing.lineRangeOverlapsDreamingFence(lines, 6, 6)).toBe(true);
1200+
});
1201+
});
1202+
1203+
it("refuses to promote rehydrated candidates that land inside a managed dreaming fence", async () => {
1204+
await withTempWorkspace(async (workspaceDir) => {
1205+
const dailyPath = await writeDailyMemoryNote(workspaceDir, "2026-04-18", [
1206+
"# 2026-04-18",
1207+
"",
1208+
"## Notes",
1209+
"Legitimate durable observation about backups.",
1210+
"",
1211+
"## Light Sleep",
1212+
"<!-- openclaw:dreaming:light:start -->",
1213+
"- Candidate: staged dream scratchwork",
1214+
"<!-- openclaw:dreaming:light:end -->",
1215+
]);
1216+
expect(dailyPath).toBeTruthy();
1217+
1218+
const applied = await applyShortTermPromotions({
1219+
workspaceDir,
1220+
minScore: 0,
1221+
minRecallCount: 0,
1222+
minUniqueQueries: 0,
1223+
candidates: [
1224+
{
1225+
key: "memory:memory/2026-04-18.md:8:8",
1226+
path: "memory/2026-04-18.md",
1227+
startLine: 8,
1228+
endLine: 8,
1229+
source: "memory",
1230+
snippet: "- Candidate: staged dream scratchwork",
1231+
recallCount: 3,
1232+
avgScore: 0.9,
1233+
maxScore: 0.9,
1234+
uniqueQueries: 2,
1235+
firstRecalledAt: "2026-04-17T00:00:00.000Z",
1236+
lastRecalledAt: "2026-04-18T00:00:00.000Z",
1237+
ageDays: 1,
1238+
score: 0.9,
1239+
recallDays: ["2026-04-17", "2026-04-18"],
1240+
conceptTags: ["dream"],
1241+
components: {
1242+
frequency: 1,
1243+
relevance: 0,
1244+
diversity: 1,
1245+
recency: 1,
1246+
consolidation: 0,
1247+
conceptual: 0,
1248+
},
1249+
},
1250+
],
1251+
});
1252+
1253+
expect(applied.applied).toBe(0);
1254+
const memoryText = await fs
1255+
.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8")
1256+
.catch(() => "");
1257+
expect(memoryText).not.toContain("Promoted From Short-Term Memory");
1258+
expect(memoryText).not.toContain("staged dream scratchwork");
1259+
});
1260+
});
1261+
11211262
it("skips direct candidates that exceed maxAgeDays during apply", async () => {
11221263
await withTempWorkspace(async (workspaceDir) => {
11231264
const applied = await applyShortTermPromotions({

extensions/memory-core/src/short-term-promotion.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,17 @@ function consumeDreamingLeadPrefix(snippet: string): string {
284284

285285
function hasDreamingNarrativeLead(snippet: string): boolean {
286286
const withoutPrefix = consumeDreamingLeadPrefix(snippet);
287-
return /^Candidate:/i.test(withoutPrefix) || /^Reflections?:/i.test(withoutPrefix);
287+
if (/^(?:Candidate|Reflections?):/i.test(withoutPrefix)) {
288+
return true;
289+
}
290+
// Managed dreaming blocks occasionally serialize recall metadata (status:/confidence:/
291+
// evidence:/recalls:) inline before the Candidate or Reflections marker, so the
292+
// start-of-string check misses shapes like "status: staged - Candidate: User: ...".
293+
// The composite detector below still requires the full signal combination, so widening
294+
// the lead check to anywhere in the first 200 chars closes the leak without creating
295+
// false positives for ordinary durable notes that merely mention the word in prose.
296+
const head = withoutPrefix.slice(0, 200);
297+
return /\b(?:Candidate|Reflections?):/i.test(head);
288298
}
289299

290300
function isContaminatedDreamingSnippet(raw: string): boolean {
@@ -1491,6 +1501,38 @@ function relocateCandidateRange(
14911501
};
14921502
}
14931503

1504+
const DREAMING_FENCE_START_RE = /<!--\s*openclaw:dreaming:[a-z][a-z0-9-]*:start\s*-->/i;
1505+
const DREAMING_FENCE_END_RE = /<!--\s*openclaw:dreaming:[a-z][a-z0-9-]*:end\s*-->/i;
1506+
1507+
function lineRangeOverlapsDreamingFence(
1508+
lines: string[],
1509+
startLine: number,
1510+
endLine: number,
1511+
): boolean {
1512+
if (lines.length === 0) {
1513+
return false;
1514+
}
1515+
const safeStart = Math.max(1, Math.min(startLine, lines.length));
1516+
const safeEnd = Math.max(safeStart, Math.min(endLine, lines.length));
1517+
let insideFence = false;
1518+
for (let i = 0; i < safeEnd; i += 1) {
1519+
const line = lines[i] ?? "";
1520+
if (DREAMING_FENCE_START_RE.test(line)) {
1521+
insideFence = true;
1522+
continue;
1523+
}
1524+
if (DREAMING_FENCE_END_RE.test(line)) {
1525+
insideFence = false;
1526+
continue;
1527+
}
1528+
const oneIndexed = i + 1;
1529+
if (insideFence && oneIndexed >= safeStart && oneIndexed <= safeEnd) {
1530+
return true;
1531+
}
1532+
}
1533+
return false;
1534+
}
1535+
14941536
async function rehydratePromotionCandidate(
14951537
workspaceDir: string,
14961538
candidate: PromotionCandidate,
@@ -1512,6 +1554,13 @@ async function rehydratePromotionCandidate(
15121554
if (!relocated) {
15131555
continue;
15141556
}
1557+
// Managed dreaming blocks in daily memory files are scratchwork, not durable
1558+
// content. If rehydration lands inside an openclaw:dreaming fence (for example
1559+
// because file edits shifted lines between ranking and apply), refuse the
1560+
// candidate so dream artifacts cannot be promoted into MEMORY.md.
1561+
if (lineRangeOverlapsDreamingFence(lines, relocated.startLine, relocated.endLine)) {
1562+
continue;
1563+
}
15151564
return {
15161565
...candidate,
15171566
startLine: relocated.startLine,
@@ -2018,4 +2067,5 @@ export const __testing = {
20182067
buildClaimHash,
20192068
totalSignalCountForEntry,
20202069
isContaminatedDreamingSnippet,
2070+
lineRangeOverlapsDreamingFence,
20212071
};

0 commit comments

Comments
 (0)