Skip to content

fix(normalize): strip Claude Code <local-command-*> tags and ANSI escapes - #1334

Open
lucagentile wants to merge 2 commits into
MemPalace:developfrom
lucagentile:fix/normalize-claude-code-noise
Open

fix(normalize): strip Claude Code <local-command-*> tags and ANSI escapes#1334
lucagentile wants to merge 2 commits into
MemPalace:developfrom
lucagentile:fix/normalize-claude-code-noise

Conversation

@lucagentile

Copy link
Copy Markdown

What does this PR do?

Fixes #1333.

Extends mempalace/normalize.py:strip_noise() to remove three Claude Code transcript artifacts that currently survive into stored drawers:

  1. The remainder of the slash-command tag envelope — <local-command-caveat>, <command-args>, <local-command-stdout> — that Claude Code injects on every slash-command invocation. strip_noise() previously covered only <command-name> and <command-message>; the other three slipped through, leaving drawers with bare <command-args></command-args> remnants and whole <local-command-stdout> payloads.
  2. ANSI escape sequences (CSI + OSC) captured verbatim from Bash tool_result content. These bloat embeddings (each escape is several BPE tokens of garbage) and garble semantic search when colored CLI output (e.g. /context, /help, anything piped through a modern tool) lands in a transcript.

Code change (mempalace/normalize.py)

  • Adds command-args, local-command-caveat, local-command-stdout to _NOISE_TAGS. They flow through the existing _tag_pattern builder, so they inherit the same line-anchoring + blank-line-bounded body guarantee — a stray unclosed tag in one message still cannot eat content from neighbouring messages.
  • Adds two pre-compiled regexes:
    • _ANSI_CSI_RE matches the standard CSI shape ESC [ params interm final covering SGR colors, cursor moves, screen ops.
    • _ANSI_OSC_RE matches ESC ] ... BEL or ESC ] ... ESC \ for terminal title (OSC 0/2) and hyperlinks (OSC 8).
    • Both are anchored on the literal ESC byte (\x1b), so user prose that mentions [1m] or ]8;; by name never matches.
  • Applies both ANSI sweeps inside strip_noise() after tag/line/chrome stripping and before the existing blank-line collapse. Tag-wrapped ANSI is already gone via _NOISE_TAG_PATTERNS, so the ANSI sweep only has to cover standalone ANSI in tool output — no double-strip path needed.

Test change (tests/test_normalize.py)

Eleven new test cases, all under the existing TestStripNoiseRemovesSystemChrome and TestStripNoisePreservesUserContent classes to keep the verbatim-safety boundary discipline visible:

  • test_strips_each_known_noise_tag extended to iterate the new tags too.
  • test_strips_full_claude_code_slash_command_envelope — integration: real five-tag envelope stripped, real prose after it preserved.
  • test_strips_empty_command_args_pair — empty-body shape Claude Code emits when a command takes no args.
  • test_strips_ansi_color_codes, test_strips_ansi_truecolor_codes, test_strips_ansi_cursor_moves — CSI coverage including 24-bit truecolor sequences from the /context renderer.
  • test_strips_ansi_osc_terminal_title, test_strips_ansi_osc_hyperlink — OSC coverage with both terminator forms (BEL, ST).
  • test_strips_ansi_inside_noise_tag_with_tag — proves no double-strip is needed; tag-wrapped ANSI exits with its tag.
  • test_user_documents_ansi_escape_by_name — user prose mentioning [1m] / [22m] literally (no ESC byte) is preserved verbatim.
  • test_user_mentions_local_command_inline — inline <local-command-caveat> mention in user prose stays intact (line-anchoring guarantee).

How to test

python -m pytest tests/test_normalize.py -v
python -m pytest tests/ -v --ignore=tests/benchmarks
ruff check mempalace/normalize.py tests/test_normalize.py
ruff format --check mempalace/normalize.py

Locally: 1499 passed, 1 skipped on the full suite (no new skips introduced); 127/127 in test_normalize.py (was 117); ruff lint clean; mempalace/normalize.py is format-clean. The pre-existing ruff format --check drift in tests/test_normalize.py is left untouched — out of scope for this fix.

Checklist

  • Tests pass (python -m pytest tests/ -v --ignore=tests/benchmarks)
  • No hardcoded paths
  • Linter passes (ruff check .)

…apes

Claude Code wraps every slash-command invocation in a five-tag envelope
(<local-command-caveat>, <command-name>, <command-message>, <command-args>,
<local-command-stdout>). strip_noise() previously covered only command-name
and command-message; the other three survived into stored drawers as tag
remnants like "<command-args></command-args>" or whole stdout payloads.

Bash tool_result content captured by Claude Code preserves ANSI color codes
verbatim. These pass through strip_noise() untouched, bloating embeddings
(each escape is several BPE tokens) and garbling semantic search.

Extend _NOISE_TAGS with the missing three, and add CSI + OSC ANSI strippers
applied after tag removal in strip_noise(). Each ANSI pattern is anchored on
the literal ESC byte so user prose that mentions e.g. "[1m]" by name stays
intact — verbatim-safety preserved per the existing design.

Tests: add coverage for each new tag, the full slash-command envelope, the
empty <command-args></command-args> shape, ANSI CSI / truecolor / cursor /
OSC-title / OSC-hyperlink sequences, ANSI inside a noise tag (no double-strip
needed), and two preservation tests for user prose that documents these
constructs by name.

Closes MemPalace#1333
@Qodo-Free-For-OSS

Copy link
Copy Markdown

Hi, This PR changes strip_noise() output (new Claude Code tags removed + ANSI escapes stripped) but does not bump NORMALIZE_VERSION, so file_already_mined() will keep treating existing drawers as current and they will not be rebuilt.

