Skip to content

Claude Code Stop hook writes the entire raw transcript into memory when the turn exceeds 128 KB (argv MAX_ARG_STRLEN) #664

Description

@NizarAlKabbani

Description

plugins/claude-code/hooks/stop.sh passes the summarizer prompt to claude -p as a
single command-line argument. Linux caps one argument at MAX_ARG_STRLEN, 32 pages,
131072 bytes on a 4 KiB-page system. When the parsed turn exceeds that, execve
fails with E2BIG and claude never starts. The failure is discarded by
2>/dev/null || true, so SUMMARY ends up empty, and the fallback then writes the
entire raw payload into the user's memory journal:

# If claude is not available or returned empty, fall back to raw parsed output
if [ -z "$SUMMARY" ]; then
  SUMMARY="$PARSED"
fi

The result is that the worst case, a turn too large to summarize, is also the one
case where the whole thing gets stored verbatim.

This is easy to hit in normal use. Loading a Claude Code skill injects that skill's
full documentation into the turn as one user text block. In our case the claude-api
skill contributed a single 828 KB block, making the parsed turn 831 KB
(~213,000 tokens)
.

plugins/claude-code/hooks/parse-transcript.sh applies no size cap of any kind.

What we expected

A turn too large to summarize is skipped, or truncated and then summarized. Either
way nothing unsummarized is written to memory, and the failure is visible somewhere.

What actually happened

One turn produced an 831 KB journal entry containing verbatim API reference
documentation. Across 725 stored entries in one project, 3 were raw dumps of this
kind, totalling 1,091,103 bytes, or 49.6% of the entire memory directory. A
normal healthy summary in the same directory is under 2.7 KB; the largest is 2,663 B.

Downstream effects:

  • Every re-index re-embeds the dump. With the local ONNX provider this pinned ~29 of
    32 cores for minutes at a time, at a load average of 60, and burned 84 minutes of
    CPU on 3 MB of notes.
  • The dump is fed back as recalled memory at session start, so it consumes context in
    every subsequent session and pollutes search results.
  • The journals are usually in git, so the dumps end up in history too.

Steps to reproduce

  1. Any project with the Claude Code plugin active and embedding.provider set
    (we used onnx).

  2. Start a session and do something that loads a large skill into the turn, or craft
    a transcript whose last turn parses to more than 131072 bytes. A direct way:

    bash plugins/claude-code/hooks/parse-transcript.sh "$TRANSCRIPT" | wc -c   # > 131072
  3. Let the Stop hook run.

  4. Observe the journal file for today under .memsearch/memory/. It contains an entry
    whose body begins === Transcript of a conversation between User and Claude Code ===
    followed by the whole turn.

Minimal confirmation of the mechanism alone, no plugin needed:

$ /bin/true "$(head -c 131071 /dev/zero | tr '\0' 'x')"; echo $?
0
$ /bin/true "$(head -c 131072 /dev/zero | tr '\0' 'x')"; echo $?
bash: /bin/true: Argument list too long
126

We also reproduced it end to end by feeding the unpatched hook a crafted 1.5 MB turn:
the resulting journal file was 1,516,261 bytes, the hook's stdout was {}, and its
stderr was completely empty.

The regression

Introduced by commit 018a85f ("Fix native summary hook prompt handling", PR #563,
2026-06-01), which replaced a stdin pipe with an argv string:

-  SUMMARY=$(printf '%s' "$PARSED" | MEMSEARCH_NO_WATCH=1 CLAUDECODE= claude -p \
+  LLM_PROMPT="${SYSTEM_PROMPT}
+
+Transcript:
+${PARSED}"
+  SUMMARY=$(MEMSEARCH_NO_WATCH=1 CLAUDECODE= claude -p \
     --strict-mcp-config \
-    --system-prompt "$SYSTEM_PROMPT" \
+    "$LLM_PROMPT" \
     2>/dev/null || true)

A pipe has no size limit; an argument does. No cap was added to compensate. Still
present on main at b734a14.

The fix already exists in this repo, in the sibling plugin

plugins/codex/hooks/stop.sh uses the same argv pattern but bounds its input:

MAX_CONTENT_CHARS="${MEMSEARCH_SUMMARY_MAX_CHARS:-8000}"
if [ ${#CONTENT} -gt "$MAX_CONTENT_CHARS" ]; then
  CONTENT="$(printf '%s' "$CONTENT" | _truncate_chars "$MAX_CONTENT_CHARS")...(truncated)"
fi

It also bounds its empty-summary fallback to the last 800 characters rather than the
whole turn. MEMSEARCH_SUMMARY_MAX_CHARS appears exactly once in the codebase. The
Claude Code plugin never received the same treatment.

Suggested fix

Any one of these closes the hole; we applied all three locally:

  1. Deliver the prompt on stdin. claude -p with no positional prompt reads the
    prompt verbatim from stdin, adding no wrapper text (verified on Claude Code 2.1.220),
    and --system-prompt still works alongside it if wanted. Pipes are not subject to
    MAX_ARG_STRLEN. Verified: 200,033 bytes as argv fails with exit 126; the same bytes
    on stdin exit 0 and summarize normally.
  2. Cap PARSED, as the Codex plugin already does. Placing the cap before the
    branch protects the memsearch summarize path too, which pipes via stdin and so
    never hit E2BIG, but currently has no cap at all and would ship the full payload
    to a remote provider. For reference, across our 80 most recent transcripts the
    parsed turn was p50 5,493 B, p95 6,922 B, max 19,487 B, so a cap in the tens of KB
    is inert for real traffic.
  3. Never fall back to $PARSED. A marker line keeps the entry's anchor comment,
    so the turn stays recoverable through progressive disclosure without storing it.
    Related: Stop hook writes Anthropic API rate-limit error string as memory summary content #527 is the same fallback writing an API rate-limit error string into
    memory as a summary.

Also worth surfacing the failure somewhere. 2>/dev/null here made a call that never
ran look identical to one that returned nothing, which is why this went unnoticed for
weeks. Precedent: #546 treated the same stderr suppression as a bug on Windows.

Documentation corrections

docs/platforms/claude-code/how-it-works.md, live at
https://zilliztech.github.io/memsearch/platforms/claude-code/how-it-works/, is
inaccurate on two points since 018a85f:

  • Step 4 says the extracted turn is "piped to claude -p --model haiku with a
    system prompt". It has been passed as argv, not piped, since June.
  • The file table describes parse-transcript.sh as a "Deterministic JSONL-to-text
    parser with truncation". It performs no truncation.

Environment

  • memsearch: 0.4.17 (pipx), plugin 0.4.17, commit b734a142ea017657959dfe918ecfe9e1a16c6654
  • Claude Code: 2.1.220
  • OS: Fedora Linux 44 (KDE Plasma), kernel 7.1.5-201.fc44.x86_64
  • Python: 3.14.6
  • getconf PAGESIZE = 4096, so MAX_ARG_STRLEN = 131072; ARG_MAX = 4194304
  • embedding.provider: onnx (gpahal/bge-m3-onnx-int8)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions