/** * ResponseFormatter — splits long response text into Discord-safe chunks. * * Splits text into chunks of at most `maxLength` characters (default 2000). * Prefers splitting at line boundaries. Tracks open code blocks: if a split * occurs inside a code block, the chunk is closed with ``` and the next chunk * reopens with ``` (preserving the language tag). */ const DEFAULT_MAX_LENGTH = 2000; const CODE_FENCE = "```"; const CLOSING_SUFFIX = "\n" + CODE_FENCE; /** * Split `text` into an array of chunks, each at most `maxLength` characters. * * - Prefers splitting at newline boundaries. * - If a code block spans a split boundary, the current chunk is closed with * ``` and the next chunk reopens with ``` (including the original language tag). * - Empty text returns an empty array. */ export function splitMessage( text: string, maxLength: number = DEFAULT_MAX_LENGTH, ): string[] { if (text.length === 0) { return []; } if (text.length <= maxLength) { return [text]; } const chunks: string[] = []; let remaining = text; let openFenceTag: string | null = null; while (remaining.length > 0) { // If we're continuing inside a code block, prepend the fence opener. const prefix = openFenceTag !== null ? openFenceTag + "\n" : ""; // If the remaining text (with prefix) fits, emit the final chunk. if (prefix.length + remaining.length <= maxLength) { chunks.push(prefix + remaining); break; } // Always reserve space for a potential closing fence suffix when splitting. // This guarantees the chunk stays within maxLength even if we need to close // a code block that was opened (or continued) in this chunk. const budget = maxLength - prefix.length - CLOSING_SUFFIX.length; if (budget <= 0) { // Degenerate case: maxLength too small for overhead. Force progress. const take = Math.max(1, maxLength - prefix.length); chunks.push(prefix + remaining.slice(0, take)); remaining = remaining.slice(take); continue; } // Find a good split point within the budget. const splitIndex = findSplitPoint(remaining, budget); const chunk = remaining.slice(0, splitIndex); remaining = remaining.slice(splitIndex); // Determine code-block state at the end of this chunk. const fenceState = computeFenceState(chunk, openFenceTag); if (fenceState.insideCodeBlock) { chunks.push(prefix + chunk + CLOSING_SUFFIX); openFenceTag = fenceState.fenceTag; } else { chunks.push(prefix + chunk); openFenceTag = null; } } return chunks; } /** * Find the best index to split `text` at, within `budget` characters. * Prefers the last newline boundary. Falls back to `budget` if no newline found. */ function findSplitPoint(text: string, budget: number): number { const region = text.slice(0, budget); const lastNewline = region.lastIndexOf("\n"); if (lastNewline > 0) { return lastNewline + 1; } return budget; } /** * Scan `chunk` for code fence toggles and determine whether we end inside * an open code block. */ function computeFenceState( chunk: string, initialFenceTag: string | null, ): { insideCodeBlock: boolean; fenceTag: string | null } { let inside = initialFenceTag !== null; let fenceTag = initialFenceTag; const fenceRegex = /^(`{3,})(.*)?$/gm; let match: RegExpExecArray | null; while ((match = fenceRegex.exec(chunk)) !== null) { const backticks = match[1]; const langTag = (match[2] ?? "").trim(); if (!inside) { inside = true; fenceTag = langTag ? backticks + langTag : backticks; } else { inside = false; fenceTag = null; } } return { insideCodeBlock: inside, fenceTag: fenceTag }; }