Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/core/tools/pi/output-accumulator.ts

typescript310 行查看原文
// Adapted from Pi c20cb09772bf4e2590a316cb54514cef76df4293.
// Copyright (c) 2025 Mario Zechner. MIT license: licenses/third-party-notices.txt.
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { open, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts";

export interface OutputAccumulatorOptions {
  maxLines?: number;
  maxBytes?: number;
  tempFilePrefix?: string;
}

export interface OutputSnapshot {
  content: string;
  truncation: TruncationResult;
  fullOutputPath?: string;
}

export interface FullOutput {
  content: string;
  /** Whether `content` omits part of the output. */
  truncated: boolean;
}

function defaultTempFilePath(prefix: string): string {
  const id = randomBytes(8).toString("hex");
  return join(tmpdir(), `${prefix}-${id}.log`);
}

/** Keep recent output available to read, and prune expired logs on the next bash invocation. */
export async function cleanupOutputFiles(prefix: string, directory = tmpdir()): Promise<void> {
  const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
  for (const file of await readdir(directory, { withFileTypes: true })) {
    if (!file.isFile() || !file.name.startsWith(`${prefix}-`)) continue;
    if (!/^[a-f0-9]{16}\.log$/.test(file.name.slice(prefix.length + 1))) continue;
    const path = join(directory, file.name);
    try {
      if ((await stat(path)).mtimeMs < cutoff) await rm(path, { force: true });
    } catch {
      // Optional cleanup must not prevent a command from running.
    }
  }
}

function byteLength(text: string): number {
  return Buffer.byteLength(text, "utf-8");
}

/**
 * Incrementally tracks streaming output with bounded memory when producers await `append`.
 *
 * 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 {
  private readonly maxLines: number;
  private readonly maxBytes: number;
  private readonly maxRollingBytes: number;
  private readonly tempFilePrefix: string;
  private readonly decoder = new TextDecoder();

  private rawChunks: Buffer[] = [];
  private tailText = "";
  private tailBytes = 0;
  private tailStartsAtLineBoundary = true;
  private totalRawBytes = 0;
  private totalDecodedBytes = 0;
  private completedLines = 0;
  private totalLines = 0;
  private currentLineBytes = 0;
  private hasOpenLine = false;
  private finished = false;

  private tempFilePath: string | undefined;
  private tempFileStream: WriteStream | undefined;
  private tempFileError: Error | undefined;
  private tempFileDrain: Promise<void> | undefined;

  constructor(options: OutputAccumulatorOptions = {}) {
    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";
  }

  /** Await the returned promise, when present, before appending another chunk. */
  append(data: Buffer): void | Promise<void> {
    if (this.finished) {
      throw new Error("Cannot append to a finished output accumulator");
    }

    if (this.tempFileError) throw this.tempFileError;

    this.totalRawBytes += data.length;
    this.appendDecodedText(this.decoder.decode(data, { stream: true }));

    if (this.tempFileStream || this.shouldUseTempFile()) {
      this.ensureTempFile();
      const stream = this.tempFileStream;
      if (stream && !stream.write(data)) return this.waitForTempFileDrain(stream);
    } else if (data.length > 0) {
      this.rawChunks.push(data);
    }
  }

  finish(): void {
    if (this.finished) {
      return;
    }
    this.finished = true;
    this.appendDecodedText(this.decoder.decode());
    if (this.shouldUseTempFile()) {
      this.ensureTempFile();
    }
  }

  snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot {
    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: TruncationResult = {
      ...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(): Promise<void> {
    if (!this.tempFileStream) {
      return;
    }

    const stream = this.tempFileStream;
    if (!stream.closed)
      await new Promise<void>((resolve) => {
        stream.once("close", resolve);
        stream.end();
      });
    this.tempFileStream = undefined;
    if (this.tempFileError) {
      if (this.tempFilePath) await rm(this.tempFilePath, { force: true }).catch(() => {});
      throw this.tempFileError;
    }
  }

  /**
   * 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: number): Promise<FullOutput> {
    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(): number {
    return this.currentLineBytes;
  }

  private appendDecodedText(text: string): void {
    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);
  }

  private trimTail(): void {
    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);
  }

  private getSnapshotText(): string {
    if (this.tailStartsAtLineBoundary) {
      return this.tailText;
    }

    const firstNewline = this.tailText.indexOf("\n");
    return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
  }

  private shouldUseTempFile(): boolean {
    return (
      this.totalRawBytes > this.maxBytes ||
      this.totalDecodedBytes > this.maxBytes ||
      this.totalLines > this.maxLines
    );
  }

  private waitForTempFileDrain(stream: WriteStream): Promise<void> {
    if (this.tempFileDrain) return this.tempFileDrain;
    const drain = new Promise<void>((resolve, reject) => {
      const finish = () => {
        stream.removeListener("drain", finish);
        stream.removeListener("error", finish);
        stream.removeListener("close", finish);
        this.tempFileDrain = undefined;
        if (this.tempFileError) reject(this.tempFileError);
        else if (stream.destroyed && !stream.writableFinished)
          reject(new Error("Output file closed before draining"));
        else resolve();
      };
      stream.once("drain", finish);
      stream.once("error", finish);
      stream.once("close", finish);
    });
    // Legacy synchronous producers can ignore the promise. closeTempFile still reports disk errors.
    void drain.catch(() => {});
    this.tempFileDrain = drain;
    return drain;
  }

  private ensureTempFile(): void {
    if (this.tempFilePath) {
      return;
    }
    this.tempFilePath = defaultTempFilePath(this.tempFilePrefix);
    this.tempFileStream = createWriteStream(this.tempFilePath, { mode: 0o600 });
    this.tempFileStream.on("error", (error) => {
      this.tempFileError = error;
    });
    for (const chunk of this.rawChunks) {
      this.tempFileStream.write(chunk);
    }
    this.rawChunks = [];
  }
}