Skip to content

Commit 820e818

Browse files
grifjefvincentkoc
authored andcommitted
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 d5badb9 commit 820e818

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
@@ -1756,6 +1756,124 @@ describe("short-term promotion", () => {
17561756
expect(testing.lineRangeOverlapsDreamingFence(lines, 8, 8)).toBe(false);
17571757
expect(testing.lineRangeOverlapsDreamingFence(lines, 6, 6)).toBe(true);
17581758
});
1759+
1760+
// Marker lines themselves carry managed-block content. A relocated range
1761+
// that includes a `<!-- openclaw:dreaming:*:start/end -->` marker would
1762+
// build its snippet from raw lines that contain that marker text, leaking
1763+
// it into MEMORY.md alongside any adjacent fenced content captured by the
1764+
// same window. The guard treats marker lines as inside-fence so those
1765+
// ranges are rejected. (#80613)
1766+
it("returns true when the range ends on a Light Sleep start marker", () => {
1767+
const lines = [
1768+
"## Plan",
1769+
"- Plan switches use exRule, not abConfig",
1770+
"",
1771+
"## Light Sleep",
1772+
"<!-- openclaw:dreaming:light:start -->",
1773+
"- Candidate: staged dream",
1774+
"<!-- openclaw:dreaming:light:end -->",
1775+
];
1776+
expect(testing.lineRangeOverlapsDreamingFence(lines, 2, 5)).toBe(true);
1777+
});
1778+
1779+
it("returns true when the range begins on a Light Sleep end marker", () => {
1780+
const lines = [
1781+
"<!-- openclaw:dreaming:light:start -->",
1782+
"- Candidate: staged dream",
1783+
"<!-- openclaw:dreaming:light:end -->",
1784+
"- normal durable bullet",
1785+
];
1786+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 4)).toBe(true);
1787+
});
1788+
1789+
it("returns true when the range covers only a marker line", () => {
1790+
const lines = [
1791+
"<!-- openclaw:dreaming:light:start -->",
1792+
"- Candidate: staged dream",
1793+
"<!-- openclaw:dreaming:light:end -->",
1794+
];
1795+
expect(testing.lineRangeOverlapsDreamingFence(lines, 1, 1)).toBe(true);
1796+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 3)).toBe(true);
1797+
});
1798+
1799+
it("returns true for REM marker single-line ranges even with no body between markers", () => {
1800+
const lines = [
1801+
"real line 1",
1802+
"<!-- openclaw:dreaming:rem:start -->",
1803+
"<!-- openclaw:dreaming:rem:end -->",
1804+
"real line 4",
1805+
];
1806+
// No content between the markers, but the marker text itself must not
1807+
// ride along into a promoted snippet.
1808+
expect(testing.lineRangeOverlapsDreamingFence(lines, 2, 2)).toBe(true);
1809+
expect(testing.lineRangeOverlapsDreamingFence(lines, 3, 3)).toBe(true);
1810+
// Real-content single lines remain unflagged.
1811+
expect(testing.lineRangeOverlapsDreamingFence(lines, 1, 1)).toBe(false);
1812+
expect(testing.lineRangeOverlapsDreamingFence(lines, 4, 4)).toBe(false);
1813+
});
1814+
});
1815+
1816+
it("does not promote rehydrated candidates whose relocated range covers a managed dreaming fence marker line (#80613)", async () => {
1817+
await withTempWorkspace(async (workspaceDir) => {
1818+
// Daily note: human content + a managed Light Sleep block. The relevant
1819+
// surface is the marker lines (5 and 8), not the fenced content between
1820+
// them. The existing fence-overlap guard already blocks ranges between
1821+
// the markers; this test exercises the residual edge case where the
1822+
// relocated range covers a marker line itself.
1823+
await writeDailyMemoryNote(workspaceDir, "2026-05-18", [
1824+
"## Plan", // 1
1825+
"- Plan switches use exRule, not abConfig", // 2
1826+
"", // 3
1827+
"## Light Sleep", // 4
1828+
"<!-- openclaw:dreaming:light:start -->", // 5
1829+
"- Candidate: staged dream", // 6
1830+
" - confidence: 0.95", // 7
1831+
"<!-- openclaw:dreaming:light:end -->", // 8
1832+
]);
1833+
1834+
// Stored recall snippet equals the marker text exactly, so relocate's
1835+
// exact-match path resolves to (5, 5) with the marker as its snippet.
1836+
// The contamination predicate does not flag bare marker text (no
1837+
// Candidate/Reflections + confidence + evidence + status: staged +
1838+
// recalls signature), so the only line of defense is the fence-overlap
1839+
// guard. Pre-patch the guard returns false for a marker-only range and
1840+
// the marker text leaks into MEMORY.md; post-patch the range is rejected.
1841+
await recordShortTermRecalls({
1842+
workspaceDir,
1843+
query: "marker-line edge case",
1844+
results: [
1845+
{
1846+
path: "memory/2026-05-18.md",
1847+
startLine: 5,
1848+
endLine: 5,
1849+
score: 0.94,
1850+
snippet: "<!-- openclaw:dreaming:light:start -->",
1851+
source: "memory",
1852+
},
1853+
],
1854+
});
1855+
1856+
const ranked = await rankShortTermPromotionCandidates({
1857+
workspaceDir,
1858+
minScore: 0,
1859+
minRecallCount: 0,
1860+
minUniqueQueries: 0,
1861+
});
1862+
const applied = await applyShortTermPromotions({
1863+
workspaceDir,
1864+
candidates: ranked,
1865+
minScore: 0,
1866+
minRecallCount: 0,
1867+
minUniqueQueries: 0,
1868+
});
1869+
1870+
expect(applied.applied).toBe(0);
1871+
const memoryText = await fs
1872+
.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8")
1873+
.catch(() => "");
1874+
expect(memoryText).not.toContain("Promoted From Short-Term Memory");
1875+
expect(memoryText).not.toMatch(/openclaw:dreaming/i);
1876+
});
17591877
});
17601878

17611879
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
@@ -2267,15 +2267,21 @@ function lineRangeOverlapsDreamingFence(
22672267
let insideFence = false;
22682268
for (let i = 0; i < safeEnd; i += 1) {
22692269
const line = lines[i] ?? "";
2270-
if (DREAMING_FENCE_START_RE.test(line)) {
2271-
insideFence = true;
2272-
continue;
2273-
}
2274-
if (DREAMING_FENCE_END_RE.test(line)) {
2275-
insideFence = false;
2270+
const oneIndexed = i + 1;
2271+
const isStart = DREAMING_FENCE_START_RE.test(line);
2272+
const isEnd = DREAMING_FENCE_END_RE.test(line);
2273+
if (isStart || isEnd) {
2274+
// The marker line itself is managed-block content. A relocated range
2275+
// that includes a `<!-- openclaw:dreaming:*:start/end -->` marker would
2276+
// build its snippet from raw lines that contain that marker text and
2277+
// leak it into MEMORY.md alongside any adjacent fenced content captured
2278+
// by the same window. (#80613)
2279+
if (oneIndexed >= safeStart && oneIndexed <= safeEnd) {
2280+
return true;
2281+
}
2282+
insideFence = isStart;
22762283
continue;
22772284
}
2278-
const oneIndexed = i + 1;
22792285
if (insideFence && oneIndexed >= safeStart && oneIndexed <= safeEnd) {
22802286
return true;
22812287
}

0 commit comments

Comments
 (0)