From c6ed755835e329f3314e74042e9e70ae1b712f60 Mon Sep 17 00:00:00 2001 From: Akhilesh Arora Date: Tue, 2 Jun 2026 15:08:59 +0200 Subject: [PATCH] fix(recall): truncate recalled context by code point, not code unit (#109) truncateRecallLine capped lines with line.length and line.slice, which count and index by UTF-16 code unit. When recall.maxCharsPerMemory or recall.maxTotalRecallChars is set and the cut lands between the halves of a surrogate pair, the line keeps a lone surrogate that becomes U+FFFD once UTF-8 encoded for the request, corrupting any non-BMP character (emoji, CJK Ext-B) in the injected context. Slice on Array.from(line) code points instead. Both recall call sites route through this function. Same class as the sanitizeText fix in #31, reintroduced by the #71 budget path. Signed-off-by: Akhilesh Arora --- src/core/hooks/auto-recall.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index e5760f0..036c1cd 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -776,11 +776,15 @@ function normalizeBudgetLimit(value: number | undefined): number | undefined { } function truncateRecallLine(line: string, maxChars: number): string { - if (line.length <= maxChars) return line; + // Count and slice by code point, not UTF-16 code unit, so a cut never lands + // between the halves of a surrogate pair (which would corrupt a non-BMP + // character to U+FFFD when the line is UTF-8 encoded for the request). + const cps = Array.from(line); + if (cps.length <= maxChars) return line; if (maxChars <= RECALL_TRUNCATION_SUFFIX.length) { - return line.slice(0, maxChars); + return cps.slice(0, maxChars).join(""); } - return `${line.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).trimEnd()}${RECALL_TRUNCATION_SUFFIX}`; + return `${cps.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).join("").trimEnd()}${RECALL_TRUNCATION_SUFFIX}`; } /**