/** * Execute a search process with streaming output capture. * * stdout is streamed to both an in-memory preview (truncated to pi's standard * limits) and a temp file (full output). stderr is collected up to a bound. * When the output is not truncated the temp file is removed; otherwise it is * retained and its path returned so the caller can inform the LLM. */ import { dirname, join } from "node:path"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead, } from "@earendil-works/pi-coding-agent"; import { Effect, FileSystem, Stream } from "effect"; import { ChildProcess } from "effect/unstable/process"; import type { CapturedOutput } from "./output.ts"; const STDERR_MAX_BYTES = 64 * 1024; // --------------------------------------------------------------------------- // Preview state machine // --------------------------------------------------------------------------- interface PreviewState { readonly decoder: InstanceType; preview: string; totalBytes: number; lineBreaks: number; trailingLineBreaks: number; truncated: boolean; } function makePreviewState(): PreviewState { return { decoder: new TextDecoder(), preview: "", totalBytes: 0, lineBreaks: 0, trailingLineBreaks: 0, truncated: false, }; } function observeStdout(state: PreviewState, chunk: Uint8Array): void { state.totalBytes += chunk.byteLength; for (const byte of chunk) { if (byte === 0x0a) { state.lineBreaks++; state.trailingLineBreaks++; } else { state.trailingLineBreaks = 0; } } if (state.truncated) return; state.preview += state.decoder.decode(chunk, { stream: true }); const truncation = truncateHead(state.preview, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); if (truncation.truncated) { state.preview = truncation.content; state.truncated = true; } } function finishStdout( state: PreviewState, fullOutputPath: string, ): CapturedOutput { if (!state.truncated) { state.preview += state.decoder.decode(); } const totalBytes = state.totalBytes - state.trailingLineBreaks; const lineCount = totalBytes === 0 ? 0 : state.lineBreaks - state.trailingLineBreaks + 1; return { preview: state.preview, lineCount, totalBytes, truncated: state.truncated, fullOutputPath: state.truncated ? fullOutputPath : undefined, }; } // --------------------------------------------------------------------------- // stderr capture // --------------------------------------------------------------------------- function collectStderr( stream: Stream.Stream, ): Effect.Effect { return Stream.runFold( stream, () => Buffer.alloc(0), (captured, chunk) => { if (captured.byteLength >= STDERR_MAX_BYTES) return captured; const remaining = STDERR_MAX_BYTES - captured.byteLength; return Buffer.concat([captured, chunk.subarray(0, remaining)]); }, ).pipe(Effect.map((bytes) => bytes.toString("utf8"))); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface SearchProcessResult { readonly code: number; readonly stderr: string; readonly output: CapturedOutput; } export interface ExecuteSearchProcessOptions { readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly tempPrefix: string; } export function executeSearchProcess( options: ExecuteSearchProcessOptions, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectory({ prefix: options.tempPrefix, }); const fullOutputPath = join(directory, "output.txt"); let retainDirectory = false; return yield* Effect.gen(function* () { const preview = makePreviewState(); const process = yield* ChildProcess.make(options.command, options.args, { cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); const result = yield* Effect.all( { exitCode: process.exitCode, stdout: process.stdout.pipe( Stream.orDie, Stream.tap((chunk) => Effect.sync(() => observeStdout(preview, chunk)), ), Stream.run(fs.sink(fullOutputPath)), ), stderr: collectStderr(process.stderr.pipe(Stream.orDie)), }, { concurrency: "unbounded" }, ); const output = finishStdout(preview, fullOutputPath); retainDirectory = output.truncated; return { code: Number(result.exitCode), stderr: result.stderr, output, }; }).pipe( Effect.ensuring( Effect.suspend(() => retainDirectory ? Effect.void : fs .remove(directory, { recursive: true, force: true }) .pipe(Effect.orDie), ), ), ); }).pipe(Effect.scoped); } /** * Remove the temp directory backing a captured output when the search * produced an error and the output should not be retained. */ export function discardCapturedOutput( output: CapturedOutput, ) { if (!output.fullOutputPath) return Effect.void; const directory = dirname(output.fullOutputPath); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.remove(directory, { recursive: true, force: true }); }); }