import type { ExtensionAPI, ToolResultEvent } from "@earendil-works/pi-coding-agent"; import { isBashToolResult, isPowerShellToolResult } from "@earendil-works/pi-coding-agent"; import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { formatCapture, truncateUtf8 } from "./format.ts"; import { saveResult, type ResultReference } from "./results.ts"; import { createManagedToolPolicy, AXI_RECOVERY_TOOLS, type ManagedToolPolicy } from "../policy/index.ts"; const PREVIEW_BYTES = 12 * 1024; const PREVIEW_LINES = 200; const FOOTER_SLACK = 128; const MAX_CAPTURE_BYTES = 64 * 1024 * 1024; type ToolResultEventResult = { content?: ToolResultEvent["content"]; details?: unknown; isError?: boolean }; interface AxiHookDetails { truncation?: { truncated?: boolean }; fullOutputPath?: string; [key: string]: unknown; } function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } function tailUtf8(text: string, maxBytes: number): string { const bytes = Buffer.from(text, "utf8"); let start = Math.max(0, bytes.length - maxBytes); while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start)); } function validSpillPath(path: string): boolean { const root = resolve(tmpdir()); const resolved = resolve(path); return dirname(resolved) === root && /^pi-(?:bash|powershell)-[0-9a-f]{16}\.log$/i.test(basename(resolved)); } function shellCommand(event: ToolResultEvent): string | undefined { if (!isBashToolResult(event) && !isPowerShellToolResult(event)) return undefined; return typeof event.input.command === "string" ? event.input.command : undefined; } function hasAxiMetadata(details: unknown): boolean { const axi = record(record(details)?.axi); return typeof axi?.result === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(axi.result) && axi.stream === "formatted"; } function isShellResult(event: ToolResultEvent): boolean { return isBashToolResult(event) || isPowerShellToolResult(event); } function isTarget(event: ToolResultEvent, policy: ManagedToolPolicy): boolean { if (hasAxiMetadata(event.details) || AXI_RECOVERY_TOOLS.has(event.toolName)) return false; if (isShellResult(event)) { const command = shellCommand(event); return command !== undefined && policy.isManagedCommand(command); } return policy.isManagedPlugin(event.toolName); } type Capture = { stdout: string; stderr: string; captureComplete: boolean }; async function readSpill(event: ToolResultEvent, path: string): Promise { let file: Awaited> | undefined; try { file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); const info = await file.stat(); if (!info.isFile() || info.size === 0 || info.size > MAX_CAPTURE_BYTES) return undefined; const chunks: Buffer[] = []; for (let position = 0; position < info.size;) { const length = Math.min(64 * 1024, info.size - position); const buffer = Buffer.allocUnsafe(length); const read = await file.read(buffer, 0, length, position); if (read.bytesRead !== length) return undefined; chunks.push(buffer); position += read.bytesRead; } if ((await file.stat()).size !== info.size) return undefined; return { stdout: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)), stderr: event.isError && event.content[0]?.type === "text" ? event.content[0].text : "", captureComplete: true }; } catch { return undefined; } finally { await file?.close().catch(() => undefined); } } async function capture(event: ToolResultEvent): Promise { const details = record(event.details) as AxiHookDetails | undefined; const nativeText = event.content[0]?.type === "text" ? event.content[0].text : ""; const path = details?.fullOutputPath; if (!isShellResult(event)) return { stdout: nativeText, stderr: "", captureComplete: false }; if (Object.hasOwn(details ?? {}, "fullOutputPath") && (typeof path !== "string" || !validSpillPath(path))) return undefined; if (event.isError && typeof path !== "string") return undefined; if (typeof path === "string") return readSpill(event, path); if (details?.truncation?.truncated === true) return undefined; return { stdout: nativeText, stderr: "", captureComplete: true }; } function chooseFormat(stdout: string, stderr: string): { full: string; format: "json" | "text" } { if (!stdout.trim()) return { full: formatCapture(stdout, stderr, "text").full, format: "text" }; const json = formatCapture(stdout, stderr, "json"); if (!json.warning && Buffer.byteLength(json.full) < Buffer.byteLength(stdout)) return { full: json.full, format: "json" }; return { full: formatCapture(stdout, stderr, "text").full, format: "text" }; } function hookDetails(original: unknown, reference: ResultReference, previewComplete: boolean) { return { ...record(original), axi: { result: reference.id, stream: "formatted" as const, bytes: reference.bytes, captureComplete: reference.captureComplete, previewComplete } }; } function shouldCompact(stdout: string, formatted: string, preview: string): boolean { if (preview === formatted && formatted === stdout) return false; if (preview === formatted && Buffer.byteLength(stdout) - Buffer.byteLength(formatted) < FOOTER_SLACK) return false; return true; } async function persistCapture(event: ToolResultEvent, append: (reference: ResultReference) => void, chosen: { full: string; format: "json" | "text" }, captured: Capture): Promise { try { return await saveResult({ command: event.toolName, format: chosen.format, stdout: captured.stdout, stderr: captured.stderr, formatted: chosen.full, captureComplete: captured.captureComplete, append }); } catch { return undefined; } } function markerText(text: string, maxBytes: number): string { return truncateUtf8(text.replaceAll("[", " ").replaceAll("]", " ").replaceAll("\r", " ").replaceAll("\n", " "), maxBytes, 1, "marker text truncated").replaceAll("[", "(").replaceAll("]", ")"); } function upstreamRecoveryHint(details: unknown, received: string): string { const value = record(details); const truncation = record(value?.truncation); const footer = received.split("\n").map((line) => line.trim()).filter((line) => /full output|output file|truncated/i.test(line)).at(-1); const boundedFooter = footer ? markerText(footer, 512) : undefined; return `AXI recovers only text received${truncation?.truncated === true || typeof value?.fullOutputPath === "string" ? " from this plugin result" : ""}${boundedFooter ? `; upstream footer: ${boundedFooter}` : ""}`; } function buildPreview(full: string, reference: ResultReference, captureComplete: boolean, errorSummary: string | undefined, recoveryHint: string | undefined): { text: string; previewComplete: boolean } { const footerBase = (complete: boolean) => `\n\n[axi result=${reference.id} captureComplete=${captureComplete} previewComplete=${complete}${errorSummary ? ` error=${errorSummary}` : ""}${recoveryHint ? ` upstream=${JSON.stringify(recoveryHint)}` : ""}; call axi_read with {"result":"${reference.id}","stream":"formatted"}]`; const fits = Buffer.byteLength(full + footerBase(true)) <= PREVIEW_BYTES && (full + footerBase(true)).split("\n").length <= PREVIEW_LINES; const footer = footerBase(fits); const body = fits ? full : truncateUtf8(full, PREVIEW_BYTES - Buffer.byteLength(footer), PREVIEW_LINES - 5); const text = body + footer; return { text, previewComplete: fits && Buffer.byteLength(text) <= PREVIEW_BYTES && text.split("\n").length <= PREVIEW_LINES }; } export async function compactAxiToolResult(event: ToolResultEvent, append: (reference: ResultReference) => void, policy: ManagedToolPolicy = createManagedToolPolicy()): Promise { if (!isTarget(event, policy) || event.content.length !== 1 || event.content[0]?.type !== "text") return undefined; const captured = await capture(event); if (!captured || [captured.stdout, captured.stderr].some((text) => Buffer.byteLength(text) > MAX_CAPTURE_BYTES)) return undefined; const chosen = chooseFormat(captured.stdout, captured.stderr); if (Buffer.byteLength(chosen.full) > MAX_CAPTURE_BYTES) return undefined; const preview = truncateUtf8(chosen.full, PREVIEW_BYTES, PREVIEW_LINES); if (!shouldCompact(captured.stdout, chosen.full, preview)) return undefined; const reference = await persistCapture(event, append, chosen, captured); if (!reference) return undefined; const errorSummary = event.isError ? JSON.stringify(markerText(tailUtf8(captured.stderr || captured.stdout, 512), 512)) : undefined; const recoveryHint = isShellResult(event) ? undefined : upstreamRecoveryHint(event.details, captured.stdout); const result = buildPreview(chosen.full, reference, captured.captureComplete, errorSummary, recoveryHint); return { content: [{ type: "text", text: result.text }], details: hookDetails(event.details, reference, result.previewComplete), isError: event.isError }; } export function registerAxiHook(pi: ExtensionAPI, policy: ManagedToolPolicy = createManagedToolPolicy()): void { pi.on("tool_result", async (event) => compactAxiToolResult(event, (reference) => pi.appendEntry("axi-result", reference), policy)); }