/** * Shared truncation utilities for tool outputs. * * Truncation is based on two independent limits - whichever is hit first wins: * - Line limit (default: 2000 lines) * - Byte limit (default: 30KB) */ export const DEFAULT_MAX_LINES = 2000; export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB export interface TruncationResult { /** The truncated content */ content: string; /** Whether truncation occurred */ truncated: boolean; /** Which limit was hit: "lines", "bytes", or null if not truncated */ truncatedBy: "lines" | "bytes" | null; /** Total number of lines in the original content */ totalLines: number; /** Total number of bytes in the original content */ totalBytes: number; /** Number of lines in the truncated output */ outputLines: number; /** Number of bytes in the truncated output */ outputBytes: number; /** Human-readable truncation notice (empty if not truncated) */ notice: string; } export interface TruncationOptions { /** Maximum number of lines (default: 2000) */ maxLines?: number; /** Maximum number of bytes (default: 30KB) */ maxBytes?: number; } /** * Format bytes as human-readable size. */ function formatSize(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } else if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}KB`; } else { return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } } /** * Generate a truncation notice. */ function makeNotice( direction: "head" | "tail", truncatedBy: "lines" | "bytes", totalLines: number, totalBytes: number, outputLines: number, outputBytes: number, ): string { const totalSize = formatSize(totalBytes); const outputSize = formatSize(outputBytes); const directionText = direction === "head" ? "first" : "last"; if (truncatedBy === "lines") { return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`; } else { return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`; } } /** * Truncate content from the head (keep first N lines/bytes). * Suitable for file reads where you want to see the beginning. */ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); const lines = content.split("\n"); const totalLines = lines.length; // Check if no truncation needed if (totalLines <= maxLines && totalBytes <= maxBytes) { return { content, truncated: false, truncatedBy: null, totalLines, totalBytes, outputLines: totalLines, outputBytes: totalBytes, notice: "", }; } // Determine which limit we'll hit first const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; for (let i = 0; i < lines.length && i < maxLines; i++) { const line = lines[i]; const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; // If this is the first line and it alone exceeds maxBytes, include partial if (i === 0) { const truncatedLine = truncateStringToBytes(line, maxBytes); outputLinesArr.push(truncatedLine); outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); } break; } outputLinesArr.push(line); outputBytesCount += lineBytes; } // If we exited due to line limit if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { truncatedBy = "lines"; } const outputContent = outputLinesArr.join("\n"); const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); return { content: outputContent, truncated: true, truncatedBy, totalLines, totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), }; } /** * Truncate content from the tail (keep last N lines/bytes). * Suitable for bash output where you want to see the end (errors, final results). */ export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); const lines = content.split("\n"); const totalLines = lines.length; // Check if no truncation needed if (totalLines <= maxLines && totalBytes <= maxBytes) { return { content, truncated: false, truncatedBy: null, totalLines, totalBytes, outputLines: totalLines, outputBytes: totalBytes, notice: "", }; } // Work backwards from the end const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { const line = lines[i]; const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; // If this is the first line we're adding and it alone exceeds maxBytes, include partial if (outputLinesArr.length === 0) { // Take the end of the line const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); outputLinesArr.unshift(truncatedLine); outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); } break; } outputLinesArr.unshift(line); outputBytesCount += lineBytes; } // If we exited due to line limit if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { truncatedBy = "lines"; } const outputContent = outputLinesArr.join("\n"); const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); return { content: outputContent, truncated: true, truncatedBy, totalLines, totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), }; } /** * Truncate a string to fit within a byte limit (from the start). * Handles multi-byte UTF-8 characters correctly. */ function truncateStringToBytes(str: string, maxBytes: number): string { const buf = Buffer.from(str, "utf-8"); if (buf.length <= maxBytes) { return str; } // Find a valid UTF-8 boundary let end = maxBytes; while (end > 0 && (buf[end] & 0xc0) === 0x80) { end--; } return buf.slice(0, end).toString("utf-8"); } /** * Truncate a string to fit within a byte limit (from the end). * Handles multi-byte UTF-8 characters correctly. */ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { const buf = Buffer.from(str, "utf-8"); if (buf.length <= maxBytes) { return str; } // Start from the end, skip maxBytes back let start = buf.length - maxBytes; // Find a valid UTF-8 boundary (start of a character) while (start < buf.length && (buf[start] & 0xc0) === 0x80) { start++; } return buf.slice(start).toString("utf-8"); }