Skip to content

fix(claude-code): don't retain Claude Code injected/synthetic context - #2487

Closed
hankel-ai wants to merge 2 commits into
vectorize-io:mainfrom
hankel-ai:fix/claude-code-injected-context-retention
Closed

fix(claude-code): don't retain Claude Code injected/synthetic context#2487
hankel-ai wants to merge 2 commits into
vectorize-io:mainfrom
hankel-ai:fix/claude-code-injected-context-retention

Conversation

@hankel-ai

Copy link
Copy Markdown

Problem

The Claude Code auto-retain hook stores a lot of behind-the-scenes context that the user never actually sent, polluting the memory bank with harness noise (skill docs, settings schemas, slash-command expansions, system reminders, command output). In practice this drowns real project/preference facts under injected-context memories.

Two root causes, plus a Windows packaging papercut:

  1. read_transcript() ignores isMeta. Claude Code marks injected/synthetic transcript turns with isMeta: true (skill documentation, settings-schema dumps, slash-command expansions). These were being retained as if the user authored them.

  2. Harness context rides inside real turns. Genuine user/assistant turns can embed harness-injected context in XML wrappers — <system-reminder>, <task-notification>, <local-command-stdout>, and <command-name>/<command-message>/<command-args>. None of that was sent by the user, but it was being retained verbatim.

  3. Shell hooks get CRLF on Windows. With the Git-for-Windows default core.autocrlf=true, scripts/*.sh check out with CRLF endings, which breaks their \ line-continuations and shebang under Git Bash.

Changes

  • scripts/retain.py: skip transcript entries where isMeta is set.
  • scripts/lib/content.py: add strip_injected_context() (removes the Claude Code harness wrappers) and call it from strip_memory_tags(), so every existing retention/recall call site benefits.
  • hindsight-integrations/claude-code/.gitattributes: pin *.sh text eol=lf.
  • tests/test_content.py: unit tests for strip_injected_context() and the strip_memory_tags() path.

Testing

PYTHONPATH=scripts python -m pytest tests/test_content.py → all pass (63). Full-suite failures on my Windows box are pre-existing and unrelated (path-separator/symlink assertions in test_run_mcp.py, test_config.py, test_bank.py) — they fail identically on a pristine checkout.

🤖 Generated with Claude Code

The Claude Code auto-retain hook stores behind-the-scenes context that the
user never sent, polluting the memory bank with harness noise:

- read_transcript() ignored the `isMeta` flag, so injected/synthetic turns
  (skill docs, settings schemas, slash-command expansions) were retained as
  if authored by the user. Skip entries where isMeta is set.
- Real user/assistant turns can carry harness-injected context inside XML
  wrappers (<system-reminder>, <task-notification>, <local-command-stdout>,
  <command-*>). Add strip_injected_context() and apply it in strip_memory_tags
  so only the messages actually exchanged are retained.
- Ship a .gitattributes pinning *.sh to LF. On Windows with the default
  core.autocrlf=true, the hook shell scripts get CRLF endings that break their
  `\` line-continuations and shebang under Git Bash.

Adds unit tests for strip_injected_context and the strip_memory_tags path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nicolas-vivot

Copy link
Copy Markdown
Contributor

Found the exact same issue and was going to push a fix for that. Thank you so much for addressing it :)

@nicolas-vivot

Copy link
Copy Markdown
Contributor

@hankel-ai
A few things to consider (nice to have):

  1. Add a read_transcript test covering the isMeta skip. This is the primary behavior introduced by the change, but it currently isn't covered. The new tests only exercise strip_injected_context() and strip_memory_tags(), so a regression in read_transcript() could go unnoticed.

  2. Keep strip_injected_context() separate from strip_memory_tags(). Having one concern embedded inside an unrelated function makes the behavior implicit and easy to overlook at the call sites. It may be clearer to either call strip_injected_context() explicitly where needed, or introduce a generic wrapper (for example, sanitize_content()) that composes both operations while keeping each function focused on a single responsibility.

  3. Consider making the tag regex more resilient to future changes. The current pattern only matches tags without attributes. If attributes are added later, the stripping would silently stop working. strip_channel_envelope() in the same module already handles this more robustly using <channel\b[^>]*>. Following the same approach here would make the implementation less tightly coupled to the current tag format.

…zers; harden tag regex

Address review feedback on PR vectorize-io#2487:

- Add read_transcript() tests covering the isMeta skip (the primary behavior
  of this PR, previously untested): isMeta turns dropped, non-meta kept,
  nested-format unwrapping, missing-file handling.
- Split strip_injected_context() back out of strip_memory_tags() so each stays
  single-responsibility; add sanitize_content() to compose both, and route the
  four call sites through it. Preserves prior behavior at the call sites.
- Make the tag-stripping regexes attribute-tolerant (<tag\b[^>]*>), matching
  strip_channel_envelope(), so a future <system-reminder priority="x"> or
  <hindsight_memories count="3"> still gets stripped instead of slipping through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hankel-ai

Copy link
Copy Markdown
Author

Thanks @nicolas-vivot — all three addressed in the latest push:

  1. read_transcript isMeta coverage — added TestReadTranscript in tests/test_hooks.py: isMeta turns are dropped, non-meta turns kept, nested {type, message:{role, content}} unwrapping, and missing-file handling.
  2. Separated the concernsstrip_injected_context() is no longer embedded inside strip_memory_tags(). Each is single-responsibility again, and a new sanitize_content() composes the two; the four call sites now go through it, so behavior is unchanged where both strips were wanted.
  3. Attribute-resilient regex — the injected-context and memory-tag patterns now use <tag\b[^>]*> (same style as strip_channel_envelope()), so a future <system-reminder priority="…"> or <hindsight_memories count="…"> still matches. Added tests for both.

@handnewb handnewb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a solid fix — Claude Code injects harness-level scaffolding (PreToolUse, system reminders) into the conversation that shouldn't be retained as memories. The filter in content.py is the right place. One suggestion: consider adding a config flag/env var to make this filtering opt-out, in case some users actually want the harness context retained (e.g., for debugging memory quality). Otherwise LGTM!

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Closing: superseded by Coding Agents, where injected and synthetic context is filtered structurally rather than by content matching.

The reader drops isMeta and isSidechain records outright, and a tag-structural rule strips the host's own transport wrappers from retained text — Hindsight's own injection blocks, codex's <hook_prompt>, Claude Code's <task-notification>, and <system-reminder>. Measuring real transcripts while fixing that turned up two leaks worth more than the originally reported ones (#3378, #3383).

npx @vectorize-io/hindsight-coding-agents install claude-code (docs). Thanks for pushing on this — retaining the harness's own words as the user's is exactly the kind of quiet memory-quality bug worth chasing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants