import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { formatBytes } from "../tools/render-utils";
import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
export const DEFAULT_MAX_LINES = 3000;
export const DEFAULT_MAX_BYTES = 50 * 1024;
export const DEFAULT_MAX_COLUMN = 512;
const NL = "\n";
const ELLIPSIS = "…";
export interface OutputSummary {
output: string;
truncated: boolean;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
elidedBytes?: number;
elidedLines?: number;
columnDroppedBytes?: number;
columnTruncatedLines?: number;
artifactId?: string;
}
export interface OutputSinkOptions {
artifactPath?: string;
artifactId?: string;
spillThreshold?: number;
* When > 0, the sink keeps the first `headBytes` of output in addition to
* the rolling tail window. Output between the two windows is elided
* (middle elision). Default 0 = tail-only behavior.
*/
headBytes?: number;
* Per-line byte cap. When > 0, lines wider than `maxColumns` bytes are
* truncated with an ellipsis at write time; remaining bytes up to the next
* `\n` are dropped. Cap state persists across chunks so split-mid-line
* writes still respect the budget. Default 0 = no per-line cap.
*/
maxColumns?: number;
onChunk?: (chunk: string) => void;
chunkThrottleMs?: number;
}
export interface TruncationResult {
content: string;
truncated?: boolean;
truncatedBy?: "lines" | "bytes" | "middle";
totalLines: number;
totalBytes: number;
outputLines?: number;
outputBytes?: number;
elidedBytes?: number;
elidedLines?: number;
lastLinePartial?: boolean;
firstLineExceedsLimit?: boolean;
}
export interface TruncationOptions {
maxLines?: number;
maxBytes?: number;
* For `truncateMiddle`: bytes reserved for the head window. The tail
* window receives `maxBytes - maxHeadBytes`. Default `floor(maxBytes/2)`.
*/
maxHeadBytes?: number;
* For `truncateMiddle`: lines reserved for the head window. The tail
* window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`.
*/
maxHeadLines?: number;
}
export interface ByteTruncationResult {
text: string;
bytes: number;
}
export interface TailTruncationNoticeOptions {
fullOutputPath?: string;
originalContent?: string;
suffix?: string;
}
export interface HeadTruncationNoticeOptions {
startLine?: number;
totalFileLines?: number;
}
function countNewlines(text: string): number {
let count = 0;
let pos = text.indexOf(NL);
while (pos !== -1) {
count++;
pos = text.indexOf(NL, pos + 1);
}
return count;
}
function asBuffer(data: Uint8Array): Buffer {
return Buffer.isBuffer(data) ? (data as Buffer) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
function findUtf8BoundaryForward(buf: Buffer, pos: number): number {
let i = Math.max(0, pos);
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i++;
return i;
}
function findUtf8BoundaryBackward(buf: Buffer, cut: number): number {
let i = Math.min(buf.length, Math.max(0, cut));
if (i >= buf.length) return buf.length;
while (i > 0 && (buf[i] & 0xc0) === 0x80) i--;
return i;
}
function truncateBytesWindowed(
data: string | Uint8Array,
maxBytesRaw: number,
mode: "head" | "tail",
): ByteTruncationResult {
const maxBytes = maxBytesRaw;
if (maxBytes === 0) return { text: "", bytes: 0 };
if (typeof data === "string") {
if (data.length <= maxBytes) {
const len = Buffer.byteLength(data, "utf-8");
if (len <= maxBytes) return { text: data, bytes: len };
}
const window =
mode === "head"
? data.substring(0, Math.min(data.length, maxBytes))
: data.substring(Math.max(0, data.length - maxBytes));
const buf = Buffer.from(window, "utf-8");
if (mode === "head") {
const end = findUtf8BoundaryBackward(buf, maxBytes);
if (end <= 0) return { text: "", bytes: 0 };
const slice = buf.subarray(0, end);
return { text: slice.toString("utf-8"), bytes: slice.length };
} else {
const startAt = Math.max(0, buf.length - maxBytes);
const start = findUtf8BoundaryForward(buf, startAt);
const slice = buf.subarray(start);
return { text: slice.toString("utf-8"), bytes: slice.length };
}
}
const buf = asBuffer(data);
if (buf.length <= maxBytes) return { text: buf.toString("utf-8"), bytes: buf.length };
if (mode === "head") {
const end = findUtf8BoundaryBackward(buf, maxBytes);
if (end <= 0) return { text: "", bytes: 0 };
const slice = buf.subarray(0, end);
return { text: slice.toString("utf-8"), bytes: slice.length };
} else {
const startAt = buf.length - maxBytes;
const start = findUtf8BoundaryForward(buf, startAt);
const slice = buf.subarray(start);
return { text: slice.toString("utf-8"), bytes: slice.length };
}
}
* Truncate a string/buffer to fit within a byte limit, keeping the tail.
* Handles multi-byte UTF-8 boundaries correctly.
*/
export function truncateTailBytes(data: string | Uint8Array, maxBytes: number): ByteTruncationResult {
return truncateBytesWindowed(data, maxBytes, "tail");
}
* Truncate a string/buffer to fit within a byte limit, keeping the head.
* Handles multi-byte UTF-8 boundaries correctly.
*/
export function truncateHeadBytes(data: string | Uint8Array, maxBytes: number): ByteTruncationResult {
return truncateBytesWindowed(data, maxBytes, "head");
}
* Truncate a single line to max characters, appending '…' if truncated.
*/
export function truncateLine(
line: string,
maxChars: number = DEFAULT_MAX_COLUMN,
): { text: string; wasTruncated: boolean } {
if (line.length <= maxChars) return { text: line, wasTruncated: false };
return { text: `${line.slice(0, maxChars)}…`, wasTruncated: true };
}
export function noTruncResult(content: string, totalLines?: number, totalBytes?: number): TruncationResult {
if (totalLines == null) totalLines = countNewlines(content) + 1;
if (totalBytes == null) totalBytes = Buffer.byteLength(content, "utf-8");
return { content, totalLines, totalBytes };
}
* Truncate content from the head (keep first N lines/bytes).
* Never returns partial lines. If the first line exceeds the byte limit,
* returns empty content with firstLineExceedsLimit=true.
*
* This implementation avoids Buffer.from(content) for the whole input.
* It only computes UTF-8 byteLength for candidate lines that can still fit.
*/
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 totalLines = countNewlines(content) + 1;
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return noTruncResult(content, totalLines, totalBytes);
}
let includedLines = 0;
let bytesUsed = 0;
let cutIndex = 0;
let cursor = 0;
let truncatedBy: "lines" | "bytes" = "lines";
while (includedLines < maxLines) {
const nl = content.indexOf(NL, cursor);
const lineEnd = nl === -1 ? content.length : nl;
const sepBytes = includedLines > 0 ? 1 : 0;
const remaining = maxBytes - bytesUsed - sepBytes;
if (remaining < 0) {
truncatedBy = "bytes";
break;
}
const lineCodeUnits = lineEnd - cursor;
if (lineCodeUnits > remaining) {
truncatedBy = "bytes";
if (includedLines === 0) {
return {
content: "",
truncated: true,
truncatedBy: "bytes",
totalLines,
totalBytes,
outputLines: 0,
outputBytes: 0,
lastLinePartial: false,
firstLineExceedsLimit: true,
};
}
break;
}
const lineText = content.slice(cursor, lineEnd);
const lineBytes = Buffer.byteLength(lineText, "utf-8");
if (lineBytes > remaining) {
truncatedBy = "bytes";
if (includedLines === 0) {
return {
content: "",
truncated: true,
truncatedBy: "bytes",
totalLines,
totalBytes,
outputLines: 0,
outputBytes: 0,
lastLinePartial: false,
firstLineExceedsLimit: true,
};
}
break;
}
bytesUsed += sepBytes + lineBytes;
includedLines++;
cutIndex = nl === -1 ? content.length : nl;
if (nl === -1) break;
cursor = nl + 1;
}
if (includedLines >= maxLines && bytesUsed <= maxBytes) truncatedBy = "lines";
return {
content: content.slice(0, cutIndex),
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: includedLines,
outputBytes: bytesUsed,
lastLinePartial: false,
firstLineExceedsLimit: false,
};
}
* Truncate content from the tail (keep last N lines/bytes).
* May return a partial first line if the last line exceeds the byte limit.
*
* Also avoids Buffer.from(content) for the whole input.
*/
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 totalLines = countNewlines(content) + 1;
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return noTruncResult(content, totalLines, totalBytes);
}
let includedLines = 0;
let bytesUsed = 0;
let startIndex = content.length;
let end = content.length;
let truncatedBy: "lines" | "bytes" = "lines";
while (includedLines < maxLines) {
const nl = content.lastIndexOf(NL, end - 1);
const lineStart = nl === -1 ? 0 : nl + 1;
const sepBytes = includedLines > 0 ? 1 : 0;
const remaining = maxBytes - bytesUsed - sepBytes;
if (remaining < 0) {
truncatedBy = "bytes";
break;
}
const lineCodeUnits = end - lineStart;
if (lineCodeUnits > remaining) {
truncatedBy = "bytes";
if (includedLines === 0) {
const windowStart = Math.max(lineStart, end - maxBytes);
const window = content.substring(windowStart, end);
const tail = truncateTailBytes(window, maxBytes);
return {
content: tail.text,
truncated: true,
truncatedBy: "bytes",
totalLines,
totalBytes,
outputLines: 1,
outputBytes: tail.bytes,
lastLinePartial: true,
firstLineExceedsLimit: false,
};
}
break;
}
const lineText = content.slice(lineStart, end);
const lineBytes = Buffer.byteLength(lineText, "utf-8");
if (lineBytes > remaining) {
truncatedBy = "bytes";
if (includedLines === 0) {
const tail = truncateTailBytes(lineText, maxBytes);
return {
content: tail.text,
truncated: true,
truncatedBy: "bytes",
totalLines,
totalBytes,
outputLines: 1,
outputBytes: tail.bytes,
lastLinePartial: true,
firstLineExceedsLimit: false,
};
}
break;
}
bytesUsed += sepBytes + lineBytes;
includedLines++;
startIndex = lineStart;
if (nl === -1) break;
end = nl;
}
if (includedLines >= maxLines && bytesUsed <= maxBytes) truncatedBy = "lines";
return {
content: content.slice(startIndex),
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: includedLines,
outputBytes: bytesUsed,
lastLinePartial: false,
firstLineExceedsLimit: false,
};
}
* Format the inline marker substituted for the elided middle region.
* Returned without surrounding newlines so callers can position it freely.
*/
export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`;
return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`;
}
* Truncate content keeping a head window and a tail window, eliding the middle.
*
* The combined output is `<head>\n<marker>\n<tail>` when truncation is needed.
* `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the
* remainder. Falls back to `truncateTail` / `truncateHead` if either side's
* budget is empty or the content already fits.
*/
export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
const tailBytes = Math.max(0, maxBytes - headBytes);
const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2));
const tailLines = Math.max(0, maxLines - headLines);
const totalBytes = Buffer.byteLength(content, "utf-8");
const totalLines = countNewlines(content) + 1;
if (totalBytes <= maxBytes && totalLines <= maxLines) {
return noTruncResult(content, totalLines, totalBytes);
}
if (headBytes <= 0 || headLines <= 0) {
return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
}
if (tailBytes <= 0 || tailLines <= 0) {
return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
}
const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines });
const headLinesKept = head.outputLines ?? 0;
const tailLinesKept = tail.outputLines ?? 0;
const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8");
const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8");
if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
if (tailLinesKept === 0) return head;
if (headLinesKept + tailLinesKept >= totalLines) {
return noTruncResult(content, totalLines, totalBytes);
}
const elidedLines = totalLines - headLinesKept - tailLinesKept;
const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept);
const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
const composed = `${head.content}\n${marker}\n${tail.content}`;
const markerBytes = Buffer.byteLength(marker, "utf-8");
return {
content: composed,
truncated: true,
truncatedBy: "middle",
totalLines,
totalBytes,
outputLines: headLinesKept + tailLinesKept + 1,
outputBytes: headBytesKept + tailBytesKept + markerBytes + 2,
elidedLines,
elidedBytes,
lastLinePartial: tail.lastLinePartial,
firstLineExceedsLimit: false,
};
}
const MAX_PENDING = 10;
export class TailBuffer {
#pending: string[] = [];
#pos = 0;
constructor(readonly maxBytes: number) {}
append(text: string): void {
if (!text) return;
const max = this.maxBytes;
if (max === 0) {
this.#pending.length = 0;
this.#pos = 0;
return;
}
const n = Buffer.byteLength(text, "utf-8");
if (n >= max) {
const { text: t, bytes } = truncateTailBytes(text, max);
this.#pending[0] = t;
this.#pending.length = 1;
this.#pos = bytes;
return;
}
this.#pos += n;
if (this.#pending.length === 0) {
this.#pending[0] = text;
this.#pending.length = 1;
} else {
this.#pending.push(text);
if (this.#pending.length > MAX_PENDING) this.#compact();
}
if (this.#pos > max * 2) this.#trimTo(max);
}
text(): string {
const max = this.maxBytes;
this.#trimTo(max);
return this.#flush();
}
bytes(): number {
const max = this.maxBytes;
this.#trimTo(max);
return this.#pos;
}
#compact(): void {
this.#pending[0] = this.#pending.join("");
this.#pending.length = 1;
}
#flush(): string {
if (this.#pending.length === 0) return "";
if (this.#pending.length > 1) this.#compact();
return this.#pending[0];
}
#trimTo(max: number): void {
if (max === 0) {
this.#pending.length = 0;
this.#pos = 0;
return;
}
if (this.#pos <= max) return;
const joined = this.#flush();
const { text, bytes } = truncateTailBytes(joined, max);
this.#pos = bytes;
this.#pending[0] = text;
this.#pending.length = 1;
}
}
export class OutputSink {
#buffer = "";
#bufferBytes = 0;
#head = "";
#headBytes = 0;
#headLines = 0;
#headRetentionDisabled = false;
#totalLines = 0;
#totalBytes = 0;
#sawData = false;
#truncated = false;
#lastChunkTime = 0;
#currentLineBytes = 0;
#columnEllipsisAdded = false;
#columnDroppedBytes = 0;
#columnTruncatedLines = 0;
#file?: {
path: string;
artifactId?: string;
sink: Bun.FileSink;
};
#pendingFileWrites?: string[];
#fileReady = false;
readonly #artifactPath?: string;
readonly #artifactId?: string;
readonly #spillThreshold: number;
readonly #headLimit: number;
readonly #onChunk?: (chunk: string) => void;
readonly #chunkThrottleMs: number;
readonly #maxColumns: number;
constructor(options?: OutputSinkOptions) {
const {
artifactPath,
artifactId,
spillThreshold = DEFAULT_MAX_BYTES,
headBytes = 0,
maxColumns = 0,
onChunk,
chunkThrottleMs = 0,
} = options ?? {};
this.#artifactPath = artifactPath;
this.#artifactId = artifactId;
this.#spillThreshold = spillThreshold;
this.#headLimit = Math.max(0, headBytes);
this.#maxColumns = Math.max(0, maxColumns);
this.#onChunk = onChunk;
this.#chunkThrottleMs = chunkThrottleMs;
}
* Push a chunk of output. The buffer management and onChunk callback run
* synchronously. File sink writes are deferred and serialized internally.
*/
push(chunk: string): void {
chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
if (this.#onChunk) {
const now = Date.now();
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
this.#lastChunkTime = now;
this.#onChunk(chunk);
}
}
const rawBytes = Buffer.byteLength(chunk, "utf-8");
this.#totalBytes += rawBytes;
if (chunk.length > 0) {
this.#sawData = true;
this.#totalLines += countNewlines(chunk);
}
const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk;
const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8");
const cappedThisChunk = cappedBytes < rawBytes;
if (cappedThisChunk) this.#truncated = true;
if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) {
this.#writeToFile(chunk);
}
if (cappedBytes === 0) return;
let tailChunk = capped;
let tailBytes = cappedBytes;
if (this.#headLimit > 0 && !this.#headRetentionDisabled && this.#headBytes < this.#headLimit) {
const room = this.#headLimit - this.#headBytes;
if (cappedBytes <= room) {
this.#head += capped;
this.#headBytes += cappedBytes;
this.#headLines += countNewlines(capped);
return;
}
const headSlice = truncateHeadBytes(capped, room);
if (headSlice.bytes > 0) {
this.#head += headSlice.text;
this.#headBytes += headSlice.bytes;
this.#headLines += countNewlines(headSlice.text);
tailChunk = capped.substring(headSlice.text.length);
tailBytes = cappedBytes - headSlice.bytes;
}
}
this.#pushTail(tailChunk, tailBytes);
}
* Apply the per-line byte cap to `chunk`, dropping bytes that would push the
* current line beyond `#maxColumns`. Emits a single `…` once a line trips the
* cap; subsequent bytes are skipped until the next `\n`. State persists
* across calls so a long line split across chunks still produces one marker.
*/
#applyColumnCap(chunk: string): string {
if (chunk.length === 0) return chunk;
const max = this.#maxColumns;
const parts: string[] = [];
let cursor = 0;
while (cursor < chunk.length) {
const nlIdx = chunk.indexOf(NL, cursor);
const segEnd = nlIdx === -1 ? chunk.length : nlIdx;
if (segEnd > cursor) {
const segment = chunk.substring(cursor, segEnd);
if (this.#columnEllipsisAdded) {
this.#columnDroppedBytes += Buffer.byteLength(segment, "utf-8");
} else {
const segBytes = Buffer.byteLength(segment, "utf-8");
const remaining = max - this.#currentLineBytes;
if (segBytes <= remaining) {
parts.push(segment);
this.#currentLineBytes += segBytes;
} else {
const ellipsisBytes = 3;
const headRoom = Math.max(0, remaining - ellipsisBytes);
let kept = "";
let keptBytes = 0;
if (headRoom > 0) {
const sliced = truncateHeadBytes(segment, headRoom);
kept = sliced.text;
keptBytes = sliced.bytes;
parts.push(kept);
}
parts.push(ELLIPSIS);
this.#columnDroppedBytes += segBytes - keptBytes;
this.#columnTruncatedLines++;
this.#currentLineBytes += keptBytes + ellipsisBytes;
this.#columnEllipsisAdded = true;
}
}
}
if (nlIdx === -1) break;
parts.push(NL);
this.#currentLineBytes = 0;
this.#columnEllipsisAdded = false;
cursor = nlIdx + 1;
}
return parts.join("");
}
#willOverflow(dataBytes: number): boolean {
return this.#bufferBytes + dataBytes > this.#spillThreshold;
}
#pushTail(chunk: string, dataBytes: number): void {
if (dataBytes === 0) return;
const threshold = this.#spillThreshold;
const willOverflow = this.#bufferBytes + dataBytes > threshold;
if (!willOverflow) {
this.#buffer += chunk;
this.#bufferBytes += dataBytes;
return;
}
this.#truncated = true;
if (dataBytes >= threshold) {
const { text, bytes } = truncateTailBytes(chunk, threshold);
this.#buffer = text;
this.#bufferBytes = bytes;
} else {
this.#buffer += chunk;
this.#bufferBytes += dataBytes;
const { text, bytes } = truncateTailBytes(this.#buffer, threshold);
this.#buffer = text;
this.#bufferBytes = bytes;
}
}
* Write a chunk to the artifact file. Handles the async file sink creation
* by queuing writes until the sink is ready, then draining synchronously.
*/
#writeToFile(chunk: string): void {
if (this.#fileReady && this.#file) {
this.#file.sink.write(chunk);
return;
}
if (!this.#pendingFileWrites) {
this.#pendingFileWrites = [chunk];
void this.#createFileSink();
} else {
this.#pendingFileWrites.push(chunk);
}
}
async #createFileSink(): Promise<void> {
if (!this.#artifactPath || this.#fileReady) return;
try {
const sink = Bun.file(this.#artifactPath).writer();
this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
if (this.#buffer.length > 0) {
sink.write(this.#buffer);
}
if (this.#pendingFileWrites) {
for (const pending of this.#pendingFileWrites) {
sink.write(pending);
}
this.#pendingFileWrites = undefined;
}
this.#fileReady = true;
} catch {
try {
await this.#file?.sink?.end();
} catch {
}
this.#file = undefined;
this.#pendingFileWrites = undefined;
}
}
createInput(): WritableStream<Uint8Array | string> {
const dec = new TextDecoder("utf-8", { ignoreBOM: true });
const finalize = () => {
this.push(dec.decode());
};
return new WritableStream({
write: chunk => {
this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
},
close: finalize,
abort: finalize,
});
}
* Replace the in-memory buffer with the given text. Used when an upstream
* minimizer rewrites the captured output after the raw bytes have already
* been streamed.
*
* After this call the buffer is authoritative: streaming counters realign
* to the replacement, the retained head window is cleared, and head
* retention is disabled so subsequent `push()` calls append directly to the
* tail buffer instead of repopulating the (now meaningless) head window
* — which would otherwise reorder content and trip the middle-elision
* branch in `dump()` against stale totals.
*/
replace(text: string): void {
this.#buffer = text;
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
this.#head = "";
this.#headBytes = 0;
this.#headLines = 0;
this.#headRetentionDisabled = true;
this.#totalBytes = this.#bufferBytes;
this.#totalLines = countNewlines(text);
this.#sawData = text.length > 0;
this.#truncated = false;
this.#currentLineBytes = 0;
this.#columnEllipsisAdded = false;
this.#columnDroppedBytes = 0;
this.#columnTruncatedLines = 0;
}
async dump(notice?: string): Promise<OutputSummary> {
const noticeLine = notice ? `[${notice}]\n` : "";
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
if (this.#file) await this.#file.sink.end();
const headBytes = this.#headBytes;
const tailBuf = this.#buffer;
const tailBytes = this.#bufferBytes;
const headLines = this.#headLines + (headBytes > 0 && !this.#head.endsWith("\n") ? 1 : 0);
const tailLines = tailBuf.length > 0 ? countNewlines(tailBuf) + 1 : 0;
const effectiveTotalBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes);
let body: string;
let outputBytes: number;
let outputLines: number;
let elidedBytes: number | undefined;
let elidedLines: number | undefined;
if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) {
elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes);
elidedLines = Math.max(0, totalLines - headLines - tailLines);
const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
const markerBytes = Buffer.byteLength(marker, "utf-8");
const headSep = this.#head.endsWith("\n") ? "" : "\n";
const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
body = `${this.#head}${headSep}${marker}${tailSep}${tailBuf}`;
outputBytes =
headBytes +
markerBytes +
tailBytes +
Buffer.byteLength(headSep, "utf-8") +
Buffer.byteLength(tailSep, "utf-8");
outputLines = headLines + 1 + tailLines;
this.#truncated = true;
} else if (headBytes > 0) {
body = `${this.#head}${tailBuf}`;
outputBytes = headBytes + tailBytes;
outputLines = body.length > 0 ? countNewlines(body) + 1 : 0;
} else {
body = tailBuf;
outputBytes = tailBytes;
outputLines = tailLines;
}
return {
output: `${noticeLine}${body}`,
truncated: this.#truncated,
totalLines,
totalBytes: this.#totalBytes,
outputLines,
outputBytes,
elidedBytes,
elidedLines,
columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
artifactId: this.#file?.artifactId,
};
}
}
* Format a truncation notice for tail-truncated output (bash, python, ssh).
* Returns empty string if not truncated.
*/
export function formatTailTruncationNotice(
truncation: TruncationResult,
options: TailTruncationNoticeOptions = {},
): string {
if (!truncation.truncated) return "";
const { fullOutputPath, originalContent, suffix = "" } = options;
const startLine = truncation.totalLines - (truncation.outputLines ?? truncation.totalLines) + 1;
const endLine = truncation.totalLines;
const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
let notice: string;
if (truncation.lastLinePartial) {
let lastLineSizePart = "";
if (originalContent) {
const lastNl = originalContent.lastIndexOf(NL);
const lastLine = lastNl === -1 ? originalContent : originalContent.substring(lastNl + 1);
lastLineSizePart = ` (line is ${formatBytes(Buffer.byteLength(lastLine, "utf-8"))})`;
}
notice = `[Showing last ${formatBytes(truncation.outputBytes ?? truncation.totalBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
} else {
notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
}
return `\n\n${notice}`;
}
* Format a truncation notice for head-truncated output (read tool).
* Returns empty string if not truncated.
*/
export function formatHeadTruncationNotice(
truncation: TruncationResult,
options: HeadTruncationNoticeOptions = {},
): string {
if (!truncation.truncated) return "";
const startLineDisplay = options.startLine ?? 1;
const totalFileLines = options.totalFileLines ?? truncation.totalLines;
const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
const nextOffset = endLineDisplay + 1;
const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use :${nextOffset} to continue]`;
return `\n\n${notice}`;
}
* Build an onChunk handler that appends to a TailBuffer and emits a streaming
* update (when `onUpdate` is defined) with the buffer's current text.
*/
export function streamTailUpdates<TDetails, TInput = unknown>(
tailBuffer: TailBuffer,
onUpdate: AgentToolUpdateCallback<TDetails, TInput> | undefined,
): (chunk: string) => void {
return chunk => {
tailBuffer.append(chunk);
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: tailBuffer.text() }],
details: {} as TDetails,
});
}
};
}