Skip to content

Commit cf8981c

Browse files
committed
fix(memory-core): treat dreaming fence marker lines as inside-fence in promotion guard
The lineRangeOverlapsDreamingFence guard tracked insideFence state from the marker lines but did not flag ranges that included the marker lines themselves. A relocated promotion range that ended on a start marker, began on an end marker, or covered only a marker line passed the guard, and the snippet built from those raw lines carried the marker text into MEMORY.md. Treat marker lines as inside-fence content for range overlap purposes and add unit + integration coverage. The integration test exercises the real applyShortTermPromotions path: pre-patch a recall whose stored snippet is the start-marker text produces a 'Promoted From Short-Term Memory' entry containing the literal marker; post-patch the same input yields applied=0 and no MEMORY.md write. Refs #80613.
1 parent f07c874 commit cf8981c

2 files changed

Lines changed: 131 additions & 7 deletions

File tree

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,6 +1198,124 @@ describe("short-term promotion", () => {
11981198
expect(testing.lineRangeOverlapsDreamingFence(lines, 8, 8)).toBe(false);
11991199
expect(testing.lineRangeOverlapsDreamingFence(lines, 6, 6)).toBe(true);
12001200
});
1201+
1202+
// Marker lines themselves carry managed-block content. A relocated range
1203+
// that includes a `<!-- openclaw:dreaming:*:start/end -->` marker would
1204+
// build its snippet from raw lines that contain that marker text, leaking
1205+
// it into MEMORY.md alongside any adjacent fenced content captured by the
1206+
// same window. The guard treats marker lines as inside-fence so those
1207+
// ranges are rejected. (#80613)
1208+
it("returns true when the range ends on a Light Sleep start marker", () => {
1209+
const lines = [
1210+
"## Plan",
1211+
"- Plan switches use exRule, not abConfig",
1212+
"",
1213+
"## Light Sleep",
1214+
"<!-- openclaw:dreaming:light:start -->",
1215+
"- Candidate: staged dream",
1216+
"<!-- openclaw:dreaming:light:end -->",
1217+
];
1218+
expect(testing.lineRangeOverlapsDreamingFence(lines, 2, 5)).toBe(true);
1219+
});
1220+
1221+
it("returns true when the range begins on a Light Sleep end marker", () => {
1222+
const lines = [
1223+
"<!-- openclaw:dreaming:light:start -->",
1224+
"- Candidate: staged dream",
1225+
"<!-- openclaw:dreaming:light:end -->",
1226+
"- normal durable bullet",
1227+
];
1228+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 4)).toBe(true);
1229+
});
1230+
1231+
it("returns true when the range covers only a marker line", () => {
1232+
const lines = [
1233+
"<!-- openclaw:dreaming:light:start -->",
1234+
"- Candidate: staged dream",
1235+
"<!-- openclaw:dreaming:light:end -->",
1236+
];
1237+
expect(testing.lineRangeOverlapsDreamingFence(lines, 1, 1)).toBe(true);
1238+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 3)).toBe(true);
1239+
});
1240+
1241+
it("returns true for REM marker single-line ranges even with no body between markers", () => {
1242+
const lines = [
1243+
"real line 1",
1244+
"<!-- openclaw:dreaming:rem:start -->",
1245+
"<!-- openclaw:dreaming:rem:end -->",
1246+
"real line 4",
1247+
];
1248+
// No content between the markers, but the marker text itself must not
1249+
// ride along into a promoted snippet.
1250+
expect(testing.lineRangeOverlapsDreamingFence(lines, 2, 2)).toBe(true);
1251+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 3)).toBe(true);
1252+
// Real-content single lines remain unflagged.
1253+
expect(testing.lineRangeOverlapsDreamingFence(lines, 1, 1)).toBe(false);
1254+
expect(testing.lineRangeOverlapsDreamingFence(lines, 4, 4)).toBe(false);
1255+
});
1256+
});
1257+
1258+
it("does not promote rehydrated candidates whose relocated range covers a managed dreaming fence marker line (#80613)", async () => {
1259+
await withTempWorkspace(async (workspaceDir) => {
1260+
// Daily note: human content + a managed Light Sleep block. The relevant
1261+
// surface is the marker lines (5 and 8), not the fenced content between
1262+
// them. The existing fence-overlap guard already blocks ranges between
1263+
// the markers; this test exercises the residual edge case where the
1264+
// relocated range covers a marker line itself.
1265+
await writeDailyMemoryNote(workspaceDir, "2026-05-18", [
1266+
"## Plan", // 1
1267+
"- Plan switches use exRule, not abConfig", // 2
1268+
"", // 3
1269+
"## Light Sleep", // 4
1270+
"<!-- openclaw:dreaming:light:start -->", // 5
1271+
"- Candidate: staged dream", // 6
1272+
" - confidence: 0.95", // 7
1273+
"<!-- openclaw:dreaming:light:end -->", // 8
1274+
]);
1275+
1276+
// Stored recall snippet equals the marker text exactly, so relocate's
1277+
// exact-match path resolves to (5, 5) with the marker as its snippet.
1278+
// The contamination predicate does not flag bare marker text (no
1279+
// Candidate/Reflections + confidence + evidence + status: staged +
1280+
// recalls signature), so the only line of defense is the fence-overlap
1281+
// guard. Pre-patch the guard returns false for a marker-only range and
1282+
// the marker text leaks into MEMORY.md; post-patch the range is rejected.
1283+
await recordShortTermRecalls({
1284+
workspaceDir,
1285+
query: "marker-line edge case",
1286+
results: [
1287+
{
1288+
path: "memory/2026-05-18.md",
1289+
startLine: 5,
1290+
endLine: 5,
1291+
score: 0.94,
1292+
snippet: "<!-- openclaw:dreaming:light:start -->",
1293+
source: "memory",
1294+
},
1295+
],
1296+
});
1297+
1298+
const ranked = await rankShortTermPromotionCandidates({
1299+
workspaceDir,
1300+
minScore: 0,
1301+
minRecallCount: 0,
1302+
minUniqueQueries: 0,
1303+
});
1304+
const applied = await applyShortTermPromotions({
1305+
workspaceDir,
1306+
candidates: ranked,
1307+
minScore: 0,
1308+
minRecallCount: 0,
1309+
minUniqueQueries: 0,
1310+
});
1311+
1312+
expect(applied.applied).toBe(0);
1313+
const memoryText = await fs
1314+
.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8")
1315+
.catch(() => "");
1316+
expect(memoryText).not.toContain("Promoted From Short-Term Memory");
1317+
expect(memoryText).not.toMatch(/openclaw:dreaming/i);
1318+
});
12011319
});
12021320

12031321
it("refuses to promote rehydrated candidates that land inside a managed dreaming fence", async () => {

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,15 +1517,21 @@ function lineRangeOverlapsDreamingFence(
15171517
let insideFence = false;
15181518
for (let i = 0; i < safeEnd; i += 1) {
15191519
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;
1520+
const oneIndexed = i + 1;
1521+
const isStart = DREAMING_FENCE_START_RE.test(line);
1522+
const isEnd = DREAMING_FENCE_END_RE.test(line);
1523+
if (isStart || isEnd) {
1524+
// The marker line itself is managed-block content. A relocated range
1525+
// that includes a `<!-- openclaw:dreaming:*:start/end -->` marker would
1526+
// build its snippet from raw lines that contain that marker text and
1527+
// leak it into MEMORY.md alongside any adjacent fenced content captured
1528+
// by the same window. (#80613)
1529+
if (oneIndexed >= safeStart && oneIndexed <= safeEnd) {
1530+
return true;
1531+
}
1532+
insideFence = isStart;
15261533
continue;
15271534
}
1528-
const oneIndexed = i + 1;
15291535
if (insideFence && oneIndexed >= safeStart && oneIndexed <= safeEnd) {
15301536
return true;
15311537
}

0 commit comments

Comments
 (0)