Severity: action required | Category: correctness

How to fix: Bump NORMALIZE_VERSION to 3

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

Normalization behavior changed (extra tag stripping + ANSI stripping) but NORMALIZE_VERSION was not bumped. This prevents already-mined drawers from being rebuilt, leaving existing stored transcripts polluted.

Issue Context

file_already_mined() uses normalize_version as a schema gate. Convo mining typically calls file_already_mined() without mtime checking, so version bumps are required to invalidate previously-mined conversation exports.

Fix Focus Areas

  • mempalace/palace.py[42-50]
  • mempalace/palace.py[395-416]

What to change

  • Increment NORMALIZE_VERSION (e.g., 2 → 3).
  • Update/extend the inline version history comment to mention this PR’s changes (Claude Code tag envelope + ANSI stripping).

Qodo code review - free for open-source.

zhapostolski added a commit to zhapostolski/mempalace that referenced this pull request May 4, 2026
…hromadb version mismatch

When an HNSW segment has a mtime gap > 7200s (2 h), quarantine it
regardless of the metadata sniff-test result. ChromaDB flush-lag is
measured in seconds; a 2+ hour gap means the segment was written by a
different process/version. Observed: chromadb 0.6.x segfaults loading
segments whose metadata passes the pickle format check but whose binary
layout is incompatible with the current runtime.

The original directory is renamed (not deleted), so manual recovery
is still possible if the heuristic misfires.

Includes cherry-pick of MemPalace#1334:
fix(normalize): strip Claude Code local-command tags and ANSI escapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous-mined drawers stamped with v2 won't be re-mined unless the
schema version advances; the new tag/ANSI sweeps need this gate to
flip so existing transcripts get rebuilt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@igorls igorls added bug Something isn't working area/mining File and conversation mining labels May 6, 2026
jrzmurray added a commit to jrzmurray/mempalace that referenced this pull request Aug 13, 2026
…ion-safe

Local integration of the fix for MemPalace#1333 (strip slash-command envelope
remnants and ANSI escape sequences from Bash-tool output), informed by
PR MemPalace#1909 upstream. Not opened as a competing PR -- MemPalace#1909 is already an
active, close-to-mergeable submission for the same issue; this is for
local use, with two things MemPalace#1909 doesn't have:

1. The tag-boundary fix MemPalace#1909's own review comment flagged: a bare `\b`
   after the tag name incorrectly matches the boundary between "s" and
   "-" inside a longer tag name (e.g. <command-args-extended>), causing
   <command-args>'s lazy body to stop early and misparse unrelated
   content. Fixed with a `[\s>]` lookahead instead.

2. A third ANSI pattern (_ANSI_SIMPLE_RE) for the "Fe minus CSI/OSC" and
   "Fs" escape sequence classes -- single ESC + one byte, no payload.
   Neither MemPalace#1334 nor MemPalace#1909 cover this; both only strip CSI and OSC. The
   motivating case: watch-mode dev tools (tsx watch, nodemon) emit ESC c
   (RIS, full terminal reset) before reprinting output on a file-change
   restart, confirmed by reproducing it directly against the real API
   dev server. A backgrounded process's redirected stdout/stderr can
   carry this into a captured Bash tool_result verbatim.

Also fixes a real truncation-safety bug found during review of the CSI
pattern (both MemPalace#1909's and an earlier draft of this one shared it): with
`[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]` (both middle groups zero-or-more),
a genuinely truncated `ESC[` immediately followed by ordinary real text
is indistinguishable from a valid empty-parameter CSI sequence, since
the final-byte class (0x40-0x7E) covers nearly every letter. Confirmed
empirically: `"before \x1b[this has words after"` silently became
`"before his has words after"` -- the leading "t" of "this" eaten as a
false CSI terminator. Real captured tool output does get truncated
mid-write (a background process's redirected stdout racing its
reader), so this isn't a contrived edge case.

Fixed by requiring >=1 parameter byte (`[0-9;]+`, not `*`) and dropping
the intermediate-byte class entirely -- real SGR/cursor codes are
overwhelmingly digits + `;`, and genuine use of ECMA-48 intermediate
bytes in ordinary terminal color/cursor output is vanishingly rare. The
accepted tradeoff: a truly bare, zero-param CSI (e.g. `ESC[H`,
cursor-home with no row/col) is no longer stripped -- left as harmless
unstripped noise rather than risking real-word corruption.

The OSC pattern has a narrower, documented residual risk in the same
family (a truncated OSC's greedy payload scan can still treat a later,
genuine bare BEL in real prose as a false terminator) -- not closed,
since BEL essentially never appears in real captured text unlike the
letters/punctuation that made the CSI case common. Covered by an
explicit regression test that documents current behavior so any future
change to that tradeoff is a deliberate, visible diff.

Test suite substantially expanded given this ships to an external repo:
real-world CSI/OSC/simple-escape shapes, the truncation-safety
regressions above (including the accepted OSC/simple-pattern residual
risks), boundary conditions (empty string, escape at start/end, escapes
only, unterminated at EOF), Unicode interaction (multi-byte characters
and combining sequences adjacent to escapes never split), idempotency
(stripping twice equals stripping once), ReDoS/performance checks
against adversarial input sizes, and hypothesis property-based fuzzing
for the core invariants (escape-free text is never touched; a
well-formed SGR pair never touches its surrounding text; a truncated
CSI never eats more than its own bytes). 26 tests -> 69 tests in this
area.

Full suite: 3268 passed, 20 skipped -- no regressions (same 2
pre-existing unrelated failures as always, tracked separately).
ruff check / ruff format -- clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/mining File and conversation mining bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude Code transcript noise: <local-command-*> tags and ANSI escapes survive strip_noise()

3 participants