Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion examples/claude-code-memory-plugin/scripts/auto-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { maybeDetach, readHookStdin } from "./lib/async-writer.mjs";
import { readJsonState, writeJsonState } from "./lib/state.mjs";
import { getEffectivePeerId } from "./lib/workspace-peer.mjs";
import { sendSessionMessages } from "./shared/batch-send.mjs";
import { capTextParts } from "./lib/text-part-cap.mjs";

if (!isPluginEnabled()) {
process.stdout.write(JSON.stringify({ decision: "approve" }) + "\n");
Expand Down Expand Up @@ -422,6 +423,11 @@ function formatTurnsAsText(turns) {
// Strip plugin-injected blocks from text parts (tool parts pass through), and
// drop parts that become empty. Mirrors the old content-path stripInjectedBlocks
// + trim, but per text part so tool I/O is never collapsed.
// Text parts are additionally byte-capped and made well-formed (capTextParts)
// so an oversized or surrogate-broken part -- e.g. a slash-command/skill
// prompt expanded into the transcript as a user turn -- can never poison the
// server's vectordb write queue. See lib/text-part-cap.mjs for the failure
// mode.
function sanitizePartsForSend(parts) {
const out = [];
for (const p of parts || []) {
Expand All @@ -432,7 +438,7 @@ function sanitizePartsForSend(parts) {
out.push(p);
}
}
return out;
return capTextParts(out);
}

async function pushTurnsToOv(ovSessionId, turns, peerId = "") {
Expand Down
5 changes: 4 additions & 1 deletion examples/claude-code-memory-plugin/scripts/debug-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadConfig } from "./config.mjs";
import { capTextPartBytes } from "./lib/text-part-cap.mjs";

// ---------------------------------------------------------------------------
// ANSI helpers
Expand Down Expand Up @@ -239,7 +240,9 @@ async function captureToOpenViking(text) {
try {
// Step 2: Add message
console.log("\nStep 2: Adding message...");
const body = { role: "user", content: text };
// Same cap as the capture hooks -- debugging a session whose last user
// turn is oversized must not poison the very server being debugged.
const body = { role: "user", content: capTextPartBytes(text) };
if (cfg.peerId) body.peer_id = cfg.peerId;
const addResult = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
Expand Down
70 changes: 70 additions & 0 deletions examples/claude-code-memory-plugin/scripts/lib/text-part-cap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Byte caps and well-formedness sanitation for outgoing text parts.
*
* The local vectordb's bytes_row format caps string fields at 65535 UTF-8
* bytes (#2967), and the per-record `fields` JSON blob aggregates several
* scalars on top of that (#3593, still open). An oversized text part -- e.g.
* a slash-command/skill prompt expanded into the transcript as a user turn,
* observed at 248KB in the wild -- therefore produces an addMessage the
* server accepts but whose derived record can never serialize. The queue
* consumer re-enqueues the failed task forever, and the retry loop can
* starve the event loop until the server stops answering.
*
* Unpaired surrogates are the second, size-independent member of the same
* poison class: JSON transcripts can legally carry a lone `\ud83d` escape,
* and the server's Python utf-8 encode raises "surrogates not allowed" on
* it. Every string returned from this module is well-formed UTF-16 (safe to
* utf-8 encode) and byte-capped.
*/
export const TEXT_PART_MAX_BYTES = 16384;

// Per-message budget across all text parts: the 65535-byte record field must
// also hold sibling scalars and JSON overhead, so several maxed parts cannot
// be allowed to fill it (4 x 16384 already exceeds it).
export const TEXT_TOTAL_MAX_BYTES = 49152;

// The truncation marker must fit INSIDE the cap so callers can trust that a
// returned string never exceeds the requested byte budget (for budgets that
// can hold it -- tiny budgets degrade to marker-only, never to a hang).
const MARKER_RESERVE_BYTES = 64;

// Replace unpaired surrogates with U+FFFD (same byte count as the lone
// surrogate's WTF-8 encoding, so byte budgets are unaffected).
function toWellFormedText(s) {
if (typeof s.toWellFormed === "function") return s.toWellFormed();
return s.replace(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
"�",
);
}

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 +41 to +69
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
TEXT_PART_MAX_BYTES,
TEXT_TOTAL_MAX_BYTES,
capTextPartBytes,
capTextParts,
} from "./text-part-cap.mjs";

const MARKER_RE = /\n\.\.\. \[truncated, \d+ more chars\]$/;

// A lone surrogate does not survive a UTF-8 round-trip (it becomes U+FFFD),
// so round-trip equality proves the string is well-formed -- the same
// property the server's utf-8 encode requires.
function isUtf8RoundTrippable(s) {
return Buffer.from(s, "utf8").toString("utf8") === s;
}

test("in-bounds well-formed text passes through unchanged", () => {
assert.equal(capTextPartBytes("hello world"), "hello world");
const exact = "a".repeat(TEXT_PART_MAX_BYTES);
assert.equal(capTextPartBytes(exact), exact);
});

test("in-bounds text containing a lone surrogate is made well-formed", () => {
const dirty = "abc\ud83d def";
const cleaned = capTextPartBytes(dirty);
assert.notEqual(cleaned, dirty);
assert.ok(isUtf8RoundTrippable(cleaned));
assert.match(cleaned, /^abc.* def$/);
});

test("oversized ascii is capped under the limit with a marker", () => {
const capped = capTextPartBytes("x".repeat(254000));
assert.ok(Buffer.byteLength(capped, "utf8") <= TEXT_PART_MAX_BYTES);
assert.match(capped, MARKER_RE);
});

test("oversized multi-byte text is capped under the limit and well-formed", () => {
const capped = capTextPartBytes("日本語テスト".repeat(20000));
assert.ok(Buffer.byteLength(capped, "utf8") <= TEXT_PART_MAX_BYTES);
assert.match(capped, MARKER_RE);
assert.ok(isUtf8RoundTrippable(capped));
});

test("truncation never leaves an unpaired surrogate at any pair alignment", () => {
// Sweep 0-3 three-byte chars before an emoji run so the slice boundary
// lands on every possible surrogate-pair alignment.
for (let k = 0; k <= 3; k++) {
const input = "見".repeat(k) + "\u{1F600}".repeat(20000);
const capped = capTextPartBytes(input);
assert.ok(
isUtf8RoundTrippable(capped),
`k=${k}: capped output contains an unpaired surrogate`,
);
assert.ok(Buffer.byteLength(capped, "utf8") <= TEXT_PART_MAX_BYTES, `k=${k}: over cap`);
}
});

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);
});
Comment on lines +61 to +65

test("capTextParts enforces the per-message total budget across parts", () => {
const parts = [];
for (let i = 0; i < 6; i++) parts.push({ type: "text", text: "y".repeat(20000) });
parts.push({ type: "tool", tool_name: "Read", tool_input: { big: "z".repeat(30000) } });

const capped = capTextParts(parts);
assert.equal(capped.length, parts.length);
assert.strictEqual(capped[6], parts[6], "non-text parts pass through untouched");

let totalTextBytes = 0;
for (const p of capped) {
if (p.type === "text") totalTextBytes += Buffer.byteLength(p.text, "utf8");
}
// Total stays within the budget plus small marker-only tails for parts
// capped after the budget ran out.
assert.ok(
totalTextBytes <= TEXT_TOTAL_MAX_BYTES + parts.length * 64,
`total text bytes ${totalTextBytes} exceeds budget`,
);
});

test("capTextParts leaves in-bounds messages untouched", () => {
const parts = [
{ type: "text", text: "short note" },
{ type: "tool", tool_name: "Bash", tool_output: "ok" },
];
const capped = capTextParts(parts);
assert.strictEqual(capped[0], parts[0]);
assert.strictEqual(capped[1], parts[1]);
});
8 changes: 6 additions & 2 deletions examples/claude-code-memory-plugin/scripts/subagent-stop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { maybeDetach, readHookStdin } from "./lib/async-writer.mjs";
import { getEffectivePeerId } from "./lib/workspace-peer.mjs";
import { sendSessionMessages } from "./shared/batch-send.mjs";
import { capTextParts } from "./lib/text-part-cap.mjs";

if (!isPluginEnabled()) {
process.stdout.write(JSON.stringify({ decision: "approve" }) + "\n");
Expand Down Expand Up @@ -240,8 +241,11 @@ async function pushTurns(ovSessionId, turns, { peerId = null, enqueueOnly = fals
for (const turn of turns) {
// Send structured parts: tool calls/results are dedicated `tool` parts, not
// inlined into content, so the server can process them separately.
const parts = (turn.parts || []).filter(
(p) => p.type !== "text" || (p.text && p.text.trim()),
// A subagent's first user turn is the parent's Task prompt, which is
// routinely large (skill expansions, injected context) -- cap text parts
// for the same reason auto-capture does (see lib/text-part-cap.mjs).
const parts = capTextParts(
(turn.parts || []).filter((p) => p.type !== "text" || (p.text && p.text.trim())),
);
if (parts.length === 0) continue;
const payload = { role: turn.role, parts };
Expand Down
Loading