UNPKG

@earendil-works/pi-coding-agent

Version:

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

302 lines • 16.4 kB
import { constants } from "fs"; import { access as fsAccess, open as fsOpen, readFile as fsReadFile } from "fs/promises"; import { Type } from "typebox"; import { TextDecoder } from "util"; import { processImage } from "../../utils/image-process.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPathAsync } from "./path-utils.js"; import { readRenderers } from "./renderers/read.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "./truncate.js"; const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), }); export const readToolSystemPromptContribution = { snippet: "Read file contents", guidelines: ["Use read to examine files instead of cat or sed."], }; /** * Result for programmatic callers such as codemode scripts: the text for text files, and an image * block for images that codemode's `image()` accepts. `note` is the text that goes with the image, * such as resize hints. Property descriptions are left out so the type stays on one line in tool * descriptions. */ const readOutputSchema = Type.Union([ Type.String(), Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String(), note: Type.String() }), ]); const defaultReadOperations = { readFile: (path) => fsReadFile(path), createReadStream: readLocalFileChunks, access: (path) => fsAccess(path, constants.R_OK), detectImageMimeType: detectSupportedImageMimeTypeFromFile, }; /** * Stream a local file the way fs.readFile reads it: a regular file up to its size when opened, so text * appended during the read is not counted and a growing file still ends; a file reporting size 0 (such * as /proc files) and other file types until EOF. */ async function* readLocalFileChunks(path, { signal }) { const handle = await fsOpen(path, "r"); try { const stats = await handle.stat(); const end = stats.isFile() && stats.size > 0 ? stats.size - 1 : undefined; yield* handle.createReadStream({ end, signal, autoClose: false }); } finally { await handle.close(); } } /** The image block and its note, or the text for text files and images that could not be processed. */ function toReadOutput(content) { const text = content.find((block) => block.type === "text")?.text ?? ""; const image = content.find((block) => block.type === "image"); return image ? { type: "image", data: image.data, mimeType: image.mimeType, note: text } : text; } function getNonVisionImageNote(model) { if (!model || model.input.includes("image")) { return undefined; } return "[Current model does not support images. The image will be omitted from this request.]"; } /** * Whether a range can be streamed. Other values (fractional, negative limit) keep the whole-file path, * whose array slicing defines what they select, for example limit -1 as all lines but the last. */ function isStreamableRange(offset, limit) { return ((offset === undefined || Number.isInteger(offset)) && (limit === undefined || (Number.isInteger(limit) && limit >= 0))); } /** Select lines from a whole file in memory: for operations without createReadStream and unusual ranges. */ function selectLinesFromBuffer(buffer, startLine, limit) { const allLines = buffer.toString("utf-8").split("\n"); const endLine = limit === undefined ? undefined : Math.min(startLine + limit, allLines.length); const truncation = truncateHead(allLines.slice(startLine, endLine).join("\n")); const firstLineBytes = truncation.firstLineExceedsLimit ? Buffer.byteLength(allLines[startLine], "utf-8") : 0; return { totalFileLines: allLines.length, truncation, firstLineBytes }; } /** * Select lines from a file read in chunks. Only lines truncateHead can show are stored; the rest are * counted, so memory does not grow with the file. Stops reading when the signal aborts. */ async function selectLinesFromStream(chunks, startLine, limit, signal) { const endLine = limit === undefined ? Number.POSITIVE_INFINITY : startLine + limit; // Non-fatal UTF-8 that keeps a byte order mark decodes like Buffer.toString("utf-8"). const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); const storedLines = []; let storedBytes = 0; let storedPastCaps = false; let line = 0; let selectedLines = 0; let selectedBytes = 0; let firstLineBytes = 0; let lastLineEmpty = true; // The selected line being read: its text, while it is stored, and its size. let pieces = []; let lineBytes = 0; let lineHasText = false; const appendToLine = (text, start, end) => { lineHasText = true; if (!pieces) return; const piece = text.slice(start, end); // A line over the byte cap is never shown, and a first line over it is only measured: never store // all of a 1 GB line. if (lineBytes <= DEFAULT_MAX_BYTES) pieces.push(piece); lineBytes += Buffer.byteLength(piece, "utf-8"); }; const finishLine = () => { if (pieces) { if (selectedLines === 0) firstLineBytes = lineBytes; // Storing continues one line past the caps: truncateHead ignores a trailing empty line, so the // line after the cap must be there for it to decide truncation as for the full text. if (storedLines.length > DEFAULT_MAX_LINES || storedBytes > DEFAULT_MAX_BYTES) storedPastCaps = true; const text = pieces.join(""); storedBytes += Buffer.byteLength(text, "utf-8") + (storedLines.length > 0 ? 1 : 0); storedLines.push(text); } selectedLines++; lastLineEmpty = !lineHasText; pieces = storedPastCaps ? undefined : []; lineBytes = 0; lineHasText = false; }; const select = (text) => { let position = 0; // Start of the selected text in this piece, measured as a whole: the joined lines' size. let selectionStart = -1; while (true) { const selected = line >= startLine && line < endLine; if (selected && selectionStart === -1) selectionStart = position; const newline = text.indexOf("\n", position); const end = newline === -1 ? text.length : newline; if (selected && end > position) appendToLine(text, position, end); if (newline === -1) break; if (selected) { finishLine(); if (line + 1 === endLine) { selectedBytes += Buffer.byteLength(text.slice(selectionStart, newline), "utf-8"); selectionStart = -1; } } line++; position = newline + 1; } if (selectionStart !== -1) selectedBytes += Buffer.byteLength(text.slice(selectionStart), "utf-8"); }; for await (const chunk of chunks) { // Leaving the loop closes the stream. if (signal?.aborted) throw new Error("Operation aborted"); select(decoder.decode(chunk, { stream: true })); } select(decoder.decode()); if (line >= startLine && line < endLine) finishLine(); const truncation = { ...truncateHead(storedLines.join("\n")), // truncateHead's counts for the joined selected lines, which drop a trailing empty line. totalLines: selectedBytes === 0 ? 0 : selectedLines - (lastLineEmpty ? 1 : 0), totalBytes: selectedBytes, }; return { totalFileLines: line + 1, truncation, firstLineBytes }; } export function createReadToolDefinition(cwd, options) { const autoResizeImages = options?.autoResizeImages ?? true; const fallbackResizeOptions = options?.resizeOptions; const ops = options?.operations ?? defaultReadOperations; return { name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, promptSnippet: readToolSystemPromptContribution.snippet, promptGuidelines: [...readToolSystemPromptContribution.guidelines], parameters: readSchema, outputSchema: readOutputSchema, constrainedSampling: { type: "json_schema", strict: "prefer" }, async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, ctx) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); return; } let aborted = false; const onAbort = () => { aborted = true; reject(new Error("Operation aborted")); }; signal?.addEventListener("abort", onAbort, { once: true }); (async () => { try { const absolutePath = await resolveReadPathAsync(path, ctx?.cwd || cwd); if (aborted) return; // Check if file exists and is readable. await ops.access(absolutePath); if (aborted) return; const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined; let content; let details; const nonVisionImageNote = getNonVisionImageNote(ctx?.model); if (mimeType) { // Read image as binary. const buffer = await ops.readFile(absolutePath); const processed = await processImage(buffer, mimeType, { autoResizeImages, resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions, }); if (!processed.ok) { let textNote = `Read image file [${mimeType}]\n${processed.message}`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [{ type: "text", text: textNote }]; } else { let textNote = `Read image file [${processed.mimeType}]`; if (processed.hints.length > 0) textNote += `\n${processed.hints.join("\n")}`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [ { type: "text", text: textNote }, { type: "image", data: processed.data, mimeType: processed.mimeType }, ]; } } else { // Read text content. Convert offset from 1-indexed input to 0-indexed line access. const startLine = offset ? Math.max(0, offset - 1) : 0; const startLineDisplay = startLine + 1; const { totalFileLines, truncation, firstLineBytes } = ops.createReadStream && isStreamableRange(offset, limit) ? await selectLinesFromStream(ops.createReadStream(absolutePath, { signal }), startLine, limit, signal) : selectLinesFromBuffer(await ops.readFile(absolutePath), startLine, limit); if (aborted) return; // Check if offset is out of bounds. if (startLine >= totalFileLines) { throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`); } // A user-specified limit is honored before truncation. const userLimitedLines = limit === undefined ? undefined : Math.min(startLine + limit, totalFileLines) - startLine; let outputText; if (truncation.firstLineExceedsLimit) { // First line alone exceeds the byte limit. Point the model at a bash fallback. const firstLineSize = formatSize(firstLineBytes); outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; details = { truncation }; } else if (truncation.truncated) { // Truncation occurred. Build an actionable continuation notice. const endLineDisplay = startLineDisplay + truncation.outputLines - 1; const nextOffset = endLineDisplay + 1; outputText = truncation.content; if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; } else { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; } details = { truncation }; } else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) { // User-specified limit stopped early, but the file still has more content. const remaining = totalFileLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; } else { // No truncation and no remaining user-limited content. outputText = truncation.content; } content = [{ type: "text", text: outputText }]; } if (aborted) return; signal?.removeEventListener("abort", onAbort); resolve({ content, details }); } catch (error) { signal?.removeEventListener("abort", onAbort); if (!aborted) reject(error); } })(); }).then((result) => ({ ...result, structuredContent: toReadOutput(result.content) })); }, ...readRenderers, }; } export function createReadTool(cwd, options) { return wrapToolDefinition(createReadToolDefinition(cwd, options)); } //# sourceMappingURL=read.js.map