fix(memory): dedupe managed markdown blocks

This commit is contained in:
Peter Steinberger
2026-05-02 11:27:33 +01:00
parent f738263604
commit c247820bd1
3 changed files with 126 additions and 4 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao.
- Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot.
- Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser.
- Model commands: clarify direct and inline `/model` acknowledgements for non-default selections as session-scoped. Thanks @addu2612.
- TUI/chat: skip full provider model normalization during context-window warmup while preserving provider-owned context metadata, avoiding cold-start stalls with large model registries. Thanks @547895019.

View File

@@ -47,4 +47,103 @@ describe("replaceManagedMarkdownBlock", () => {
}),
).toBe("alpha\n\n<!-- start -->\nbeta\n<!-- end -->\n");
});
it("replaces headed blocks with CRLF line endings in place", () => {
expect(
replaceManagedMarkdownBlock({
original: "# Title\r\n\r\n## Generated\r\n<!-- start -->\r\n- old\r\n<!-- end -->\r\n",
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- new",
}),
).toBe("# Title\r\n\r\n## Generated\n<!-- start -->\n- new\n<!-- end -->\r\n");
});
it("collapses pre-existing duplicate managed blocks into one", () => {
const original = [
"# Title",
"",
"## Generated",
"<!-- start -->",
"- run-1",
"<!-- end -->",
"",
"## Generated",
"<!-- start -->",
"- run-2",
"<!-- end -->",
"",
"## Generated",
"<!-- start -->",
"- run-3",
"<!-- end -->",
"",
].join("\n");
const updated = replaceManagedMarkdownBlock({
original,
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- latest",
});
expect(updated).toBe("# Title\n\n## Generated\n<!-- start -->\n- latest\n<!-- end -->\n");
expect(updated.match(/<!-- start -->/g)?.length).toBe(1);
expect(updated).not.toContain("run-");
});
it("preserves unmanaged markdown while removing duplicate blocks", () => {
const original = [
"# Title",
"",
"Paragraph A",
"",
"",
"Paragraph B",
"",
"## Generated",
"<!-- start -->",
"- old",
"<!-- end -->",
"",
"## Generated",
"<!-- start -->",
"- stale",
"<!-- end -->",
"",
"## Notes",
"kept",
"",
"",
].join("\n");
expect(
replaceManagedMarkdownBlock({
original,
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- new",
}),
).toBe(
"# Title\n\nParagraph A\n\n\nParagraph B\n\n## Generated\n<!-- start -->\n- new\n<!-- end -->\n\n## Notes\nkept\n\n",
);
});
it("is idempotent across repeated calls with the same body", () => {
const params = {
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- only",
} as const;
const first = replaceManagedMarkdownBlock({ original: "# Title\n", ...params });
const second = replaceManagedMarkdownBlock({ original: first, ...params });
const third = replaceManagedMarkdownBlock({ original: second, ...params });
expect(second).toBe(first);
expect(third).toBe(first);
});
});

View File

@@ -10,6 +10,10 @@ function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isLineWhitespace(value: string): boolean {
return /^[\t \r\n]*$/.test(value);
}
export function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
@@ -17,13 +21,31 @@ export function withTrailingNewline(content: string): string {
export function replaceManagedMarkdownBlock(params: ManagedMarkdownBlockParams): string {
const headingPrefix = params.heading ? `${params.heading}\n` : "";
const managedBlock = `${headingPrefix}${params.startMarker}\n${params.body}\n${params.endMarker}`;
const headingPattern = params.heading
? `${escapeRegex(params.heading)}(?:[ \t]*(?:\r\n|\n|\r))+[ \t]*`
: "";
const existingPattern = new RegExp(
`${params.heading ? `${escapeRegex(params.heading)}\\n` : ""}${escapeRegex(params.startMarker)}[\\s\\S]*?${escapeRegex(params.endMarker)}`,
"m",
`${headingPattern}${escapeRegex(params.startMarker)}[\\s\\S]*?${escapeRegex(params.endMarker)}`,
"g",
);
const matches = Array.from(params.original.matchAll(existingPattern));
if (existingPattern.test(params.original)) {
return params.original.replace(existingPattern, managedBlock);
if (matches.length > 0) {
let updated = "";
let lastEnd = 0;
matches.forEach((match, index) => {
const matchStart = match.index ?? 0;
const matchEnd = matchStart + match[0].length;
const betweenMatches = params.original.slice(lastEnd, matchStart);
if (index === 0) {
updated += params.original.slice(0, matchStart);
updated += managedBlock;
} else if (!isLineWhitespace(betweenMatches)) {
updated += betweenMatches;
}
lastEnd = matchEnd;
});
return updated + params.original.slice(lastEnd);
}
const trimmed = params.original.trimEnd();