import { utf8ByteLength } from "@earendil-works/chord/output"; /** * 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: 50KB) * * Never returns partial lines. Tool output streams are bounded by `OutputBuffer` from `@earendil-works/chord/output` instead. */ export const DEFAULT_MAX_LINES = 2000; export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB 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 complete lines in the truncated output */ outputLines: number; /** Number of bytes in the truncated output */ outputBytes: number; /** Whether the last line was partially truncated (only for tail truncation edge case) */ lastLinePartial: boolean; /** Whether the first line exceeded the byte limit (for head truncation) */ firstLineExceedsLimit: boolean; /** The max lines limit that was applied */ maxLines: number; /** The max bytes limit that was applied */ maxBytes: number; } export interface TruncationOptions { /** Maximum number of lines (default: 2000) */ maxLines?: number; /** Maximum number of bytes (default: 50KB) */ maxBytes?: number; } // One implementation, shared with the output buffer. export { utf8ByteLength }; function splitLinesForCounting(content: string): string[] { if (content.length === 0) return []; const lines = content.split("\n"); if (content.endsWith("\n")) lines.pop(); return lines; } /** * Format bytes as human-readable size. */ export 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`; } } /** * Truncate content from the head (keep first N lines/bytes). * Suitable for file reads where you want to see the beginning. * * Never returns partial lines. If first line exceeds byte limit, * returns empty content with firstLineExceedsLimit=true. */ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { return truncateHeadOf( content, { lines: splitLinesForCounting(content).length, bytes: utf8ByteLength(content) }, options, ); } /** * `truncateHead` of a text known by a prefix and its totals (`lines` counted like `truncateHead`, ignoring a trailing * newline). The prefix must be the whole text, or longer than `maxBytes + 1` UTF-8 bytes, or hold at least `maxLines` * newlines; then the result equals `truncateHead` of the whole text. */ export function truncateHeadOf( prefix: string, totals: { readonly lines: number; readonly bytes: number }, options: TruncationOptions = {}, ): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = totals.bytes; const lines = splitLinesForCounting(prefix); const totalLines = totals.lines; // Check if no truncation needed if (totalLines <= maxLines && totalBytes <= maxBytes) { return { content: prefix, truncated: false, truncatedBy: null, totalLines, totalBytes, outputLines: totalLines, outputBytes: totalBytes, lastLinePartial: false, firstLineExceedsLimit: false, maxLines, maxBytes, }; } // Check if first line alone exceeds byte limit const firstLineBytes = utf8ByteLength(lines[0]); if (firstLineBytes > maxBytes) { return { content: "", truncated: true, truncatedBy: "bytes", totalLines, totalBytes, outputLines: 0, outputBytes: 0, lastLinePartial: false, firstLineExceedsLimit: true, maxLines, maxBytes, }; } // Collect complete lines that fit 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 = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; break; } outputLinesArr.push(line); outputBytesCount += lineBytes; } // Without a byte break, only omitted lines prove the line limit was reached; otherwise a trailing newline exceeded bytes. if (truncatedBy !== "bytes") truncatedBy = outputLinesArr.length < totalLines ? "lines" : "bytes"; const outputContent = outputLinesArr.join("\n"); const finalOutputBytes = utf8ByteLength(outputContent); return { content: outputContent, truncated: true, truncatedBy, totalLines, totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, lastLinePartial: false, firstLineExceedsLimit: false, maxLines, maxBytes, }; }