UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

214 lines • 7.9 kB
import { open } from "node:fs/promises"; import { createOutputFileStream } from "../../utils/output-files.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "./truncate.js"; function byteLength(text) { return Buffer.byteLength(text, "utf-8"); } /** * Incrementally tracks streaming output with bounded memory. * * Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded * tail for display snapshots, and opens a temp file when the full output needs * to be preserved. */ export class OutputAccumulator { maxLines; maxBytes; maxRollingBytes; tempFilePrefix; decoder = new TextDecoder(); rawChunks = []; tailText = ""; tailBytes = 0; tailStartsAtLineBoundary = true; totalRawBytes = 0; totalDecodedBytes = 0; completedLines = 0; totalLines = 0; currentLineBytes = 0; hasOpenLine = false; finished = false; tempFilePath; tempFileStream; constructor(options = {}) { this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES; this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; this.maxRollingBytes = Math.max(this.maxBytes * 2, 1); this.tempFilePrefix = options.tempFilePrefix ?? "pi-output"; } append(data) { if (this.finished) { throw new Error("Cannot append to a finished output accumulator"); } this.totalRawBytes += data.length; this.appendDecodedText(this.decoder.decode(data, { stream: true })); if (this.tempFileStream || this.shouldUseTempFile()) { this.ensureTempFile(); this.tempFileStream?.write(data); } else if (data.length > 0) { this.rawChunks.push(data); } } finish() { if (this.finished) { return; } this.finished = true; this.appendDecodedText(this.decoder.decode()); if (this.shouldUseTempFile()) { this.ensureTempFile(); } } snapshot(options = {}) { const tailTruncation = truncateTail(this.getSnapshotText(), { maxLines: this.maxLines, maxBytes: this.maxBytes, }); const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes; const truncatedBy = truncated ? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines")) : null; const truncation = { ...tailTruncation, truncated, truncatedBy, totalLines: this.totalLines, totalBytes: this.totalDecodedBytes, maxLines: this.maxLines, maxBytes: this.maxBytes, }; if (options.persistIfTruncated && truncation.truncated) { this.ensureTempFile(); } return { content: truncation.content, truncation, fullOutputPath: this.tempFilePath, }; } async closeTempFile() { if (!this.tempFileStream) { return; } const stream = this.tempFileStream; this.tempFileStream = undefined; await new Promise((resolve, reject) => { const onError = (error) => { stream.off("finish", onFinish); reject(error); }; const onFinish = () => { stream.off("error", onError); resolve(); }; stream.once("error", onError); stream.once("finish", onFinish); stream.end(); }); } /** * The complete output, for callers that can take more than the display snapshot. Call after * `finish()` and `closeTempFile()`. Output longer than `maxBytes` raw bytes keeps its first and * last `maxBytes / 2` bytes around an omission marker. */ async readFullOutput(maxBytes) { if (!this.tempFilePath) { return { content: new TextDecoder().decode(Buffer.concat(this.rawChunks)), truncated: false }; } const file = await open(this.tempFilePath, "r"); try { const size = (await file.stat()).size; if (size <= maxBytes) { return { content: new TextDecoder().decode(await file.readFile()), truncated: false }; } const headBytes = Math.floor(maxBytes / 2); const tailBytes = maxBytes - headBytes; const head = Buffer.alloc(headBytes); const tail = Buffer.alloc(tailBytes); await file.read(head, 0, headBytes, 0); await file.read(tail, 0, tailBytes, size - tailBytes); // Cut at character boundaries: streaming decode holds back an incomplete trailing sequence, // and the tail skips leading continuation bytes. const headText = new TextDecoder().decode(head, { stream: true }); let tailStart = 0; while (tailStart < tail.length && (tail[tailStart] & 0xc0) === 0x80) tailStart++; const tailText = new TextDecoder().decode(tail.subarray(tailStart)); const omitted = size - headBytes - tailBytes; return { content: `${headText}\n\n[... ${omitted} bytes omitted ...]\n\n${tailText}`, truncated: true }; } finally { await file.close(); } } getLastLineBytes() { return this.currentLineBytes; } appendDecodedText(text) { if (text.length === 0) { return; } const bytes = byteLength(text); this.totalDecodedBytes += bytes; this.tailText += text; this.tailBytes += bytes; if (this.tailBytes > this.maxRollingBytes * 2) { this.trimTail(); } let newlines = 0; let lastNewline = -1; for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) { newlines++; lastNewline = i; } if (newlines === 0) { this.currentLineBytes += bytes; this.hasOpenLine = true; } else { this.completedLines += newlines; const tail = text.slice(lastNewline + 1); this.currentLineBytes = byteLength(tail); this.hasOpenLine = tail.length > 0; } this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); } trimTail() { const buffer = Buffer.from(this.tailText, "utf-8"); if (buffer.length <= this.maxRollingBytes) { this.tailBytes = buffer.length; return; } let start = buffer.length - this.maxRollingBytes; while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) { start++; } this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a; this.tailText = buffer.subarray(start).toString("utf-8"); this.tailBytes = byteLength(this.tailText); } getSnapshotText() { if (this.tailStartsAtLineBoundary) { return this.tailText; } const firstNewline = this.tailText.indexOf("\n"); return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1); } shouldUseTempFile() { return (this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines); } ensureTempFile() { if (this.tempFilePath) { return; } const { path, stream } = createOutputFileStream(this.tempFilePrefix, ".log"); this.tempFilePath = path; this.tempFileStream = stream; for (const chunk of this.rawChunks) { this.tempFileStream.write(chunk); } this.rawChunks = []; } } //# sourceMappingURL=output-accumulator.js.map