From 335cf82cefa4636126a7a8f0fe3a637019b7ed2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 07:21:26 -0700 Subject: [PATCH] fix(memory): preserve line endings when forgetting entries (#130943) --- .../memory-core/src/memory-forget.test.ts | 161 +++++++++++++++--- extensions/memory-core/src/memory-forget.ts | 7 +- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/extensions/memory-core/src/memory-forget.test.ts b/extensions/memory-core/src/memory-forget.test.ts index b8eb4dac1c14..f36213eb931c 100644 --- a/extensions/memory-core/src/memory-forget.test.ts +++ b/extensions/memory-core/src/memory-forget.test.ts @@ -178,35 +178,142 @@ describe("memory forget", () => { ]); }); - it("durably tombstones an unresolved explicit session without inventing artifacts", async () => { - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Long-Term Memory\nKeep this.\n"); + it.each([ + { label: "LF", content: "# Long-Term Memory\nKeep this.\n" }, + { label: "CRLF", content: "# Long-Term Memory\r\nKeep this.\r\n" }, + { label: "mixed newlines", content: "# Long-Term Memory\r\nKeep this.\n" }, + ])( + "durably tombstones an unresolved explicit session without changing $label artifacts", + async ({ content }) => { + const memoryPath = path.join(workspaceDir, "MEMORY.md"); + await fs.writeFile(memoryPath, content); + const backup = { + key: "unrelated-backup", + value: { + createdAt: "2026-08-25T00:00:00.000Z", + content, + contentHash: createHash("sha256").update(content).digest("hex"), + }, + }; + await writeMemoryCoreWorkspaceEntries({ + namespace: DREAMING_MEMORY_BACKUP_NAMESPACE, + workspaceDir, + entries: [backup], + }); + const db = openOpenClawAgentDatabase({ agentId: "main" }).db; + db.prepare( + `INSERT INTO memory_index_chunks + (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) + VALUES ('unrelated', 'MEMORY.md', 'memory', 1, 2, + 'unrelated-hash', 'test', 'Keep this.', '[1,0]', 1)`, + ).run(); - const preview = await forgetMemoryEntries({ - cfg, - agentId: "main", - sessionIds: ["unknown-session"], - dryRun: true, - }); - expect(preview).toMatchObject({ - sessionIds: ["unknown-session"], - sessionResolutions: [{ sessionId: "unknown-session", source: "unresolved" }], - }); - expect(Object.values(preview.artifacts).every((count) => count === 0)).toBe(true); - expect(listMemorySessionTombstones({ agentId: "main" })).toEqual([]); + const preview = await forgetMemoryEntries({ + cfg, + agentId: "main", + sessionIds: ["unknown-session"], + dryRun: true, + }); + expect(preview).toMatchObject({ + sessionIds: ["unknown-session"], + sessionResolutions: [{ sessionId: "unknown-session", source: "unresolved" }], + }); + expect(Object.values(preview.artifacts).every((count) => count === 0)).toBe(true); + expect(listMemorySessionTombstones({ agentId: "main" })).toEqual([]); - const report = await forgetMemoryEntries({ - cfg, - agentId: "main", - sessionIds: ["unknown-session"], - }); - expect(report).toEqual({ ...preview, dryRun: false }); - const tombstones = listMemorySessionTombstones({ agentId: "main" }); - expect(tombstones).toMatchObject([{ sessionId: "unknown-session", reason: "forgotten" }]); - expect( - await forgetMemoryEntries({ cfg, agentId: "main", sessionIds: ["unknown-session"] }), - ).toEqual(report); - expect(listMemorySessionTombstones({ agentId: "main" })).toEqual(tombstones); - }); + const report = await forgetMemoryEntries({ + cfg, + agentId: "main", + sessionIds: ["unknown-session"], + }); + expect(report).toEqual({ ...preview, dryRun: false }); + const tombstones = listMemorySessionTombstones({ agentId: "main" }); + expect(tombstones).toMatchObject([{ sessionId: "unknown-session", reason: "forgotten" }]); + expect( + await forgetMemoryEntries({ cfg, agentId: "main", sessionIds: ["unknown-session"] }), + ).toEqual(report); + expect(listMemorySessionTombstones({ agentId: "main" })).toEqual(tombstones); + expect(await fs.readFile(memoryPath, "utf8")).toBe(content); + expect(db.prepare("SELECT id FROM memory_index_chunks").all()).toEqual([{ id: "unrelated" }]); + expect( + await readMemoryCoreWorkspaceEntries({ + namespace: DREAMING_MEMORY_BACKUP_NAMESPACE, + workspaceDir, + }), + ).toEqual([backup]); + }, + ); + + it.each([ + { label: "LF", targetEnding: "\n", survivorEnding: "\n" }, + { label: "CRLF", targetEnding: "\r\n", survivorEnding: "\r\n" }, + { label: "mixed", targetEnding: "\r\n", survivorEnding: "\n" }, + ])( + "preserves surviving line endings when purging $label corpus, memory, and backups", + async ({ targetEnding, survivorEnding }) => { + const memoryPath = path.join(workspaceDir, "MEMORY.md"); + const corpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus"); + const corpusPath = path.join(corpusDir, "2026-08-26.txt"); + const quotation = "User: Remove this selected private fact."; + const retainedMemory = "# Long-Term Memory\r\nKeep this.\nAnother retained line.\r\n"; + const content = `${retainedMemory}- Candidate: ${quotation}\r\n`; + const retainedCorpus = `[main/sessions/main/survivor#L1] User: Keep this unrelated fact.${survivorEnding}`; + await fs.mkdir(corpusDir, { recursive: true }); + await fs.writeFile(memoryPath, content); + await fs.writeFile( + corpusPath, + `[main/sessions/main/target#L1] ${quotation}${targetEnding}${retainedCorpus}`, + ); + await writeMemoryCoreWorkspaceEntries({ + namespace: DREAMING_MEMORY_BACKUP_NAMESPACE, + workspaceDir, + entries: [ + { + key: "backup", + value: { + createdAt: "2026-08-25T00:00:00.000Z", + content, + contentHash: createHash("sha256").update(content).digest("hex"), + }, + }, + ], + }); + + const preview = await forgetMemoryEntries({ + cfg, + agentId: "main", + sessionIds: ["target"], + dryRun: true, + }); + expect(preview.artifacts).toMatchObject({ + memoryFiles: 1, + memoryLines: 1, + sessionCorpusFiles: 1, + sessionCorpusLines: 1, + backups: 1, + }); + expect(await fs.readFile(memoryPath, "utf8")).toBe(content); + const report = await forgetMemoryEntries({ cfg, agentId: "main", sessionIds: ["target"] }); + expect(report).toEqual({ ...preview, dryRun: false }); + expect(await fs.readFile(memoryPath, "utf8")).toBe(retainedMemory); + expect(await fs.readFile(corpusPath, "utf8")).toBe(retainedCorpus); + expect( + await readMemoryCoreWorkspaceEntries({ + namespace: DREAMING_MEMORY_BACKUP_NAMESPACE, + workspaceDir, + }), + ).toEqual([ + { + key: "backup", + value: { + createdAt: "2026-08-25T00:00:00.000Z", + content: retainedMemory, + contentHash: createHash("sha256").update(retainedMemory).digest("hex"), + }, + }, + ]); + }, + ); it("removes staged backfill entries when their source session is forgotten", async () => { await seedSession("backfilled"); diff --git a/extensions/memory-core/src/memory-forget.ts b/extensions/memory-core/src/memory-forget.ts index 27915ee8c4cd..1588d9f9976a 100644 --- a/extensions/memory-core/src/memory-forget.ts +++ b/extensions/memory-core/src/memory-forget.ts @@ -151,7 +151,8 @@ function scrubMemoryContent(params: { corpusSnippets: ReadonlySet; agentId: string; }): { content: string; removedEntries: number; removedLines: number } { - const lines = params.content.split(/\r?\n/u); + // Preserve surviving line endings so unrelated artifacts do not enter the purge plan. + const lines = params.content.split("\n"); const corpusSnippets = [...params.corpusSnippets]; let removedEntries = 0; let removedLines = 0; @@ -402,12 +403,12 @@ async function forgetWorkspaceMemory( } const absolutePath = path.join(corpusDir, file.name); const content = await fs.readFile(absolutePath, "utf8"); - const lines = content.split(/\r?\n/u); + const lines = content.split("\n"); const retained = lines.filter((line) => { if (!referencesSession(line, params.agentId, sessionIds)) { return true; } - const snippet = /^\[[^\]]+#L\d+\]\s*(.+)$/u.exec(line)?.[1]?.trim(); + const snippet = /^\[[^\]]+#L\d+\]\s*(.+)$/u.exec(line.trimEnd())?.[1]?.trim(); // Ingestion only admits snippets of at least 12 characters; shorter // malformed corpus rows must never trigger broad substring deletion. if (snippet && snippet.length >= 12) {