Files
oh-my-pi/packages/coding-agent/src/session/streaming-output.ts
T
can1357 28b9ce7a0c feat(coding-agent): added middle-elision caps to OutputSink truncation
- Added `tools.artifactHeadBytes` and `tools.outputMaxColumns` settings with defaults in `SETTINGS_SCHEMA`.
- Expanded `OutputSink` with `headBytes`/`maxColumns` and middle truncate logic with elision markers and tracking.
- Updated output-meta to resolve sink settings, emit truncation metrics, and use `truncateMiddle` for spills.
- Integrated head and column limits into JS/Python/Bash/SSH/read output flows, with `:raw` skipping read truncation.
- Documented new output middle-elision and column-cap behavior in `CHANGELOG.md`.
- Added truncation tests for `OutputSink`, `truncateMiddle`, and read-tool line handling.
2026-05-13 11:19:11 +02:00

1084 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { formatBytes } from "../tools/render-utils";
import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
// =============================================================================
// Constants
// =============================================================================
export const DEFAULT_MAX_LINES = 3000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
const NL = "\n";
const ELLIPSIS = "…";
// =============================================================================
// Interfaces
// =============================================================================
export interface OutputSummary {
output: string;
truncated: boolean;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
/** Bytes elided from the middle when head-retain mode is active. */
elidedBytes?: number;
/** Lines elided from the middle when head-retain mode is active. */
elidedLines?: number;
/** Bytes dropped by the per-line column cap (sum across all lines). */
columnDroppedBytes?: number;
/** Number of distinct lines that hit the per-line column cap. */
columnTruncatedLines?: number;
/** Artifact ID for internal URL access (artifact://<id>) when truncated */
artifactId?: string;
}
export interface OutputSinkOptions {
artifactPath?: string;
artifactId?: string;
/** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
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;
/** Minimum ms between onChunk calls. 0 = every chunk (default). */
chunkThrottleMs?: number;
}
export interface TruncationResult {
content: string;
truncated?: boolean;
truncatedBy?: "lines" | "bytes" | "middle";
totalLines: number;
totalBytes: number;
outputLines?: number;
outputBytes?: number;
/** Bytes elided from the middle (truncateMiddle only). */
elidedBytes?: number;
/** Lines elided from the middle (truncateMiddle only). */
elidedLines?: number;
lastLinePartial?: boolean;
firstLineExceedsLimit?: boolean;
}
export interface TruncationOptions {
/** Maximum number of lines (default: 3000) */
maxLines?: number;
/** Maximum number of bytes (default: 50KB) */
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;
}
/** Result from byte-level truncation helpers. */
export interface ByteTruncationResult {
text: string;
bytes: number;
}
export interface TailTruncationNoticeOptions {
fullOutputPath?: string;
originalContent?: string;
suffix?: string;
}
export interface HeadTruncationNoticeOptions {
startLine?: number;
totalFileLines?: number;
}
// =============================================================================
// Internal low-level helpers
// =============================================================================
/** Count newline characters via native substring search. */
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;
}
/** Zero-copy view of a Uint8Array as a Buffer (copies only if already a Buffer). */
function asBuffer(data: Uint8Array): Buffer {
return Buffer.isBuffer(data) ? (data as Buffer) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
/** Advance past UTF-8 continuation bytes (10xxxxxx) to a leading byte. */
function findUtf8BoundaryForward(buf: Buffer, pos: number): number {
let i = Math.max(0, pos);
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i++;
return i;
}
/** Retreat past UTF-8 continuation bytes to land on a leading byte. */
function findUtf8BoundaryBackward(buf: Buffer, cut: number): number {
let i = Math.min(buf.length, Math.max(0, cut));
// If the cut is at end-of-buffer, it's already a valid boundary.
if (i >= buf.length) return buf.length;
while (i > 0 && (buf[i] & 0xc0) === 0x80) i--;
return i;
}
// =============================================================================
// Byte-level truncation (windowed encoding)
// =============================================================================
function truncateBytesWindowed(
data: string | Uint8Array,
maxBytesRaw: number,
mode: "head" | "tail",
): ByteTruncationResult {
const maxBytes = maxBytesRaw;
if (maxBytes === 0) return { text: "", bytes: 0 };
// --------------------------
// String path (windowed)
// --------------------------
if (typeof data === "string") {
// Fast non-truncation check only when it *might* fit.
if (data.length <= maxBytes) {
const len = Buffer.byteLength(data, "utf-8");
if (len <= maxBytes) return { text: data, bytes: len };
// else: multibyte-heavy string; fall through to truncation using full string as window.
}
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 };
}
}
// --------------------------
// Uint8Array / Buffer path
// --------------------------
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");
}
// =============================================================================
// Line-level utilities
// =============================================================================
/**
* 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 };
}
// =============================================================================
// Content truncation (line + byte aware, no full Buffer allocation)
// =============================================================================
/** Shared helper to build a no-truncation result. */
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; // char index where we cut (exclusive)
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;
// No room even for separators / bytes.
if (remaining < 0) {
truncatedBy = "bytes";
break;
}
// Fast reject huge lines without slicing/encoding:
// UTF-8 bytes >= UTF-16 code units, so if code units exceed remaining, bytes must exceed too.
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;
}
// Small slice (bounded by remaining <= maxBytes) for exact UTF-8 byte count.
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;
}
// Include the line (join semantics: no trailing newline after the last included line).
bytesUsed += sepBytes + lineBytes;
includedLines++;
cutIndex = nl === -1 ? content.length : nl; // exclude the newline after the last included line
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; // char index where output starts
let end = content.length; // char index where current line ends (exclusive)
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;
// Fast reject huge line without slicing/encoding.
if (lineCodeUnits > remaining) {
truncatedBy = "bytes";
if (includedLines === 0) {
// Window the line substring to avoid materializing a giant string.
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; // exclude the newline itself; it'll be accounted as sepBytes in the next iteration
}
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,
};
}
// =============================================================================
// Middle elision (keep head + tail, drop middle)
// =============================================================================
/**
* 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);
}
// Degenerate budgets → fall back to one-sided truncation.
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");
// Head unusable (first line exceeds budget) → tail-only.
if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
// Tail unusable → head-only.
if (tailLinesKept === 0) return head;
// Windows overlap → no meaningful elision; return content untruncated.
if (headLinesKept + tailLinesKept >= totalLines) {
return noTruncResult(content, totalLines, totalBytes);
}
const elidedLines = totalLines - headLinesKept - tailLinesKept;
// `totalBytes - headBytesKept - tailBytesKept` includes newline separators
// between the kept windows and the elided region; close enough for a notice.
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,
};
}
// =============================================================================
// TailBuffer — ring-style tail buffer with lazy joining
// =============================================================================
const MAX_PENDING = 10;
export class TailBuffer {
#pending: string[] = [];
#pos = 0; // byte count of the currently-held tail (after trims)
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 the incoming chunk alone is >= budget, it fully dominates the tail.
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();
}
// Trim when we exceed 2× budget to amortize cost.
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;
}
// -- private ---------------------------------------------------------------
#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;
}
}
// =============================================================================
// OutputSink — line-buffered output with file spill support
// =============================================================================
export class OutputSink {
#buffer = "";
#bufferBytes = 0;
#head = "";
#headBytes = 0;
#headLines = 0; // newline count inside #head
#totalLines = 0; // newline count
#totalBytes = 0;
#sawData = false;
#truncated = false;
#lastChunkTime = 0;
// Per-line column cap streaming state (persists across `push` calls so a
// long line split across chunks still trips the same trigger).
#currentLineBytes = 0;
#columnEllipsisAdded = false;
#columnDroppedBytes = 0;
#columnTruncatedLines = 0;
#file?: {
path: string;
artifactId?: string;
sink: Bun.FileSink;
};
// Queue of chunks waiting for the file sink to be created.
#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);
// Throttled onChunk: only call the callback when enough time has passed.
// Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
// what reached the sink — the column cap is for the persisted LLM view.
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);
}
// Per-line column cap. State persists across chunks so a mid-line split
// still respects the budget. Operates on the sanitized chunk; the cap is
// applied before head/tail accounting but after artifact mirroring decides.
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;
// Mirror RAW chunk to the artifact file so the on-disk record is the full
// uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's
// column cap dropped bytes (otherwise we'd lose data) OR file already open.
if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) {
this.#writeToFile(chunk);
}
if (cappedBytes === 0) return;
// Head retention: drain the (capped) chunk into #head until the budget is
// exhausted, then forward any leftover to the tail buffer.
let tailChunk = capped;
let tailBytes = cappedBytes;
if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) {
const room = this.#headLimit - this.#headBytes;
if (cappedBytes <= room) {
this.#head += capped;
this.#headBytes += cappedBytes;
this.#headLines += countNewlines(capped);
return;
}
// Split: head takes a UTF-8-safe prefix; remainder flows to tail.
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) {
// Past the cap; drop until newline.
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 {
// First overflow on this line: keep what fits, append ellipsis,
// arm the skip-until-newline flag.
const ellipsisBytes = 3; // "…" in UTF-8
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 {
// Triggers file mirroring as soon as the next chunk would push us over
// the tail budget (head retention does not change spill-to-artifact).
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;
}
// Overflow: keep only a tail window in memory.
this.#truncated = true;
// Avoid creating a giant intermediate string when chunk alone dominates.
if (dataBytes >= threshold) {
const { text, bytes } = truncateTailBytes(chunk, threshold);
this.#buffer = text;
this.#bufferBytes = bytes;
} else {
// Intermediate size is bounded (<= threshold + dataBytes), safe to concat.
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) {
// Fast path: file sink exists, write synchronously
this.#file.sink.write(chunk);
return;
}
// File sink not yet created — queue this chunk and kick off creation
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 };
// Flush existing buffer to file BEFORE it gets trimmed further.
if (this.#buffer.length > 0) {
sink.write(this.#buffer);
}
// Drain any chunks that arrived while the sink was being created
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 {
/* ignore */
}
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 while preserving the
* streaming counters (totalLines/totalBytes reflect the raw chunks that
* already reached the sink). Used when an upstream minimizer rewrites the
* captured output after the raw bytes have already been streamed.
*
* Clears any retained head window — the minimized text is authoritative.
*/
replace(text: string): void {
this.#buffer = text;
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
this.#head = "";
this.#headBytes = 0;
this.#headLines = 0;
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();
// Compose the visible output. With head retention, splice head + marker
// + tail when content was elided. Otherwise return the rolling buffer.
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;
// Bytes that survived the column cap. Middle elision operates on these,
// so column-dropped bytes don't inflate the "elided from middle" count.
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) {
// Middle was elided. Emit head + marker + tail.
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) {
// Head + tail combine into the full buffered output (no overlap or elision).
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,
};
}
}
// =============================================================================
// Truncation notice formatting
// =============================================================================
/**
* 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}`;
}
// =============================================================================
// Streaming tail update helper (shared by bash/ssh tools)
// =============================================================================
/**
* 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,
});
}
};
}