| 1 | const DEFAULT_SUMMARY_CHARS = 180; |
| 2 | const WHITESPACE_RE = /\s/u; |
| 3 | |
| 4 | export type ReasoningSummaryOptions = { |
| 5 | /** While streaming, the tail line is the live signal; once done, the head line reads best. */ |
| 6 | streaming: boolean; |
| 7 | maxChars?: number; |
| 8 | }; |
| 9 | |
| 10 | type LineBounds = { start: number; end: number }; |
| 11 | |
| 12 | function hasNonWhitespace(text: string, start: number, end: number): boolean { |
| 13 | for (let index = start; index < end; index += 1) { |
| 14 | if (!WHITESPACE_RE.test(text[index])) return true; |
| 15 | } |
| 16 | return false; |
| 17 | } |
| 18 | |
| 19 | // Scans forward for the first non-blank line without splitting or copying the |
| 20 | // whole string — long streaming reasoning must not allocate a full line per |
| 21 | // token. |
| 22 | function firstNonBlankLine(text: string): LineBounds | null { |
| 23 | let start = 0; |
| 24 | while (start < text.length) { |
| 25 | let end = start; |
| 26 | while (end < text.length && text[end] !== "\n" && text[end] !== "\r") end += 1; |
| 27 | if (hasNonWhitespace(text, start, end)) return { start, end }; |
| 28 | start = text[end] === "\r" && text[end + 1] === "\n" ? end + 2 : end + 1; |
| 29 | } |
| 30 | return null; |
| 31 | } |
| 32 | |
| 33 | // Scans backward for the last non-blank line without copying its content. |
| 34 | // When the current line is longer than the preview budget, it stops at a |
| 35 | // bounded tail window instead of walking all the way back to the line start. |
| 36 | function lastNonBlankLine(text: string, initialWindow: number): LineBounds | null { |
| 37 | let end = text.length; |
| 38 | while (end > 0 && (text[end - 1] === "\n" || text[end - 1] === "\r")) end -= 1; |
| 39 | const minimumWindow = Math.max(32, initialWindow); |
| 40 | let window = minimumWindow; |
| 41 | |
| 42 | while (end > 0) { |
| 43 | const windowStart = Math.max(0, end - window); |
| 44 | let separator = -1; |
| 45 | for (let index = end - 1; index >= windowStart; index -= 1) { |
| 46 | if (text[index] === "\n" || text[index] === "\r") { |
| 47 | separator = index; |
| 48 | break; |
| 49 | } |
| 50 | } |
| 51 | const start = separator >= 0 ? separator + 1 : windowStart; |
| 52 | if (hasNonWhitespace(text, start, end)) return { start, end }; |
| 53 | if (separator >= 0) { |
| 54 | end = separator; |
| 55 | while (end > 0 && (text[end - 1] === "\n" || text[end - 1] === "\r")) end -= 1; |
| 56 | window = minimumWindow; |
| 57 | continue; |
| 58 | } |
| 59 | if (windowStart === 0) return null; |
| 60 | window *= 2; |
| 61 | } |
| 62 | return null; |
| 63 | } |
| 64 | |
| 65 | function normalizedMaxChars(maxChars: number): number { |
| 66 | if (!Number.isFinite(maxChars)) return DEFAULT_SUMMARY_CHARS; |
| 67 | return Math.max(0, Math.floor(maxChars)); |
| 68 | } |
| 69 | |
| 70 | // Code-point-safe truncation so a surrogate pair is never split. Streaming |
| 71 | // keeps the tail because that is where newly arrived text appears. |
| 72 | function truncateSummary(text: string, maxChars: number, fromEnd: boolean): string { |
| 73 | const limit = normalizedMaxChars(maxChars); |
| 74 | if (limit === 0) return ""; |
| 75 | const chars = Array.from(text); |
| 76 | if (chars.length <= limit) return text; |
| 77 | if (limit === 1) return "…"; |
| 78 | return fromEnd |
| 79 | ? `…${chars.slice(-(limit - 1)).join("")}` |
| 80 | : `${chars.slice(0, limit - 1).join("")}…`; |
| 81 | } |
| 82 | |
| 83 | function normalizeLinePreview(text: string, bounds: LineBounds, maxChars: number, fromEnd: boolean): string { |
| 84 | const limit = normalizedMaxChars(maxChars); |
| 85 | if (limit === 0) return ""; |
| 86 | // A code point occupies at most two UTF-16 code units. The small cushion |
| 87 | // covers whitespace that will be collapsed before the character budget is |
| 88 | // applied, while keeping the per-token allocation bounded. |
| 89 | const rawWindow = limit * 2 + 16; |
| 90 | const start = fromEnd ? Math.max(bounds.start, bounds.end - rawWindow) : bounds.start; |
| 91 | const end = fromEnd ? bounds.end : Math.min(bounds.end, bounds.start + rawWindow); |
| 92 | const normalized = text.slice(start, end).trim().replace(/\s+/g, " "); |
| 93 | return truncateSummary(normalized, limit, fromEnd); |
| 94 | } |
| 95 | |
| 96 | /** Builds a single-line plain-text preview without invoking another model. */ |
| 97 | export function reasoningSummaryText(reasoning: string, { streaming, maxChars = DEFAULT_SUMMARY_CHARS }: ReasoningSummaryOptions): string { |
| 98 | if (!reasoning) return ""; |
| 99 | const limit = normalizedMaxChars(maxChars); |
| 100 | if (limit === 0) return ""; |
| 101 | const bounds = streaming ? lastNonBlankLine(reasoning, limit * 2 + 16) : firstNonBlankLine(reasoning); |
| 102 | return bounds ? normalizeLinePreview(reasoning, bounds, limit, streaming) : ""; |
| 103 | } |
| 104 |