@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
214 lines • 7.9 kB
JavaScript
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