diff --git a/hindsight-integrations/coding-agents/src/core/retain-cursor.ts b/hindsight-integrations/coding-agents/src/core/retain-cursor.ts index 763e6c647..042a8938c 100644 --- a/hindsight-integrations/coding-agents/src/core/retain-cursor.ts +++ b/hindsight-integrations/coding-agents/src/core/retain-cursor.ts @@ -11,9 +11,12 @@ * whenever that cannot be established: * * - no cursor first write-back of the session, or the cache was evicted. - * - fingerprint drift the transcript was REWRITTEN rather than extended (Claude Code compaction, - * a truncated/rotated rollout), so what we already sent is no longer a prefix - * of what we hold and an append would splice two different conversations. + * - fingerprint drift the transcript was REWRITTEN rather than extended (an edited or redacted + * turn, a truncated/rotated rollout), so what we already sent is no longer a + * prefix of what we hold and an append would splice two conversations. + * NOT Claude Code compaction, which this used to cite: that appends a + * summary record and leaves every earlier record in place (#3379), so the + * prefix stays intact and the append path keeps working. * - dirty the previous retain failed, or its outcome is unknown (the client aborts at * 15s while the server may well have committed it). Appending on top of an * unknown state is the one way to DUPLICATE turns inside the document, so we diff --git a/hindsight-integrations/coding-agents/src/core/transcript.test.ts b/hindsight-integrations/coding-agents/src/core/transcript.test.ts index 4ce610d26..d437bce0f 100644 --- a/hindsight-integrations/coding-agents/src/core/transcript.test.ts +++ b/hindsight-integrations/coding-agents/src/core/transcript.test.ts @@ -166,6 +166,36 @@ describe("readClaudeTranscript", () => { expect(result[0].content).not.toContain("hindsight_memories"); }); + it("drops the compaction summary — Claude Code's recap, not the user's words", () => { + // Written when the context window fills, as a plain type:"user" record with no isMeta flag. + // Measured at 29 records / 474,016 chars across local transcripts, averaging 16KB each, every + // one retained as if the user had typed it — and each one summarising turns already retained, + // since compaction APPENDS to the transcript rather than rewriting it (#3379). + writeFileSync( + file, + [ + JSON.stringify({ + type: "user", + isCompactSummary: true, + message: { + role: "user", + content: + "This session is being continued from a previous conversation that ran out of " + + "context. The summary below covers ", + }, + }), + JSON.stringify({ + type: "user", + message: { role: "user", content: "now add the retry backoff" }, + }), + ].join("\n") + ); + + const result = readClaudeTranscript(file); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("now add the retry backoff"); + }); + it("drops a : the harness's background-task plumbing, not the user's words", () => { // Claude Code delivers these as an ordinary type:"user" message with a string body and no // isMeta flag, so nothing else filters them and extraction saw task ids and status lines as diff --git a/hindsight-integrations/coding-agents/src/core/transcript.ts b/hindsight-integrations/coding-agents/src/core/transcript.ts index db160eb19..6c6dd4c8f 100644 --- a/hindsight-integrations/coding-agents/src/core/transcript.ts +++ b/hindsight-integrations/coding-agents/src/core/transcript.ts @@ -6,7 +6,8 @@ * no arguments, no outputs. That keeps WHICH files/commands the session touched without burying the * decisions in mechanical noise; `tool_result` blocks are dropped entirely. * - * Drops non-message lines (`last-prompt`, `mode`, `summary`, …), `isMeta` lines, `isSidechain` + * Drops non-message lines (`last-prompt`, `mode`, `summary`, …), `isMeta` lines, compaction + * summaries (`isCompactSummary`), `isSidechain` * (subagent/Task) lines, `thinking` blocks, and turns that render to nothing. Injected recall * context (`` / `` / ``) is stripped so the * write-back can't feed recalled memory back into the bank (a retain→recall feedback loop). A @@ -29,6 +30,7 @@ interface TranscriptLine { type?: string; isMeta?: boolean; isSidechain?: boolean; + isCompactSummary?: boolean; timestamp?: string; message?: { content?: string | ContentBlock[]; @@ -95,6 +97,12 @@ export function readClaudeTranscript(path: string): TransportTurn[] { if (line.type !== "user" && line.type !== "assistant") continue; if (line.isMeta === true) continue; if (line.isSidechain === true) continue; + // Claude Code's own recap of the conversation so far, written when the context window fills. + // It arrives as a plain type:"user" record with NO isMeta flag, so nothing else filters it and + // a ~16KB machine-written summary was retained as something the user said. It is also a + // summary of turns ALREADY retained — compaction appends to the transcript rather than + // rewriting it — so keeping it extracts the same decisions twice (#3379). + if (line.isCompactSummary === true) continue; if (typeof line.message !== "object" || line.message === null) continue; // `type` is validated as "user" | "assistant" above; drive role from it (not the redundant