Skip to content

fix(plugins): byte-cap and well-form text parts in claude-code capture paths - #4135

Open
michaeltarleton wants to merge 1 commit into
volcengine:mainfrom
michaeltarleton:fix/claude-code-capture-text-part-byte-cap
Open

fix(plugins): byte-cap and well-form text parts in claude-code capture paths#4135
michaeltarleton wants to merge 1 commit into
volcengine:mainfrom
michaeltarleton:fix/claude-code-capture-text-part-byte-cap

Conversation

@michaeltarleton

Copy link
Copy Markdown
Contributor

Description

The claude-code memory plugin ships text parts with no size bound on any of its capture paths:

  • auto-capture.mjs sanitizePartsForSend sends user/assistant text parts whole. Claude Code expands slash-command/skill prompt files into the transcript as user turns, so a single turn can carry the full text of a large prompt file. Observed in the wild: a 248KB addMessage payload from one skill invocation.
  • subagent-stop.mjs pushTurns has the same gap, and a subagent's first user turn is the parent's Task prompt -- routinely large for the same reasons.
  • debug-capture.mjs POSTs the transcript's combined text uncapped -- so debugging the incident above would poison the very server being debugged.

Tool output is already bounded (server-side externalization plus the pathological-payload cap); plain text parts were not.

What happens server-side

The local vectordb's bytes_row format caps string fields at 65535 UTF-8 bytes (#2967). A record derived from an oversized message fails with:

RuntimeError: string field 'fields' exceeds 65535 bytes

The queue consumer re-enqueues the failed task indefinitely (observed retry cadence ~40 minutes over two days). In our case the retry loop eventually starved the event loop until the HTTP listener stopped answering entirely -- process alive, port unbound, health checks dead -- requiring a manual kill. Same failure mode as #2967, reached via addMessage instead of resource upload. #3593 (open) truncates the fields JSON blob server-side; this client-side cap complements it as defense in depth and protects servers running any released version today.

There is a second, size-independent member of the same poison class: JSON transcripts can legally carry a lone surrogate escape (\ud83d), and the server's Python utf-8 encode raises surrogates not allowed on it -- another record that can never serialize.

Fix

New scripts/lib/text-part-cap.mjs (placed in the plugin's own lib/, not the generated shared/), applied by all three paths above:

  • capTextParts(parts) -- the single policy point both capture hooks call: caps every text part and passes non-text parts through, enforcing
    • a 16384-byte per-part cap, and
    • a 49152-byte per-message total across text parts, so several capped parts plus JSON overhead and sibling scalars in the fields blob still fit one 65535-byte record.
  • Well-formedness guarantee: every returned string is well-formed UTF-16 (safe to utf-8 encode). Inputs already containing lone surrogates are repaired (toWellFormed(), with a regex fallback for older Node), and truncation re-sanitizes after slicing since slice() cuts by UTF-16 code units and can split a surrogate pair.
  • Truncation marker fits inside the cap (reserved bytes) and matches the \n... [truncated, N more chars] shape the other truncation helpers use; tiny byte budgets degrade to marker-only output rather than looping.
  • Zero overhead for in-bounds, well-formed parts.

Testing

  • scripts/lib/text-part-cap.test.mjs (node:test), 8/8 pass: in-bounds passthrough; in-bounds lone-surrogate repair; oversized ASCII and multi-byte inputs capped under the limit with marker; a surrogate-alignment sweep proving no unpaired surrogate survives truncation at any pair boundary (checked via UTF-8 round-trip equality -- the property the server's encode requires); tiny-budget termination; per-message total budget across many parts; untouched pass-through for in-bounds messages.
  • node --check passes on all modified scripts.
  • The pre-existing auto-capture.test.mjs suite fails identically on unmodified main in this Windows environment (temp-path ENOENT), so it could not be used as a regression signal here; the new unit tests cover the added behavior directly.

Known remaining gaps (intentionally out of scope)

  • tool_input parts are sent verbatim and, unlike tool_output, are not externalized server-side -- a very large tool input (e.g. a Task prompt inside a tool_use block, or a Write carrying a large file body) can still overflow the derived record. Left out because truncating structured tool input safely needs a server-contract decision; happy to follow up.
  • Payloads enqueued to the pending dir before this fix replay verbatim, so a pre-existing oversized entry can still poison the queue once after upgrade.
  • The durable server-side fixes remain fix(vectordb): truncate oversized fields JSON blob before bytes_row write #3593 (truncate the fields blob) plus a retry limit / dead-letter for records that fail serialization non-transiently -- without one, any producer that bypasses this plugin (SDKs, other harness plugins, bots) can still wedge the queue consumer.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the Claude Code memory plugin’s capture flows by byte-capping outgoing text parts and ensuring they are well‑formed UTF‑16, preventing oversized or surrogate-broken transcripts from poisoning the server’s vectordb write pipeline.

Changes:

  • Add a shared capTextParts / capTextPartBytes utility to cap text parts by UTF‑8 bytes and repair lone surrogates.
  • Apply the cap/sanitization in auto-capture, subagent-stop, and debug-capture send paths.
  • Add node:test unit coverage for byte caps and surrogate well‑formedness.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
examples/claude-code-memory-plugin/scripts/lib/text-part-cap.mjs New shared text-part byte cap + UTF‑16 well‑formedness sanitizer used by capture paths.
examples/claude-code-memory-plugin/scripts/lib/text-part-cap.test.mjs Unit tests validating cap behavior (size + surrogate repair).
examples/claude-code-memory-plugin/scripts/auto-capture.mjs Routes structured parts through capTextParts before sending.
examples/claude-code-memory-plugin/scripts/subagent-stop.mjs Caps structured parts for subagent turn forwarding.
examples/claude-code-memory-plugin/scripts/debug-capture.mjs Caps the debug “addMessage” content to avoid poisoning the debug target server.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +41 to +69
export function capTextPartBytes(t, maxBytes = TEXT_PART_MAX_BYTES) {
const wellFormed = toWellFormedText(t);
if (Buffer.byteLength(wellFormed, "utf8") <= maxBytes) return wellFormed;
const budget = Math.max(0, maxBytes - MARKER_RESERVE_BYTES);
let s = wellFormed.slice(0, budget);
while (Buffer.byteLength(s, "utf8") > budget) {
s = s.slice(0, Math.floor(s.length * 0.9));
}
// slice() cuts by UTF-16 code units and can split a surrogate pair even in
// well-formed input; re-sanitize so the truncated string stays encodable.
s = toWellFormedText(s);
return s + `\n... [truncated, ${t.length - s.length} more chars]`;
}

// Cap every text part of a message, enforcing both the per-part cap and the
// per-message total. Non-text parts pass through untouched. This is the one
// policy point both capture hooks share -- keep call sites thin so the
// policy cannot drift between them.
export function capTextParts(parts, totalBudget = TEXT_TOTAL_MAX_BYTES) {
let remaining = totalBudget;
return parts.map((p) => {
if (p.type !== "text" || typeof p.text !== "string") return p;
const capped = capTextPartBytes(
p.text,
Math.max(0, Math.min(TEXT_PART_MAX_BYTES, remaining)),
);
remaining -= Buffer.byteLength(capped, "utf8");
return capped === p.text ? p : { ...p, text: capped };
});
Comment on lines +61 to +65
test("tiny maxBytes terminates and degrades to marker-only output", () => {
const capped = capTextPartBytes("x".repeat(100000), 10);
assert.ok(Buffer.byteLength(capped, "utf8") < 100);
assert.match(capped, MARKER_RE);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants