truncate.ts

  1export const DEFAULT_MAX_LINES = 2000;
  2export const DEFAULT_MAX_BYTES = 50 * 1024;
  3export const GREP_MAX_LINE_LENGTH = 500;
  4
  5export interface TruncationResult {
  6  content: string;
  7  truncated: boolean;
  8  truncatedBy: "lines" | "bytes" | null;
  9  totalLines: number;
 10  totalBytes: number;
 11  outputLines: number;
 12  outputBytes: number;
 13  lastLinePartial: boolean;
 14  firstLineExceedsLimit: boolean;
 15  maxLines: number;
 16  maxBytes: number;
 17}
 18
 19export interface TruncationOptions {
 20  maxLines?: number;
 21  maxBytes?: number;
 22}
 23
 24export function formatSize(bytes: number): string {
 25  if (bytes < 1024) return `${bytes}B`;
 26  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
 27  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
 28}
 29
 30export function truncateHead(
 31  content: string,
 32  options: TruncationOptions = {},
 33): TruncationResult {
 34  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
 35  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
 36  const totalBytes = Buffer.byteLength(content, "utf-8");
 37  const lines = content.split("\n");
 38  const totalLines = lines.length;
 39
 40  if (totalLines <= maxLines && totalBytes <= maxBytes) {
 41    return {
 42      content,
 43      truncated: false,
 44      truncatedBy: null,
 45      totalLines,
 46      totalBytes,
 47      outputLines: totalLines,
 48      outputBytes: totalBytes,
 49      lastLinePartial: false,
 50      firstLineExceedsLimit: false,
 51      maxLines,
 52      maxBytes,
 53    };
 54  }
 55
 56  const firstLineBytes = Buffer.byteLength(lines[0]!, "utf-8");
 57  if (firstLineBytes > maxBytes) {
 58    return {
 59      content: "",
 60      truncated: true,
 61      truncatedBy: "bytes",
 62      totalLines,
 63      totalBytes,
 64      outputLines: 0,
 65      outputBytes: 0,
 66      lastLinePartial: false,
 67      firstLineExceedsLimit: true,
 68      maxLines,
 69      maxBytes,
 70    };
 71  }
 72
 73  const outputLinesArr: string[] = [];
 74  let outputBytesCount = 0;
 75  let truncatedBy: "lines" | "bytes" = "lines";
 76
 77  for (let i = 0; i < lines.length && i < maxLines; i++) {
 78    const line = lines[i]!;
 79    const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
 80    if (outputBytesCount + lineBytes > maxBytes) {
 81      truncatedBy = "bytes";
 82      break;
 83    }
 84    outputLinesArr.push(line);
 85    outputBytesCount += lineBytes;
 86  }
 87
 88  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
 89    truncatedBy = "lines";
 90  }
 91
 92  const outputContent = outputLinesArr.join("\n");
 93  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
 94
 95  return {
 96    content: outputContent,
 97    truncated: true,
 98    truncatedBy,
 99    totalLines,
100    totalBytes,
101    outputLines: outputLinesArr.length,
102    outputBytes: finalOutputBytes,
103    lastLinePartial: false,
104    firstLineExceedsLimit: false,
105    maxLines,
106    maxBytes,
107  };
108}
109
110function truncateStringToBytesFromEnd(
111  str: string,
112  maxBytes: number,
113): string {
114  const buf = Buffer.from(str, "utf-8");
115  if (buf.length <= maxBytes) return str;
116  let end = buf.length;
117  let start = end - maxBytes;
118  // Walk forward to a valid UTF-8 boundary
119  while (start < end && (buf[start]! & 0xc0) === 0x80) {
120    start++;
121  }
122  return buf.subarray(start, end).toString("utf-8");
123}
124
125export function truncateTail(
126  content: string,
127  options: TruncationOptions = {},
128): TruncationResult {
129  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
130  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
131  const totalBytes = Buffer.byteLength(content, "utf-8");
132  const lines = content.split("\n");
133  const totalLines = lines.length;
134
135  if (totalLines <= maxLines && totalBytes <= maxBytes) {
136    return {
137      content,
138      truncated: false,
139      truncatedBy: null,
140      totalLines,
141      totalBytes,
142      outputLines: totalLines,
143      outputBytes: totalBytes,
144      lastLinePartial: false,
145      firstLineExceedsLimit: false,
146      maxLines,
147      maxBytes,
148    };
149  }
150
151  const outputLinesArr: string[] = [];
152  let outputBytesCount = 0;
153  let truncatedBy: "lines" | "bytes" = "lines";
154
155  for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
156    const line = lines[i]!;
157    const lineBytes =
158      Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
159    if (outputBytesCount + lineBytes > maxBytes) {
160      truncatedBy = "bytes";
161      break;
162    }
163    outputLinesArr.unshift(line);
164    outputBytesCount += lineBytes;
165  }
166
167  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
168    truncatedBy = "lines";
169  }
170
171  let lastLinePartial = false;
172
173  // If we couldn't fit any lines (last line alone exceeds byte limit),
174  // take partial content from the end.
175  if (outputLinesArr.length === 0) {
176    const lastLine = lines[lines.length - 1]!;
177    const partial = truncateStringToBytesFromEnd(lastLine, maxBytes);
178    outputLinesArr.push(partial);
179    outputBytesCount = Buffer.byteLength(partial, "utf-8");
180    truncatedBy = "bytes";
181    lastLinePartial = true;
182  }
183
184  const outputContent = outputLinesArr.join("\n");
185  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
186
187  return {
188    content: outputContent,
189    truncated: true,
190    truncatedBy,
191    totalLines,
192    totalBytes,
193    outputLines: outputLinesArr.length,
194    outputBytes: finalOutputBytes,
195    lastLinePartial,
196    firstLineExceedsLimit: false,
197    maxLines,
198    maxBytes,
199  };
200}
201
202export function truncateLine(
203  line: string,
204  maxChars: number = GREP_MAX_LINE_LENGTH,
205): { text: string; wasTruncated: boolean } {
206  if (line.length <= maxChars) {
207    return { text: line, wasTruncated: false };
208  }
209  return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
210}