Files
oh-my-pi/packages/coding-agent/src/session/streaming-output.ts
T
can1357 c61a5b9a56 feat(config): added minimizer output fields and compact shell summaries
- Added minimizer API fields (`MinimizerResult.text`, `settingsHash`) and shell options for minimized output behavior.
- Changed bash execution to print minimized text output and append artifact footers only after minimized output is saved.
- Added UTF-8-safe shell streaming with `on_chunk` callback routing and `replace()` output sink updates.
- Added core filter enhancements for git, cargo, go, and listing/python outputs to produce compact summaries.
- Added RTK command filters and fixtures to strip noise, truncate output, and normalize success fallbacks.
2026-04-24 11:13:43 +02:00

788 lines
23 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";
// =============================================================================
// Interfaces
// =============================================================================
export interface OutputSummary {
output: string;
truncated: boolean;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
/** Artifact ID for internal URL access (artifact://<id>) when truncated */
artifactId?: string;
}
export interface OutputSinkOptions {
artifactPath?: string;
artifactId?: string;
spillThreshold?: 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";
totalLines: number;
totalBytes: number;
outputLines?: number;
outputBytes?: number;
lastLinePartial?: boolean;
firstLineExceedsLimit?: boolean;
}
export interface TruncationOptions {
/** Maximum number of lines (default: 3000) */
maxLines?: number;
/** Maximum number of bytes (default: 50KB) */
maxBytes?: 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,
};
}
// =============================================================================
// 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;
#totalLines = 0; // newline count
#totalBytes = 0;
#sawData = false;
#truncated = false;
#lastChunkTime = 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 #onChunk?: (chunk: string) => void;
readonly #chunkThrottleMs: number;
constructor(options?: OutputSinkOptions) {
const {
artifactPath,
artifactId,
spillThreshold = DEFAULT_MAX_BYTES,
onChunk,
chunkThrottleMs = 0,
} = options ?? {};
this.#artifactPath = artifactPath;
this.#artifactId = artifactId;
this.#spillThreshold = spillThreshold;
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.
if (this.#onChunk) {
const now = Date.now();
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
this.#lastChunkTime = now;
this.#onChunk(chunk);
}
}
const dataBytes = Buffer.byteLength(chunk, "utf-8");
this.#totalBytes += dataBytes;
if (chunk.length > 0) {
this.#sawData = true;
this.#totalLines += countNewlines(chunk);
}
const threshold = this.#spillThreshold;
const willOverflow = this.#bufferBytes + dataBytes > threshold;
// Write to artifact file if configured and past the threshold
if (this.#artifactPath && (this.#file != null || willOverflow)) {
this.#writeToFile(chunk);
}
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;
}
if (this.#file) this.#truncated = true;
}
/**
* 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.
*/
replace(text: string): void {
this.#buffer = text;
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
}
async dump(notice?: string): Promise<OutputSummary> {
const noticeLine = notice ? `[${notice}]\n` : "";
const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
if (this.#file) await this.#file.sink.end();
return {
output: `${noticeLine}${this.#buffer}`,
truncated: this.#truncated,
totalLines,
totalBytes: this.#totalBytes,
outputLines,
outputBytes: this.#bufferBytes,
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 sel=L${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,
});
}
};
}