diff --git a/CHANGELOG.md b/CHANGELOG.md index 0659e9f98fc..2a5cf7658db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugin-sdk/memory-host-markdown.test.ts b/src/plugin-sdk/memory-host-markdown.test.ts index 6bf4fc2eee5..c9a2884698f 100644 --- a/src/plugin-sdk/memory-host-markdown.test.ts +++ b/src/plugin-sdk/memory-host-markdown.test.ts @@ -47,4 +47,103 @@ describe("replaceManagedMarkdownBlock", () => { }), ).toBe("alpha\n\n\nbeta\n\n"); }); + + it("replaces headed blocks with CRLF line endings in place", () => { + expect( + replaceManagedMarkdownBlock({ + original: "# Title\r\n\r\n## Generated\r\n\r\n- old\r\n\r\n", + heading: "## Generated", + startMarker: "", + endMarker: "", + body: "- new", + }), + ).toBe("# Title\r\n\r\n## Generated\n\n- new\n\r\n"); + }); + + it("collapses pre-existing duplicate managed blocks into one", () => { + const original = [ + "# Title", + "", + "## Generated", + "", + "- run-1", + "", + "", + "## Generated", + "", + "- run-2", + "", + "", + "## Generated", + "", + "- run-3", + "", + "", + ].join("\n"); + + const updated = replaceManagedMarkdownBlock({ + original, + heading: "## Generated", + startMarker: "", + endMarker: "", + body: "- latest", + }); + + expect(updated).toBe("# Title\n\n## Generated\n\n- latest\n\n"); + expect(updated.match(//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", + "", + "- old", + "", + "", + "## Generated", + "", + "- stale", + "", + "", + "## Notes", + "kept", + "", + "", + ].join("\n"); + + expect( + replaceManagedMarkdownBlock({ + original, + heading: "## Generated", + startMarker: "", + endMarker: "", + body: "- new", + }), + ).toBe( + "# Title\n\nParagraph A\n\n\nParagraph B\n\n## Generated\n\n- new\n\n\n## Notes\nkept\n\n", + ); + }); + + it("is idempotent across repeated calls with the same body", () => { + const params = { + heading: "## Generated", + startMarker: "", + endMarker: "", + 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); + }); }); diff --git a/src/plugin-sdk/memory-host-markdown.ts b/src/plugin-sdk/memory-host-markdown.ts index d9faf900989..d516cbc1c3d 100644 --- a/src/plugin-sdk/memory-host-markdown.ts +++ b/src/plugin-sdk/memory-host-markdown.ts @@ -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();