mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* 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>
108 lines
4.4 KiB
TypeScript
108 lines
4.4 KiB
TypeScript
/**
|
||
* 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+D800–U+DBFF / lone low U+DC00–U+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;
|
||
}
|