b1893b3d77
closes #4715
345 lines
9.4 KiB
TypeScript
345 lines
9.4 KiB
TypeScript
/**
|
|
* 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 (except bash tail truncation edge case).
|
|
*/
|
|
|
|
export const DEFAULT_MAX_LINES = 2000;
|
|
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
|
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
|
|
|
|
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;
|
|
}
|
|
|
|
interface RuntimeBuffer {
|
|
byteLength(content: string, encoding: "utf8"): number;
|
|
}
|
|
|
|
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
|
|
const nonAsciiPattern = /[^\x00-\x7f]/;
|
|
|
|
function utf8ByteLength(content: string): number {
|
|
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");
|
|
|
|
const firstNonAscii = content.search(nonAsciiPattern);
|
|
if (firstNonAscii === -1) return content.length;
|
|
|
|
let bytes = firstNonAscii;
|
|
for (let i = firstNonAscii; i < content.length; i++) {
|
|
const code = content.charCodeAt(i);
|
|
if (code <= 0x7f) {
|
|
bytes += 1;
|
|
} else if (code <= 0x7ff) {
|
|
bytes += 2;
|
|
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
|
|
const next = content.charCodeAt(i + 1);
|
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
bytes += 4;
|
|
i++;
|
|
} else {
|
|
bytes += 3;
|
|
}
|
|
} else {
|
|
bytes += 3;
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function replaceUnpairedSurrogates(content: string): string {
|
|
let output = "";
|
|
for (let i = 0; i < content.length; i++) {
|
|
const code = content.charCodeAt(i);
|
|
if (code >= 0xd800 && code <= 0xdbff) {
|
|
if (i + 1 < content.length) {
|
|
const next = content.charCodeAt(i + 1);
|
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
output += content[i] + content[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
output += "�";
|
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
output += "�";
|
|
} else {
|
|
output += content[i];
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
|
|
const totalBytes = utf8ByteLength(content);
|
|
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,
|
|
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;
|
|
}
|
|
|
|
// If we exited due to line limit
|
|
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
|
truncatedBy = "lines";
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Truncate content from the tail (keep last N lines/bytes).
|
|
* Suitable for bash output where you want to see the end (errors, final results).
|
|
*
|
|
* May return partial first line if the last line of original content exceeds byte limit.
|
|
*/
|
|
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
|
|
const totalBytes = utf8ByteLength(content);
|
|
const lines = content.split("\n");
|
|
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
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,
|
|
lastLinePartial: false,
|
|
firstLineExceedsLimit: false,
|
|
maxLines,
|
|
maxBytes,
|
|
};
|
|
}
|
|
|
|
// Work backwards from the end
|
|
const outputLinesArr: string[] = [];
|
|
let outputBytesCount = 0;
|
|
let truncatedBy: "lines" | "bytes" = "lines";
|
|
let lastLinePartial = false;
|
|
|
|
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
|
const line = lines[i];
|
|
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
|
|
|
if (outputBytesCount + lineBytes > maxBytes) {
|
|
truncatedBy = "bytes";
|
|
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
|
|
// take the end of the line (partial)
|
|
if (outputLinesArr.length === 0) {
|
|
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
|
outputLinesArr.unshift(truncatedLine);
|
|
outputBytesCount = utf8ByteLength(truncatedLine);
|
|
lastLinePartial = true;
|
|
}
|
|
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 = utf8ByteLength(outputContent);
|
|
|
|
return {
|
|
content: outputContent,
|
|
truncated: true,
|
|
truncatedBy,
|
|
totalLines,
|
|
totalBytes,
|
|
outputLines: outputLinesArr.length,
|
|
outputBytes: finalOutputBytes,
|
|
lastLinePartial,
|
|
firstLineExceedsLimit: false,
|
|
maxLines,
|
|
maxBytes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
if (maxBytes <= 0) return "";
|
|
|
|
let outputBytes = 0;
|
|
let start = str.length;
|
|
let needsReplacement = false;
|
|
for (let i = str.length; i > 0; ) {
|
|
let characterStart = i - 1;
|
|
const code = str.charCodeAt(characterStart);
|
|
let characterBytes: number;
|
|
let unpairedSurrogate = false;
|
|
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
|
|
const previous = str.charCodeAt(characterStart - 1);
|
|
if (previous >= 0xd800 && previous <= 0xdbff) {
|
|
characterStart--;
|
|
characterBytes = 4;
|
|
} else {
|
|
characterBytes = 3;
|
|
unpairedSurrogate = true;
|
|
}
|
|
} else if (code >= 0xd800 && code <= 0xdfff) {
|
|
characterBytes = 3;
|
|
unpairedSurrogate = true;
|
|
} else {
|
|
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
|
}
|
|
if (outputBytes + characterBytes > maxBytes) break;
|
|
outputBytes += characterBytes;
|
|
start = characterStart;
|
|
needsReplacement ||= unpairedSurrogate;
|
|
i = characterStart;
|
|
}
|
|
|
|
const output = str.slice(start);
|
|
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
|
|
}
|
|
|
|
/**
|
|
* Truncate a single line to max characters, adding [truncated] suffix.
|
|
* Used for grep match lines.
|
|
*/
|
|
export function truncateLine(
|
|
line: string,
|
|
maxChars: number = GREP_MAX_LINE_LENGTH,
|
|
): { text: string; wasTruncated: boolean } {
|
|
if (line.length <= maxChars) {
|
|
return { text: line, wasTruncated: false };
|
|
}
|
|
return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
|
|
}
|