Files
gbrain/src/core/text-safe.ts
ecd6ae8772 v0.42.40.0 fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011) (#2031)
* fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011)

excerpt() in link-extraction.ts sliced the link-context window by raw UTF-16
index, so a boundary landing inside a non-BMP char (emoji, math, CJK) left an
unpaired surrogate half in `context`. Serialized to JSONB for the
jsonb_to_recordset batch insert, Postgres rejects it at the ::jsonb cast and
aborts the whole batch — wedging `extract --stale` because the staleness
bookmark only advances on a clean finish.

- text-safe.ts: new ensureWellFormed() (Bun isWellFormed/toWellFormed) — one
  shared surrogate-cleaning primitive.
- link-extraction.ts: excerpt() well-forms the slice (root-cause fix).
- batch-rows.ts: new sanitizeForJsonb() = ensureWellFormed(stripNul(s)) applied
  to every free-text body field (link context; timeline summary/detail/source;
  take claim/source). Identity/security fields stay un-sanitized and fail closed.
- postgres-engine.ts + pglite-engine.ts: scalar addLink + addTimelineEntry use
  sanitizeForJsonb too, matching the batch path on both engines.
- brainstorm/orchestrator.ts: consolidate hand-rolled sanitizeUnicode onto
  ensureWellFormed (also fixes consecutive-lone-surrogate mishandling).

Tests: ensureWellFormed unit cases (incl. consecutive lone surrogates), an
excerpt window-split regression, PGLite + Postgres-e2e surrogate cases across
all free-text fields and scalar paths, and fail-closed identity-field tests
proving sanitization was NOT extended to slugs/holders.

* v0.42.39.0 chore: bump version and changelog (#2011)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.42.39.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.40.0 chore: re-slot release version (was 0.42.39.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: fix env-mutation isolation violations in retrieval-reflex tests

check:test-isolation (rule R1) flags direct process.env mutation in
non-serial test files — bun's parallel runner loads multiple files into
one process, so a leaked GBRAIN_RETRIEVAL_REFLEX flips reflex behavior
in unrelated tests. Both files landed via the #2019 merge; convert the
beforeEach/afterEach env juggling to the canonical withEnv() wrapper,
which restores the prior value via try/finally even on throw.

Fixes the failing `verify` CI check on #2031 (and the `test-status`
aggregate that inherits it). All 30 verify checks green locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:00:36 -07:00

108 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UTF-16-surrogate-safe text helpers.
*
* Shared between:
* - `src/core/eval-contradictions/judge.ts` — `truncateUtf8` truncates
* contradiction-judge prompt inputs to a per-pair char budget without
* splitting emoji / non-BMP CJK / mathematical alphanumerics.
* - `src/core/cycle/synthesize.ts` — `safeSplitIndex` is the tier-3
* hard-split fallback in the dream-cycle chunker. Preserves the D9
* stable-chunk-identity invariant by refusing to orphan a high surrogate.
*
* Two consumers, two natural shapes:
* - `truncateUtf8(text, maxChars) -> string` returns the sliced text.
* - `safeSplitIndex(text, maxChars) -> number` returns the boundary index
* without allocating the sliced string (cheaper chunker hot path).
*
* Both functions cover the same three surrogate cases. The agent-authored
* `safeSliceEnd` from PRs #1378-#1382 handled only case 1; the AT-low
* surrogate case (3) silently bit when a chunk boundary landed one
* position inside an emoji.
*
* UTF-16 surrogate ranges:
* high surrogate: U+D800..U+DBFF
* low surrogate: U+DC00..U+DFFF
*/
/**
* Make a string well-formed UTF-16: replace any unpaired surrogate half (lone
* high U+D800U+DBFF / lone low U+DC00U+DFFF) with U+FFFD, preserving valid
* pairs untouched. A lone surrogate is rejected by Postgres inside a `::jsonb`
* cast and aborts the WHOLE batch (#2011 — `extract --stale` died at ~1,550
* pages because `excerpt()` raw-sliced a window boundary through an emoji); it
* also crashes some JSON transports (the brainstorm cross-prompt path).
*
* Uses the Bun/JSC built-in (ES2024). `isWellFormed()` is the cheap guard that
* avoids the `toWellFormed()` allocation when the string is already clean — the
* common case. Prefer this over hand-rolled surrogate regexes: the two-pass
* lookbehind regex mishandles CONSECUTIVE lone low surrogates
* (`"\uDE80\uDE80"` → `"\uDE80"`, still malformed), whereas the built-in
* handles every case (`→ ""`).
*/
export function ensureWellFormed(s: string): string {
return s.isWellFormed() ? s : s.toWellFormed();
}
function isHighSurrogate(code: number): boolean {
return code >= 0xd800 && code <= 0xdbff;
}
function isLowSurrogate(code: number): boolean {
return code >= 0xdc00 && code <= 0xdfff;
}
/**
* UTF-8-safe truncation: cap at maxChars but never split a multi-byte
* character. Returns the text unchanged if already under the limit.
*
* Pattern reused from `src/core/minions/handlers/subagent-audit.ts` which
* faces the same multi-byte concern.
*/
export function truncateUtf8(text: string, maxChars: number): string {
if (!text) return '';
if (text.length <= maxChars) return text;
return text.slice(0, Math.max(0, safeSplitIndex(text, maxChars)));
}
/**
* Return the largest safe slice index ≤ maxChars (never orphans a UTF-16
* surrogate). Used by chunkers that need the index itself, not the
* truncated string.
*
* Three back-up cases (mirrors `truncateUtf8`'s implementation exactly,
* so the two functions cannot drift):
* 1. Pair STRADDLES the cut: high at maxChars-1, low at maxChars.
* Return maxChars-1 (pair starts the next chunk together).
* 2. Stray high at maxChars-1 (no paired low). Return maxChars-1.
* 3. Low at maxChars-1 (we're inside a pair that started at maxChars-2).
* Return maxChars-2 (whole pair moves to next chunk).
*
* Case 3 is intentionally conservative when the pair is COMPLETE in the
* kept half (e.g. text="abcd🚀", maxChars=6 returns 4, not 6). The 2-char
* shortfall is harmless for chunker determinism — same input → same
* chunks every time — and matches truncateUtf8's well-tested behavior.
* Fixing the conservative back-up would require diverging the two
* functions; we deliberately match them.
*/
export function safeSplitIndex(text: string, maxChars: number): number {
if (maxChars <= 0) return 0;
if (maxChars >= text.length) return text.length;
const unitAtEnd = text.charCodeAt(maxChars);
const unitBefore = text.charCodeAt(maxChars - 1);
// Case 1: pair straddles the cut.
if (isHighSurrogate(unitBefore) && isLowSurrogate(unitAtEnd)) {
return maxChars - 1;
}
// Case 2: stray high surrogate.
if (isHighSurrogate(unitBefore)) {
return maxChars - 1;
}
// Case 3: kept half ends at low surrogate; back up two.
if (isLowSurrogate(unitBefore)) {
return Math.max(0, maxChars - 2);
}
return maxChars;
}