From ca4b842b891faf70f5515893c2d309c83ea6e638 Mon Sep 17 00:00:00 2001 From: phil-lipp <52623794+phil-lipp@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:09:57 +0200 Subject: [PATCH] fix: strip matching Tags footer only during compaction restore Auto-capture embeds a Tags: line in the body; compaction also appends structured tags. Remove the trailing footer only when it matches those tags so user-authored Tags: content is preserved. --- src/index.ts | 47 +++++++++++++++++-- tests/compaction-agent-preservation.test.ts | 51 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b136fab..70deb4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1041,14 +1041,55 @@ function formatSearchResults(query: string, results: any, limit?: number): strin }); } +const EMBEDDED_TAGS_FOOTER_RE = /\n*Tags: ([^\n]*)\s*$/; + +function normalizeTagsKey(tags: string[]): string { + return tags + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + .sort() + .join("\0"); +} + +function stripMatchingEmbeddedTagsFooter(memory: string, tags: string[]): string { + const match = memory.match(EMBEDDED_TAGS_FOOTER_RE); + if (!match) { + return memory; + } + + const footerValue = match[1]; + if (footerValue === undefined) { + return memory; + } + + const embeddedTags = footerValue + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + if (normalizeTagsKey(embeddedTags) !== normalizeTagsKey(tags)) { + return memory; + } + + return memory.replace(EMBEDDED_TAGS_FOOTER_RE, ""); +} + function formatMemoriesForCompaction(memories: any[]): string { let output = `## Restored Session Memory\n\n`; memories.forEach((m, i) => { + const tags = Array.isArray(m.tags) ? m.tags : []; + // Auto-capture stores the same tags as a trailing "Tags: …" footer in the + // body (#131). Strip that footer only when it matches the structured tags + // so a later canonical line does not duplicate them. Unrelated user-authored + // "Tags:" lines (manual / API / import) stay in the body. + const body = + tags.length > 0 ? stripMatchingEmbeddedTagsFooter(m.memory ?? "", tags) : (m.memory ?? ""); + output += `### Memory ${i + 1}\n`; - output += `${m.memory}\n\n`; - if (m.tags && m.tags.length > 0) { - output += `Tags: ${m.tags.join(", ")}\n\n`; + output += `${body}\n\n`; + if (tags.length > 0) { + output += `Tags: ${tags.join(", ")}\n\n`; } }); diff --git a/tests/compaction-agent-preservation.test.ts b/tests/compaction-agent-preservation.test.ts index 54f15be..cd4b75a 100644 --- a/tests/compaction-agent-preservation.test.ts +++ b/tests/compaction-agent-preservation.test.ts @@ -213,6 +213,57 @@ describe("session.compacted agent preservation (#236)", () => { expect(result.parsed?.promptCalls[0]?.body?.parts?.[0]?.synthetic).toBe(true); }); + it("does not duplicate the Tags line when the stored memory already embeds one (regression from #131)", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [ + { + memory: "We chose libSQL over sqlite3.\n\nTags: architecture, decision", + tags: ["architecture", "decision"], + }, + ], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + const text = result.parsed?.promptCalls[0]?.body?.parts?.[0]?.text ?? ""; + expect(text).toContain("We chose libSQL over sqlite3."); + const tagsLines = text.match(/^Tags: .*/gm) ?? []; + expect(tagsLines).toEqual(["Tags: architecture, decision"]); + }); + + it("still appends a single Tags line when the memory body has no embedded footer", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [{ memory: "Plain memory body without footer.", tags: ["decision"] }], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + const text = result.parsed?.promptCalls[0]?.body?.parts?.[0]?.text ?? ""; + const tagsLines = text.match(/^Tags: .*/gm) ?? []; + expect(tagsLines).toEqual(["Tags: decision"]); + }); + + it("preserves a trailing Tags: line that does not match the structured tags", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [ + { + memory: "Imported note from the API.\n\nTags: user-authored evidence", + tags: ["canonical"], + }, + ], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + const text = result.parsed?.promptCalls[0]?.body?.parts?.[0]?.text ?? ""; + expect(text).toContain("Imported note from the API."); + const tagsLines = text.match(/^Tags: .*/gm) ?? []; + expect(tagsLines).toEqual(["Tags: user-authored evidence", "Tags: canonical"]); + }); + it("does not call session.prompt when there are no memories", () => { const result = runCompactionScenario({ sessionAgent: "my-orchestrator",