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