import { isReadableUrlPath, type ReadToolDetails } from "@oh-my-pi/pi-tui/tools/read"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type EditStore, notebookToEditableText } from "@oh-my-pi/pi-natives"; import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, SpeculativePhysicalOutcome, ToolSpeculationAssessment, ToolSpeculationCommitContext, ToolSpeculationDiscardContext, ToolSpeculationExecutionContext, ToolTier, } from "@oh-my-pi/pi-agent-core"; import { completeSimple, type ImageContent, type TextContent } from "@oh-my-pi/pi-ai"; import { BINARY_SNIFF_BYTES, type ImageMetadata, IMAGE_METADATA_HEADER_BYTES, hasFsCode, isProbablyBinary, isProbablyBinaryHeader, isEnoent, logger, parseHeifImageMimeType, parseImageMetadata, prompt, readImageMetadata, } from "@oh-my-pi/pi-utils"; import { peekFile } from "@oh-my-pi/pi-utils/peek-file"; import { cfgIdaAvailable, EXECUTABLE_SNIFF_BYTES, isExecutableFile, isExecutableHeader, isIdaDatabasePath, } from "../ida"; import { normalizeToLF } from "../edit/normalize"; import { getEditStore, recordFullySeenRows } from "../edit/store"; import { extractUriScheme, InternalUrlRouter, type SchemeSpec, sessionResolveContext } from "../internal-urls"; import { isMarkdownPath } from "@oh-my-pi/pi-tui/lang-from-path"; import readDescription from "../prompts/tools/read.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { McpResourceNotFoundError } from "../internal-urls/mcp-protocol"; import { parseInternalUrl } from "../internal-urls/parse"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateHead, truncateHeadBytes, truncateLine, } from "@oh-my-pi/pi-tui/tools/streaming-output"; import { buildLineEntriesWithBlockContext, lineEntriesToPlainText, spansCoverEveryLine, warmBlockContext, } from "../utils/block-context"; import { isCpuProfilePath, renderCpuProfile } from "../utils/cpuprofile"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { loadHeifImageInput, loadImageInput, loadSvgImageInput } from "../utils/image-loading"; import { ImageInputTooLargeError, InvalidImageDataError, MAX_IMAGE_INPUT_BYTES, webpExclusionForModel, } from "@oh-my-pi/pi-tui/chat/image-loading"; import { askImageQuestion, resolveImageQuestionModel } from "../utils/image-question"; import { CONVERTIBLE_EXTENSIONS, convertFileWithMarkit } from "../utils/markit"; import { isSampleProfilePath, renderSampleProfile } from "../utils/sample-profile"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { formatConflictSummary, formatConflictWarning, formatScanCapNote, getConflictHistory, recoverConflictUriPrefix, scanConflictLines, scanFileForConflicts, } from "./conflict-detect"; import { executeReadUrl, fetchReadUrl, parseReadUrlTarget } from "./fetch"; import { approvalDirectoryKey } from "./approval-cache"; import { approvalTargetKey, approvalTargetNamesFile, isApprovalPublicUrl } from "./approval-targets"; import { postProcessToolResult, resolveArtifactSpillThreshold, resolveInlineByteCapBudget, resolveOutputMaxColumns, } from "./output-meta"; import { expandPath, formatPathRelativeToCwd, probeLiteralPathExists, resolveReadPathAsync, specialFileKind, splitDelimitedPathEntry, splitMixedUrlPathList, splitPathAndSelPreferringLiteral, splitPathAndSelPreferringLiteralSync, } from "./path-utils"; import { type LineRange } from "@oh-my-pi/pi-tui/tools/line-ranges"; import { splitPathAndSel } from "@oh-my-pi/pi-tui/tools/read"; import { readArchive, resolveArchiveReadPath } from "./read-archive"; import { appendReadNotice, BRACKET_CONTEXT_ELLIPSIS, buildInMemoryMultiRangeResult, buildInMemorySelectorResult, buildInMemoryTextResult, contiguousLineNumbers, countTextLines, formatLineEntriesWithMode, formatReadHashlineHeader, formatSummaryElisionFooter, formatTextWithMode, type HashlineHeaderContext, hashlineHeaderContext, hashlineHeaderContextForText, lineNumbersFromSpans, markRawSuffixResolution, markMarkdownContentType, prependHashlineHeader, prependSuffixResolutionNotice, RANGE_LEADING_CONTEXT_LINES, RANGE_TRAILING_CONTEXT_LINES, READ_CHUNK_SIZE, readHashlineHeaderContext, toReadTruncationStats, } from "./read-format"; import { findSuffixMatchCached, isNotFoundError, isRemoteMountPath, type SuffixMatchCache, } from "./read-path-resolution"; import { type PdfImageReadTarget, type PdfPageReadOptions, renderPdfPageScreenshot, splitPdfImageReadPath, } from "./read-pdf"; import { ternShowsMedia } from "./tern-media"; import { formatDimensionNote, resizeImage } from "../utils/image-resize"; import { VideoError, buildVideoContactSheetPng, extractVideoFramePng, formatVideoDetails, parseVideoSelector, probeVideo, splitVideoReadTarget, videoMimeForPath, type VideoMetadata, type VideoPng, } from "../utils/video"; import { isVideoPath } from "@oh-my-pi/pi-tui/prompt/video"; import { isMultiRange, isRawSelector, type ParsedSelector, parseSel, type ResolvedSelector, resolveTailSelector, selToOffsetLimit, } from "./read-selector"; import { splitAddressableFileLines } from "@oh-my-pi/pi-tui/tools/hashline-format"; import { formatSingleLine } from "@oh-my-pi/pi-tui/tools/read"; import { nativeShapingOff, shapesOutput, shapeToolResult, shapingExperiment } from "../spotlight"; import type { ShapeInput } from "../spotlight/types"; import { readBinary, resolveBinaryViewPath } from "./read-binary"; import { readSqlite, resolveSqliteReadPath } from "./read-sqlite"; import { parseJsonSelector, readJson, resolveJsonReadPath, splitJsonQueryTarget } from "./read-json"; import { getReadTextFileBridge, isProseSummaryPath, MAX_SUMMARY_BYTES, MAX_SUMMARY_LINES, renderSummary, routeReadThroughBridge, trySummarize, } from "./read-summary"; import { parseSqlitePathCandidates } from "./sqlite-reader"; import { formatBytes, shortenPath } from "@oh-my-pi/pi-tui/render/render-utils"; import { ToolAbortError, throwIfAborted } from "./tool-errors"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { toolResult } from "./tool-result"; import { cfgFetchEnabled, cfgReadDefaultLimit, cfgReadRenderMarkdown, cfgReadSummarizeEnabled, cfgReadSummarizeProse, } from "./settings"; import { cfgImagesAutoResize } from "../modes/settings"; /** Largest profile (`*.sample.txt`, `*.cpuprofile`) converted to a bottleneck summary; bigger files read as plain text. */ const MAX_PROFILE_SUMMARY_BYTES = 32 * 1024 * 1024; /** Largest URL-located file a bare `:raw` read inlines; bigger ones must page with bounded ranges. */ const MAX_URL_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES; /** Largest file buffered whole for the local read path and speculative snapshots. */ export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024; /** LF byte, scanned natively to find line boundaries in a buffered file. */ const LF_BYTE = 0x0a; /** * Whole-file bytes plus every view the local text read path consumes, * materialized exactly once. * * The binary sniff, the structural summary, the emitted line window, bracket * context and the snapshot hash all want the same bytes. Each used to open the * file for itself, so a single ranged read cost up to four opens, three UTF-8 * decodes and two CRLF normalization passes over identical content. * * Only files at or below {@link SNAPSHOT_MAX_BYTES} are buffered: past that cap * bracket context and the snapshot are skipped anyway, so streaming a window * stays strictly cheaper than materializing the file. */ interface BufferedFileText { /** File bytes, verbatim. */ readonly bytes: Buffer; /** Verbatim UTF-8 decode: a leading BOM and CRLF line endings both survive. */ readonly rawText: string; /** {@link rawText} split on LF, CR retained, so segments stay byte-faithful. */ readonly rawSegments: readonly string[]; /** BOM-stripped, CRLF-preserving text: what `Bun.file(path).text()` returns. */ readonly strippedText: string; /** {@link strippedText} normalized to LF — the exact text the snapshot store hashes. */ readonly normalizedText: string; /** Addressable lines of {@link normalizedText}; bracket context indexes these. */ readonly addressableLines: readonly string[]; /** Whether the final byte is LF. */ readonly endsWithNewline: boolean; } /** * Read the whole file, or `undefined` when the bytes cannot be read — which * drops the caller back to the streaming reader and reproduces today's error * surface. * * Kept separate from {@link deriveBufferedFileText} so the binary sniff can run * on the bytes first: a file that decodes to mojibake is refused, and building * three string views of it before finding that out would be pure waste. */ async function readWholeFile(absolutePath: string): Promise { try { return await fs.readFile(absolutePath); } catch { return undefined; } } /** * Derive every view of `bytes` the read path needs, decoding exactly once. * * `Bun.file(path).text()` strips a leading BOM while `Buffer.toString` keeps it, * and the snapshot store plus the patcher's live-file read both go through the * stripping decoder. {@link BufferedFileText.strippedText} therefore reproduces * that decode for hashing while {@link BufferedFileText.rawText} stays verbatim * for the emitted lines and their byte accounting. */ function deriveBufferedFileText(bytes: Buffer): BufferedFileText { const rawText = bytes.toString("utf-8"); const strippedText = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText; // `normalizeToLF` allocates a copy; skip it outright for the common LF file. const normalizedText = strippedText.includes("\r") ? normalizeToLF(strippedText) : strippedText; const rawSegments = rawText.split("\n"); let addressableLines: readonly string[]; if (normalizedText === rawText) { // Nothing was rewritten, so display and bracket context share one array; // the terminal newline sentinel is dropped exactly as // `splitAddressableFileLines` does. const last = rawSegments.length - 1; addressableLines = last > 0 && rawSegments[last] === "" ? rawSegments.slice(0, last) : rawSegments; } else { addressableLines = splitAddressableFileLines(normalizedText); } return { bytes, rawText, rawSegments, strippedText, normalizedText, addressableLines, endsWithNewline: bytes.length > 0 && bytes[bytes.length - 1] === LF_BYTE, }; } /** The line window a range read renders, with the budget accounting behind it. */ interface ReadLineWindow { lines: string[]; totalFileLines: number; collectedBytes: number; selectedBytes: number; stoppedByByteLimit: boolean; /** First source line that could not fit in the remaining byte budget. */ byteLimitLine?: { index: number; byteLength: number }; firstLinePreview?: { text: string; bytes: number }; firstLineByteLength?: number; /** * Rows that are only the head of a column-capped line, not the whole source line: index into * `lines` to the source line's byte length. */ prefixLines?: Map; /** Whether the fully scanned source ended in a newline. */ hasTrailingNewline: boolean; /** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */ reachedEof: boolean; } function omittedRequestedLine( line: ReadLineWindow["byteLimitLine"], requestedStart: number, rawSelector: boolean, leadingContext: number, collectedLineCount: number, ): ReadLineWindow["byteLimitLine"] { if (rawSelector || !line || leadingContext === 0) return undefined; return line.index === requestedStart && collectedLineCount === leadingContext ? line : undefined; } function formatOmittedRequestedLineNotice( line: NonNullable, maxBytes: number, rawTarget: string, ): string { return `[Line ${line.index + 1} is ${formatBytes( line.byteLength, )} and could not fit after preceding context in the ${formatBytes(maxBytes)} read budget. Use ${rawTarget} to read that line without context (byte-capped if it exceeds the budget), or widen the requested range to increase the budget.]`; } /** * Column-cap cuts of one read: the cap, the first-to-last cut line, and the first one's source * size, for the `:raw` recovery notice. */ interface ColumnCut { maxColumn: number; first: number; last: number; firstBytes: number; } function addColumnCut( cut: ColumnCut | undefined, maxColumn: number, lineNumber: number, sourceBytes: number, ): ColumnCut { if (!cut) return { maxColumn, first: lineNumber, last: lineNumber, firstBytes: sourceBytes }; const last = Math.max(cut.last, lineNumber); if (lineNumber < cut.first) return { maxColumn, first: lineNumber, last, firstBytes: sourceBytes }; return { ...cut, maxColumn, last }; } /** * Bytes a line costs against the page budget: its column-capped display when a * cap applies, so a cut line is charged what the reader sees rather than its * source size. `displayColumns` 0 charges source bytes (raw reads, cap off). */ function lineBudgetBytes(line: string, sourceBytes: number, displayColumns: number): number { // UTF-16 length never exceeds the UTF-8 source length, so a line within the cap in bytes is within it in columns. if (displayColumns <= 0 || sourceBytes <= displayColumns) return sourceBytes; const { text, wasTruncated } = truncateLine(line, displayColumns); return wasTruncated ? Buffer.byteLength(text, "utf-8") : sourceBytes; } /** * Slice the window {@link streamLinesFromFile} would have collected out of an * already-buffered file, under the identical line and byte budgets. * * Line byte lengths are walked out of the buffer rather than measured on the * decoded strings: a file that is not valid UTF-8 decodes to U+FFFD, whose * encoded length differs from the bytes on disk, and those lengths decide both * the reported byte counts and where truncation lands. */ function collectLineWindowFromBuffer( file: BufferedFileText, startLine: number, maxLinesToCollect: number, maxBytes: number, selectedLineLimit: number, displayColumns: number, ): ReadLineWindow { const { bytes, rawSegments, endsWithNewline } = file; // A trailing LF closes the last addressable line. const totalFileLines = endsWithNewline && rawSegments.length > 1 ? rawSegments.length - 1 : rawSegments.length; const window: ReadLineWindow = { lines: [], totalFileLines, collectedBytes: 0, selectedBytes: 0, stoppedByByteLimit: false, hasTrailingNewline: endsWithNewline, reachedEof: true, }; if (startLine >= totalFileLines) return window; let lineStart = 0; for (let index = 0; index < startLine; index++) { const newlineAt = bytes.indexOf(LF_BYTE, lineStart); if (newlineAt === -1) { lineStart = bytes.length; break; } lineStart = newlineAt + 1; } let doneCollecting = false; let selectedLinesSeen = 0; for (let index = startLine; index < totalFileLines; index++) { const newlineAt = bytes.indexOf(LF_BYTE, lineStart); const lineEnd = newlineAt === -1 ? bytes.length : newlineAt; const lineByteLength = lineEnd - lineStart; if (selectedLinesSeen < selectedLineLimit) { window.selectedBytes += (selectedLinesSeen > 0 ? 1 : 0) + lineByteLength; selectedLinesSeen++; } // Preview covers the first selected line only, capped at the byte budget: // the oversized-first-line branch renders it when no full line fits. if (window.lines.length === 0 && window.firstLinePreview === undefined && lineByteLength > 0) { const previewEnd = Math.min(lineEnd, lineStart + maxBytes); const { text, bytes: previewBytes } = truncateHeadBytes(bytes.subarray(lineStart, previewEnd), maxBytes); window.firstLinePreview = { text, bytes: previewBytes }; } if (!doneCollecting) { const separatorBytes = window.lines.length > 0 ? 1 : 0; if (window.lines.length >= maxLinesToCollect) { doneCollecting = true; } else { const lineText = rawSegments[index] ?? ""; const lineCost = lineBudgetBytes(lineText, lineByteLength, displayColumns); // A first line is collected when its cut display or its source fits; if only its source // fits (stray bytes widen on decode), the page ends after it. if (window.lines.length === 0 && Math.min(lineCost, lineByteLength) > maxBytes) { window.stoppedByByteLimit = true; window.byteLimitLine = { index, byteLength: lineByteLength }; doneCollecting = true; window.firstLineByteLength ??= lineByteLength; } else if (window.lines.length > 0 && window.collectedBytes + separatorBytes + lineCost > maxBytes) { window.stoppedByByteLimit = true; window.byteLimitLine = { index, byteLength: lineByteLength }; doneCollecting = true; } else { window.lines.push(lineText); window.collectedBytes += separatorBytes + lineCost; window.firstLineByteLength ??= lineByteLength; if (window.collectedBytes > maxBytes) { window.stoppedByByteLimit = true; doneCollecting = true; } else if (window.lines.length >= maxLinesToCollect) { doneCollecting = true; } } } } else if (window.firstLineByteLength === undefined) { window.firstLineByteLength = lineByteLength; } if (doneCollecting && selectedLinesSeen >= selectedLineLimit) break; lineStart = lineEnd + 1; } return window; } /** What a seek offset was measured against; a file that no longer matches is rescanned from byte zero. */ interface SeekFileIdentity { ino: number; size: number; mtimeMs: number; ctimeMs: number; } function sameSeekFileIdentity(identity: SeekFileIdentity, stat: SeekFileIdentity): boolean { return ( identity.ino === stat.ino && identity.size === stat.size && identity.mtimeMs === stat.mtimeMs && identity.ctimeMs === stat.ctimeMs ); } interface StreamFileLinesOptions { stopScanAfterCollect?: boolean; /** * Start scanning at byte `byte`, which begins 0-indexed line `line` (`line <= startLine`). * Lines before `startLine` contribute nothing but their count, so skipping them is exact. * Ignored (full scan) when the opened file no longer matches `identity`. */ seek?: { line: number; byte: number; identity: SeekFileIdentity }; /** Column cap the window is displayed under; lines are charged their capped width. 0 charges source bytes. */ displayColumns?: number; } async function streamLinesFromFile( filePath: string, startLine: number, maxLinesToCollect: number, maxBytes: number, selectedLineLimit: number | null, signal?: AbortSignal, options: StreamFileLinesOptions = {}, ): Promise { const { stopScanAfterCollect = false, seek, displayColumns = 0 } = options; const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE); const collectedLines: string[] = []; let lineIndex = seek?.line ?? 0; let position = seek?.byte ?? 0; let collectedBytes = 0; let selectedBytes = 0; let stoppedByByteLimit = false; let byteLimitLine: { index: number; byteLength: number } | undefined; let doneCollecting = false; let reachedEof = true; let fileHandle: fs.FileHandle | null = null; let currentLineLength = 0; /** Copies of the current line's parts from earlier chunks. */ let currentLineChunks: Buffer[] = []; /** The current line's part in this chunk: a view into the reused read buffer, copied only if the line continues. */ let pendingSegment: Buffer | undefined; // A seek lands just past an LF, so the bytes it skipped already end in a newline. let sawAnyByte = position > 0; let endedWithNewline = position > 0; let firstLinePreviewBytes = 0; const firstLinePreviewChunks: Buffer[] = []; let firstLineByteLength: number | undefined; let selectedLinesSeen = 0; let captureLine = false; let discardLineChunks = false; let lineCaptureLimit = 0; /** The current line is kept as a head long enough for its column-capped display, not whole. */ let capturedPrefix = false; let prefixLines: Map | undefined; const setupLineState = () => { captureLine = !doneCollecting && lineIndex >= startLine; discardLineChunks = !captureLine; if (captureLine) { const separatorBytes = collectedLines.length > 0 ? 1 : 0; lineCaptureLimit = maxBytes - collectedBytes - separatorBytes; if (lineCaptureLimit <= 0) { discardLineChunks = true; } else if (displayColumns > 0) { // A head of 3 bytes per UTF-16 unit, plus 3 for a sequence the cut splits, decodes to // more than `displayColumns` units identical to the whole line's, so it caps to the same // display. Every longer line is cut, so only that head is kept, however much budget remains. lineCaptureLimit = 3 * displayColumns + 6; } } else { lineCaptureLimit = 0; } }; const decodeLine = (): string => { if (currentLineLength === 0) return ""; if (currentLineChunks.length === 0) return pendingSegment?.toString("utf-8") ?? ""; if (pendingSegment) currentLineChunks.push(pendingSegment); return Buffer.concat(currentLineChunks).toString("utf-8"); }; const maybeCapturePreview = (segment: Uint8Array) => { if (doneCollecting || lineIndex < startLine || collectedLines.length !== 0) return; if (firstLinePreviewBytes >= maxBytes || segment.length === 0) return; const remaining = maxBytes - firstLinePreviewBytes; const slice = segment.length > remaining ? segment.subarray(0, remaining) : segment; if (slice.length === 0) return; firstLinePreviewChunks.push(Buffer.from(slice)); firstLinePreviewBytes += slice.length; }; const appendSegment = (segment: Buffer) => { const previousLength = currentLineLength; currentLineLength += segment.length; maybeCapturePreview(segment); if (!captureLine || discardLineChunks || segment.length === 0) return; if (currentLineLength <= lineCaptureLimit) { pendingSegment = segment; } else { discardLineChunks = true; if (displayColumns > 0) { currentLineChunks.push(Buffer.from(segment.subarray(0, lineCaptureLimit - previousLength))); capturedPrefix = true; } } }; const finalizeLine = () => { if (lineIndex >= startLine && (selectedLineLimit === null || selectedLinesSeen < selectedLineLimit)) { selectedBytes += (selectedLinesSeen > 0 ? 1 : 0) + currentLineLength; selectedLinesSeen++; } if (!doneCollecting && lineIndex >= startLine) { const separatorBytes = collectedLines.length > 0 ? 1 : 0; if (collectedLines.length >= maxLinesToCollect) { doneCollecting = true; } else { // A line kept whole or as a display head is charged its capped display; a dropped one cannot fit. const lineText = currentLineLength === 0 || !discardLineChunks || capturedPrefix ? decodeLine() : undefined; const lineCost = lineText === undefined ? currentLineLength : lineBudgetBytes(lineText, currentLineLength, displayColumns); if (collectedLines.length === 0 && Math.min(lineCost, currentLineLength) > maxBytes) { stoppedByByteLimit = true; byteLimitLine = { index: lineIndex, byteLength: currentLineLength }; doneCollecting = true; if (firstLineByteLength === undefined) { firstLineByteLength = currentLineLength; } } else if ( lineText === undefined || (collectedLines.length > 0 && collectedBytes + separatorBytes + lineCost > maxBytes) ) { stoppedByByteLimit = true; byteLimitLine = { index: lineIndex, byteLength: currentLineLength }; doneCollecting = true; } else { collectedLines.push(lineText); collectedBytes += separatorBytes + lineCost; if (capturedPrefix) (prefixLines ??= new Map()).set(collectedLines.length - 1, currentLineLength); if (firstLineByteLength === undefined) { firstLineByteLength = currentLineLength; } if (collectedBytes > maxBytes) { stoppedByByteLimit = true; doneCollecting = true; } else if (collectedLines.length >= maxLinesToCollect) { doneCollecting = true; } } } } else if (lineIndex >= startLine && firstLineByteLength === undefined) { firstLineByteLength = currentLineLength; } lineIndex++; currentLineLength = 0; currentLineChunks = []; pendingSegment = undefined; capturedPrefix = false; setupLineState(); }; setupLineState(); try { fileHandle = await fs.open(filePath, "r"); // Offsets from an earlier scan only hold for the same, unchanged file. if (seek && !sameSeekFileIdentity(seek.identity, await fileHandle.stat())) { lineIndex = 0; position = 0; sawAnyByte = false; endedWithNewline = false; setupLineState(); } while (true) { throwIfAborted(signal); const { bytesRead } = await fileHandle.read(bufferChunk, 0, bufferChunk.length, position); position += bytesRead; if (bytesRead === 0) break; sawAnyByte = true; const chunk = bufferChunk.subarray(0, bytesRead); endedWithNewline = chunk[bytesRead - 1] === 0x0a; // Once collection and selected-line accounting are both finished, the // remaining scan only computes `totalFileLines` — count newlines with // native indexOf instead of the per-byte JS loop (a multi-GB tail // otherwise stalls the read for seconds to minutes). if (doneCollecting && selectedLineLimit !== null && selectedLinesSeen >= selectedLineLimit) { if (stopScanAfterCollect) { reachedEof = false; break; } let searchFrom = 0; let newlineAt = chunk.indexOf(0x0a); while (newlineAt !== -1) { lineIndex++; searchFrom = newlineAt + 1; newlineAt = chunk.indexOf(0x0a, searchFrom); } if (searchFrom === 0) { currentLineLength += chunk.length; } else { currentLineLength = chunk.length - searchFrom; } continue; } let start = 0; for (let newlineAt = chunk.indexOf(0x0a); newlineAt !== -1; newlineAt = chunk.indexOf(0x0a, start)) { if (newlineAt > start) { appendSegment(chunk.subarray(start, newlineAt)); } finalizeLine(); start = newlineAt + 1; } if (start < chunk.length) { appendSegment(chunk.subarray(start)); } if (pendingSegment) { // The line continues into the next read, which reuses the buffer. currentLineChunks.push(Buffer.from(pendingSegment)); pendingSegment = undefined; } } } finally { if (fileHandle) { await fileHandle.close(); } } if (reachedEof && (currentLineLength > 0 || !sawAnyByte)) { finalizeLine(); } let firstLinePreview: { text: string; bytes: number } | undefined; if (firstLinePreviewBytes > 0) { const { text, bytes } = truncateHeadBytes(Buffer.concat(firstLinePreviewChunks, firstLinePreviewBytes), maxBytes); firstLinePreview = { text, bytes }; } return { lines: collectedLines, totalFileLines: lineIndex, collectedBytes, selectedBytes, stoppedByByteLimit, byteLimitLine, firstLinePreview, firstLineByteLength, prefixLines, reachedEof, hasTrailingNewline: reachedEof && endedWithNewline, }; } /** Byte reads of the newline-counting tail scan. */ const TAIL_SCAN_CHUNK_SIZE = 64 * 1024; /** Most trailing line starts a tail scan retains; longer tails rescan from the top of the file. */ const TAIL_SEEK_MAX_LINES = 64 * 1024; /** A resolved `:-N` selector, plus where the window starts in an unbuffered file when known. */ interface ResolvedFileTail { sel: ResolvedSelector; /** Streamer seek for a window starting at 0-indexed `line`, when the tail scan retained its offset. */ seekTo?: (line: number) => StreamFileLinesOptions["seek"]; } /** * Pin a `:-N` tail selector against a file's line count. Buffered files count * their already-split lines; larger files take one native newline-counting * pass that also keeps the byte offsets of the last lines, so the window read * can seek straight to them instead of rescanning the whole file. The count * matches {@link streamLinesFromFile}'s `totalFileLines` exactly. */ async function resolveFileTailSelector( parsed: ParsedSelector, filePath: string, buffered: BufferedFileText | undefined, signal?: AbortSignal, ): Promise { if (parsed.kind !== "tail") return { sel: parsed }; if (buffered) { const totalLines = collectLineWindowFromBuffer(buffered, 0, 0, 0, 0, 0).totalFileLines; return { sel: resolveTailSelector(parsed, totalLines) }; } // The window starts at most `count` lines plus leading context before EOF. const wanted = parsed.count + RANGE_LEADING_CONTEXT_LINES + 1; const keep = wanted <= TAIL_SEEK_MAX_LINES ? wanted : 0; // `starts[k % keep]` holds the byte offset of line `k` (k >= 1) for the last `keep` lines. const starts = new Float64Array(keep); const chunk = Buffer.allocUnsafe(TAIL_SCAN_CHUNK_SIZE); let newlines = 0; let position = 0; let lastByte = -1; const handle = await fs.open(filePath, "r"); let identity: SeekFileIdentity; try { const stat = await handle.stat(); identity = { ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; while (true) { throwIfAborted(signal); const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); if (bytesRead === 0) break; const view = chunk.subarray(0, bytesRead); for (let at = view.indexOf(LF_BYTE); at !== -1; at = view.indexOf(LF_BYTE, at + 1)) { newlines++; if (keep > 0) starts[newlines % keep] = position + at + 1; } position += bytesRead; lastByte = view[bytesRead - 1]; } } finally { await handle.close(); } // A write during the scan leaves offsets that the identity no longer vouches for. if (position !== identity.size) identity = { ...identity, ino: -1 }; // Mirror the streamer's final-line rule: an empty file or an unterminated // last line contributes one line; a terminal newline closes the last line. const totalLines = newlines + (position === 0 || lastByte !== LF_BYTE ? 1 : 0); return { sel: resolveTailSelector(parsed, totalLines), seekTo: line => line > 0 && line <= newlines && newlines - line < keep ? { line, byte: starts[line % keep], identity } : undefined, }; } const IMAGE_QUESTION_SELECTOR_ERROR = "The ?q= selector only supports images (raster files, .svg:img, file-backed image URLs, PDF page screenshots)."; /** A routed URL located to a local file, read through the filesystem pipeline under the URL's identity. */ interface LocatedRead { /** The URL as written, selector peeled: names the read in hints and `sourceInternal`. */ url: string; /** Absolute backing file (or directory). */ path: string; sel?: string; spec: SchemeSpec; } interface FilesystemReadOptions { located?: LocatedRead; question?: string; /** Model-facing path for `?q=` hints; defaults to the displayed file path. */ questionPath?: string; signal?: AbortSignal; onBufferedFile?: (bytes: Buffer) => void; onConflictMarkers?: () => void; lexicalAbsolutePath?: string; /** The read's tool context: `rawImages` keeps image routing under `:raw`, and split reads pass it on. */ toolContext?: AgentToolContext; /** Page a whole-file read as the plain result does, though the session shapes output (a held-out result). */ plainWindow?: boolean; } /** * Workflow hint for a large located file: page it through its URL, search/copy its backing * file. `rawBlocked` explains a refused whole-file `:raw` read. */ function formatLocatedFileNotice(url: string, backingPath: string, size: number, rawBlocked: boolean): string { const workflows = `Use ${url}:raw:1-3000 for bounded verbatim chunks, ${url}:1-3000 for numbered exploration, and the backing file path for search/copy workflows`; return rawBlocked ? `Unbounded raw read blocked for ${url} (${formatBytes(size)}). Reading the whole file verbatim can exhaust memory. ${workflows}: ${shortenPath(backingPath)}` : `Backing file: ${shortenPath(backingPath)} (${formatBytes(size)}). ${workflows}.`; } /** * Peel `?q=` (ask a vision model about an image) from a plain path or a URL whose * scheme declares {@link SchemeSpec.imageQuestion}; every other URL owns its query string. */ export function splitImageQuestionTarget(readPath: string): { path: string; question?: string } { if (readPath.includes("://")) { const scheme = extractUriScheme(readPath); if (!scheme || !InternalUrlRouter.instance().spec(scheme)?.imageQuestion) return { path: readPath }; } if (parseSqlitePathCandidates(readPath).length > 0) return { path: readPath }; const jsonTarget = splitJsonQueryTarget(readPath); if (jsonTarget && /\.(?:jsonl?|ndjson)$/i.test(jsonTarget.jsonPath)) return { path: readPath }; const queryIndex = readPath.indexOf("?"); if (queryIndex === -1) return { path: readPath }; const question = new URLSearchParams(readPath.slice(queryIndex + 1)).get("q"); return question ? { path: readPath.slice(0, queryIndex), question } : { path: readPath }; } /** A `?q=` target whose path before the query names media a vision question applies to, never JSON. */ const MEDIA_QUESTION_TARGET = /(?::img|:page\d+\.png|\.(?:png|jpe?g|gif|webp|bmp|tiff?|svg|heic|heif|avif|ico|mp4|m4v|mov|webm|mkv|avi))$/i; function isMediaQuestionTarget(readPath: string): boolean { const queryIndex = readPath.indexOf("?"); return queryIndex !== -1 && MEDIA_QUESTION_TARGET.test(readPath.slice(0, queryIndex)); } // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES; const readSchema = type({ path: type("string").describe("Local path, internal URI, or URL; selectors inline."), }); export type ReadToolInput = typeof readSchema.infer; type ReadParams = ReadToolInput; /** * The source window a text read showed, on the result's details (never serialized), for the Spotlight * experiment's shaping. Recorded only while the session shapes tool output. */ const kSpotlightWindow = Symbol("read.spotlightWindow"); /** * Marks a result whose file was gathered past the plain window for shaping (a whole-file read): its plain * equivalent, which the trace sizes and a held-out result shows, takes a read of its own. A whole file that fits * the plain read's smallest page is not marked: the plain read is the same. */ const kSpotlightWide = Symbol("read.spotlightWide"); interface ReadDetailsWithWindow extends ReadToolDetails { [kSpotlightWindow]?: ShapeInput["read"]; [kSpotlightWide]?: true; } /** * Resolve the filesystem path a read result should hyperlink to: the explicit * `resolvedPath` when the read corrected/resolved the input, else the absolute * path plain-file reads record only in `meta.source`. Returns `null` for * URL/internal sources that are not fs paths. */ function readDetailsLinkPath(details: ReadToolDetails | undefined): string | null { if (typeof details?.resolvedPath === "string") return details.resolvedPath; const source = details?.meta?.source; return source?.type === "path" && typeof source.value === "string" ? source.value : null; } /** Identical reads tolerated before the loop hint is appended. */ const REPEAT_READ_HINT_THRESHOLD = 3; /** Per-session cap on tracked read keys; the map resets when exceeded. */ const REPEAT_READ_TRACKER_CAP = 64; const kRepeatReadTracker = Symbol("read.repeatTracker"); interface SessionWithRepeatReadTracker extends ToolSession { [kRepeatReadTracker]?: Map; } /** * Append a loop-breaking hint when the same read selector returns * byte-identical output repeatedly. Weak models re-issue an unchanged read * dozens of times (observed: 29 bare re-reads of one file, ~645k tokens); * naming the repetition breaks the loop the same way the edit no-op guard * does. Tracking is per session and resets whenever the output changes. */ function appendRepeatReadHint(session: ToolSession, path: string, result: AgentToolResult): void { const block = result.content?.find(entry => entry.type === "text"); if (!block || typeof block.text !== "string" || block.text.length === 0 || result.isError) return; const holder = session as SessionWithRepeatReadTracker; holder[kRepeatReadTracker] ??= new Map(); const tracker = holder[kRepeatReadTracker]; if (tracker.size > REPEAT_READ_TRACKER_CAP) tracker.clear(); let hash = BigInt(result.content.length); for (const content of result.content) { hash = Bun.hash.wyhash(content.type, hash); if (content.type === "text") { hash = Bun.hash.wyhash(content.text, hash); } else if (content.type === "image") { hash = Bun.hash.wyhash(content.mimeType, hash); hash = Bun.hash.wyhash(content.data, hash); } } const entry = tracker.get(path); if (!entry || entry.hash !== hash) { tracker.set(path, { hash, count: 1 }); return; } entry.count++; if (entry.count < REPEAT_READ_HINT_THRESHOLD) return; const next = acceptsLineSelector(path) ? "use a narrower selector (path:A-B), or proceed" : "proceed"; const notice = `[You have received this identical output ${entry.count} times. Re-reading '${path}' will not change it — ${next} with the edit.]`; block.text = appendReadNotice(block.text, notice, (result.details ??= {}), "\n\n"); } /** * Whether `path` takes a trailing `:A-B` line selector: filesystem paths, web * URLs and `lines` schemes do; selector-free devices (`xd://`), opaque MCP * resources and unregistered schemes would read the tail as part of the URL. */ function acceptsLineSelector(path: string): boolean { const router = InternalUrlRouter.instance(); if (!router.canResolve(path)) return true; const scheme = extractUriScheme(router.normalize(path)); return scheme !== undefined && router.spec(scheme)?.selectors === "lines"; } const LOCAL_READ_SPECULATION_INELIGIBLE: ToolSpeculationAssessment = { eligible: false, reason: "read target is not a speculation-safe local path", }; export type SpeculativeReadTargetFailure = "local read path is unavailable" | "local read target is unsafe"; /** * Resolve a speculative local-read target and validate it against the workspace. * * Shared by host authorization and speculative execution so both bind the same * target: assessment is metadata-only and the authorize-to-execute window admits * a symlink swap, so execution must re-resolve and re-validate before reading. * Anything else fails into ordinary execution, never commit. Resolution failures * report unavailable; containment/file/size failures report unsafe (reason * strings pinned by speculative-host tests). */ export async function resolveSpeculativeReadTarget( cwd: string, lexicalPath: string, ): Promise<{ ok: true; resolved: string } | { ok: false; reason: SpeculativeReadTargetFailure }> { try { const [workspace, resolved] = await Promise.all([fs.realpath(cwd), fs.realpath(path.resolve(cwd, lexicalPath))]); const workspaceRelativePath = path.relative(workspace, resolved); if ( workspaceRelativePath.length === 0 || workspaceRelativePath === ".." || workspaceRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(workspaceRelativePath) ) { return { ok: false, reason: "local read target is unsafe" }; } const targetStat = await fs.stat(resolved); if (!targetStat.isFile() || targetStat.size > SNAPSHOT_MAX_BYTES) { return { ok: false, reason: "local read target is unsafe" }; } return { ok: true, resolved }; } catch { return { ok: false, reason: "local read path is unavailable" }; } } export interface LocalReadSpeculationEvidence { kind: "local_read"; resource: string; /** {@link digestLocalReadBytes} of the exact bytes the speculative read consumed. */ snapshotDigest: string; } /** * Digest the raw bytes of a speculative local read. The speculation host and * the speculative execution must both use this, so their evidence compares. */ export function digestLocalReadBytes(bytes: Uint8Array): string { return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); } async function assessLocalReadSpeculation( session: ToolSession, args: Readonly>, ): Promise { if (getReadTextFileBridge(session)) return LOCAL_READ_SPECULATION_INELIGIBLE; if (typeof args.path !== "string") return LOCAL_READ_SPECULATION_INELIGIBLE; const target = splitPathAndSel(args.path); if ( target.sel !== undefined || args.path.startsWith("www.") || args.path.includes(":") || args.path.includes("?") || args.path.includes("#") ) { return LOCAL_READ_SPECULATION_INELIGIBLE; } // Metadata-only: this assessment performs no filesystem I/O. The // coordinator runs assessment before host authorization, so any // realpath/stat/sniff here would touch disk even for reads the approval // policy or lifecycle handlers go on to deny. Content inspection instead // runs inside host authorization (after those policy gates allow the // candidate), and execution revalidates before anything can commit. A // provisional admission that later fails evidence falls back to ordinary // execution and never commits. const lexicalPath = path.resolve(session.cwd, args.path); const workspaceRelativePath = path.relative(session.cwd, lexicalPath); if ( workspaceRelativePath.length === 0 || workspaceRelativePath === ".." || workspaceRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(workspaceRelativePath) ) { return LOCAL_READ_SPECULATION_INELIGIBLE; } if ( lexicalPath.toLowerCase().endsWith(".ipynb") || isSampleProfilePath(lexicalPath) || isCpuProfilePath(lexicalPath) || CONVERTIBLE_EXTENSIONS.has(path.extname(lexicalPath).toLowerCase()) || lexicalPath.endsWith(".svg") || lexicalPath.endsWith(".svgz") ) { return LOCAL_READ_SPECULATION_INELIGIBLE; } return { eligible: true, effect: { kind: "local_read", resources: [{ scheme: "file", path: lexicalPath, access: "read" }] }, }; } /** * Read tool implementation. * * Reads files with support for images, converted documents (via markit), and text. * Directories return a formatted listing with modification times. */ export class ReadTool implements AgentTool { readonly name = "read"; readonly readsSkillUris = true; readonly approval = (args: unknown): ToolTier => { let readPath = ""; if (args && typeof args === "object" && "path" in args) readPath = String(args.path ?? ""); const urlTier = InternalUrlRouter.instance().readTier(readPath); if (urlTier !== "read") return urlTier; readPath = splitImageQuestionTarget(readPath).path; const target = splitPathAndSel(readPath); return target.sel === undefined && splitPdfImageReadPath(readPath) ? "exec" : "read"; }; /** * `always-ask-cached` key: the file this read targets, with every selector * (`:120-140`, `:raw`, `:img`, `?q=`) peeled, so all reads of one file share * a single grant. Internal-URL targets keep their normalized form. * * Public web reads (`http(s)://`, `www.`) report no key at all: their * contents are already public, so the cached mode allows them like a * listing instead of asking, and there is nothing to remember. The legacy * PDF image-member syntax keeps its target — that read is exec-tier, and * its remembered grant is what silences the prompt. */ readonly approvalCacheTargets = (args: unknown): readonly string[] | undefined => { const rawPath = args && typeof args === "object" && "path" in args ? String(args.path ?? "") : ""; if (rawPath.length === 0) return undefined; const withoutImageQuestion = splitImageQuestionTarget(rawPath).path; const router = InternalUrlRouter.instance(); // Authorization must key what execution reads: an existing literal file like `notes:1` is // read literally, so its grant belongs to `notes:1`, not to `notes`. const { path: selectorless, sel } = router.canHandle(withoutImageQuestion) ? router.split(withoutImageQuestion) : splitPathAndSelPreferringLiteralSync(withoutImageQuestion, this.session.cwd); if (isReadableUrlPath(selectorless) && !(sel === undefined && splitPdfImageReadPath(withoutImageQuestion))) { // Nobody needs permission to read a public page, so those stay ungated. A private host — // loopback, a private or link-local address, a single-label intranet name — is reachable // only from inside this machine or network, so it is gated like a file. if (isApprovalPublicUrl(selectorless)) return undefined; } const key = approvalTargetKey(this.session.cwd, selectorless); if (key === undefined) return undefined; // A directory read lists that folder itself, so it keys as the folder grant: approving it // covers the folder's own entries, and a parent folder's grant never authorizes reading a // subfolder's contents. if (path.isAbsolute(key) && !approvalTargetNamesFile(key)) return [approvalDirectoryKey(key)]; return [key]; }; readonly label = "Read"; readonly loadMode = "essential"; /** Rendered per access so the hashline guidance follows a live `edit.mode` change. */ get description(): string { return prompt.render(readDescription, { IS_HL_MODE: resolveFileDisplayMode(this.session).hashLines, BINARY_VIEWS: cfgIdaAvailable.get(this.session.settings), }); } readonly parameters = readSchema; readonly strict = true; readonly speculation = { finalized: { assess: ({ args }: { args: Readonly> }) => assessLocalReadSpeculation(this.session, args), execute: (context: ToolSpeculationExecutionContext, signal: AbortSignal) => this.#executeSpeculativeRead(context, signal), commit: (context: ToolSpeculationCommitContext, outcome: SpeculativePhysicalOutcome) => this.#commitSpeculativeRead(context, outcome), discard: (context: ToolSpeculationDiscardContext) => { const execution = this.#speculativeReadExecutions.get(context.toolCall.id); if (execution) execution.discarded = true; this.#speculativeReads.delete(context.toolCall.id); }, }, }; #speculativeReads = new Map< string, { store: EditStore; snapshotHash: string; absolutePath: string; path: string } >(); #speculativeReadExecutions = new Map(); constructor( private readonly session: ToolSession, private readonly completeImageRequest: typeof completeSimple = completeSimple, ) {} /** Live `read.defaultLimit`, clamped to `[1, DEFAULT_MAX_LINES]`. */ get #defaultLimit(): number { return Math.max(1, Math.min(cfgReadDefaultLimit.get(this.session.settings), DEFAULT_MAX_LINES)); } async #executeSpeculativeRead( context: ToolSpeculationExecutionContext, signal: AbortSignal, ): Promise { if (context.effect.kind !== "local_read" || context.effect.resources.length !== 1) { throw new Error("Invalid speculative read operation"); } const execution = { discarded: false }; this.#speculativeReadExecutions.set(context.toolCall.id, execution); const target = await resolveSpeculativeReadTarget(this.session.cwd, context.effect.resources[0].path); if (!target.ok) { throw new Error("Speculative read target is unavailable"); } const absolutePath = target.resolved; let snapshotDigest: string | undefined; try { const speculativeSession = Object.create(this.session) as ToolSession; Object.defineProperty(speculativeSession, "editStore", { value: undefined, writable: true, configurable: true, }); Object.defineProperty(speculativeSession, "conflictHistory", { value: undefined, writable: true, configurable: true, }); Object.defineProperty(speculativeSession, "getClientBridge", { value: () => undefined, configurable: true, }); const store = getEditStore(speculativeSession); const speculativeTool = new ReadTool(speculativeSession); // Open the authorized resolved target: between validation above and // the open below the lexical symlink could be retargeted (including // outside the workspace), and later validation can reject the result // but cannot undo that filesystem access. Render the requested // lexical path instead, so the committed result matches an ordinary // read exactly (hashline headers, source metadata). const requestedPath = (context.args as ReadParams).path; const result = await speculativeTool.#executeInner( context.toolCall.id, { ...(context.args as ReadParams), path: absolutePath }, signal, undefined, undefined, bytes => { snapshotDigest = digestLocalReadBytes(bytes); }, () => { throw new Error("Conflict-aware reads require authoritative execution"); }, path.resolve(this.session.cwd, requestedPath), ); const snapshotHash = store.headHash(absolutePath); if (!signal.aborted && !execution.discarded && snapshotHash) { this.#speculativeReads.set(context.toolCall.id, { store, snapshotHash, absolutePath, path: (context.args as ReadParams).path, }); } if (!snapshotDigest) throw new Error("Speculative read did not consume one stable buffered snapshot"); return { kind: "result", result, isError: result.isError === true, evidence: { kind: "local_read", resource: absolutePath, snapshotDigest, } satisfies LocalReadSpeculationEvidence, }; } finally { if (this.#speculativeReadExecutions.get(context.toolCall.id) === execution) { this.#speculativeReadExecutions.delete(context.toolCall.id); } } } async #commitSpeculativeRead( context: ToolSpeculationCommitContext, outcome: SpeculativePhysicalOutcome, ): Promise> { try { if (outcome.kind !== "result") throw new Error("Speculative read produced a staged outcome"); const staged = this.#speculativeReads.get(context.toolCall.id); const result = outcome.result as AgentToolResult; if (staged) { const snapshotText = staged.store.byHashText(staged.absolutePath, staged.snapshotHash); if (snapshotText !== null) { const seenLines = staged.store.seenLines(staged.absolutePath, staged.snapshotHash); getEditStore(this.session).recordSnapshot(staged.absolutePath, snapshotText, seenLines); } } appendRepeatReadHint(this.session, staged?.path ?? (context.args as ReadParams).path, result); return await postProcessToolResult(result, this.name, this.session as unknown as AgentToolContext); } finally { this.#speculativeReads.delete(context.toolCall.id); } } /** * Recover the active approved plan when a model rewrites its internal URL * as a same-basename path in the working-directory root. * * Only missing cwd-root paths qualify, so a real working-tree file always * wins and unrelated paths cannot escape into the session artifact sandbox. */ async #approvedPlanAlias(missingAbsolutePath: string, signal?: AbortSignal): Promise { const planReferencePath = this.session.getPlanReferencePath?.(); if (!planReferencePath) return undefined; const requestedPath = path.resolve(missingAbsolutePath); if (path.dirname(requestedPath) !== path.resolve(this.session.cwd)) return undefined; let approvedPlanPath: string | null; try { approvedPlanPath = await InternalUrlRouter.instance().locate( planReferencePath, sessionResolveContext(this.session, { signal }), ); } catch { return undefined; } return approvedPlanPath !== null && path.basename(requestedPath) === path.basename(approvedPlanPath) ? approvedPlanPath : undefined; } async #tryReadDelimitedPaths( readPath: string, signal: AbortSignal | undefined, toolContext: AgentToolContext | undefined, routedUrlPredicate?: (entry: string) => boolean, ): Promise | null> { const parts = await splitDelimitedPathEntry(readPath, this.session.cwd, { routedUrlPredicate }); return parts ? this.#readDelimitedParts(parts, signal, toolContext) : null; } /** Read each part as its own call; `toolContext` carries flags such as `rawImages` into every part. */ async #readDelimitedParts( parts: string[], signal: AbortSignal | undefined, toolContext: AgentToolContext | undefined, ): Promise> { const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`; const notes = [notice]; const content: Array = []; const displayReadTargets: string[] = []; const displayReadTargetLinks: Array = []; let pendingText = notice; const flushText = () => { if (pendingText.length === 0) return; content.push({ type: "text", text: pendingText }); pendingText = ""; }; const appendText = (text: string) => { pendingText = pendingText.length > 0 ? `${pendingText}\n\n${text}` : text; }; for (const part of parts) { try { const result = await this.execute("read-delimited-part", { path: part }, signal, undefined, toolContext); const nestedTargets = result.details?.displayReadTargets; if (nestedTargets?.length) { const nestedLinks = result.details?.displayReadTargetLinks; for (const [index, target] of nestedTargets.entries()) { displayReadTargets.push(target); displayReadTargetLinks.push(nestedLinks?.[index] ?? null); } } else { displayReadTargets.push(result.details?.suffixResolution?.to ?? part); displayReadTargetLinks.push(readDetailsLinkPath(result.details)); } for (const block of result.content) { if (block.type === "text") { appendText(block.text); continue; } flushText(); content.push(block); } } catch (error) { if (error instanceof ToolAbortError || signal?.aborted) throw error; const message = error instanceof Error ? error.message : String(error); const errorNote = `Could not read ${part}: ${message}`; notes.push(errorNote); displayReadTargets.push(part); displayReadTargetLinks.push(null); appendText(`[${errorNote}]`); } } flushText(); return toolResult({ notes, displayReadTargets, displayReadTargetLinks }).content(content).done(); } /** * Reinterpret a read target pointing at a video file. Timestamp selectors * (`clip.mp4:1h5m42s`, `clip.mp4:0:05`) never survive line-selector parsing, * so peel one off the raw path whenever it parses as a video frame or * timestamp selector; a literal file named by the peeled path wins, and * `splitPathAndSelPreferringLiteral` already reports the full entry as the * peeled path whenever that entry exists. The base does not have to sit in * the session cwd: unique workspace suffixes still resolve it, and a genuinely * missing file then reports its path instead of a colon-mangled one. */ async #applyVideoSelectorFallback( literalSplit: { path: string; sel?: string }, readPath: string, ): Promise<{ path: string; sel?: string }> { const videoSplit = splitVideoReadTarget(readPath); if (!videoSplit) return literalSplit; if ((await probeLiteralPathExists(literalSplit.path, this.session.cwd)) === "exists") return literalSplit; return videoSplit; } /** * Read a video file as a contact-sheet preview grid (bare read) or a single * frame (`:412` frame index, `:1h5m42s`/`:90s` timestamp). Both ride the * image pipeline so model context sees real pixels plus a metadata block. */ async #readVideoFile( absolutePath: string, sel: string | undefined, fileSize: number, suffixResolution: { from: string; to: string } | undefined, question: string | undefined, signal?: AbortSignal, ): Promise> { if (question !== undefined) throw new ToolError(IMAGE_QUESTION_SELECTOR_ERROR); const resolvedDisplayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd); const mimeType = videoMimeForPath(absolutePath); const selector = parseVideoSelector(sel); if (selector === null && sel !== undefined) { throw new ToolError( `Invalid selector ':${sel}' on '${resolvedDisplayPath}'. Use : (e.g. :412) or : (e.g. :1h5m42s, :90s) to extract a frame, or read without a selector for a preview grid.`, ); } const applySuffix = (text: string): string => suffixResolution ? prependSuffixResolutionNotice(text, suffixResolution) : text; let meta: VideoMetadata; try { meta = await probeVideo(absolutePath, signal); } catch (error) { if (error instanceof VideoError) throw new ToolError(error.message); throw error; } if (!(this.session.getActiveModel?.()?.input.includes("image") ?? true)) { const hint = "\n\nIf you want to see the video, read a frame with " + `${resolvedDisplayPath}: (e.g. :0:05) or ${resolvedDisplayPath}: (e.g. :412).`; return toolResult({ resolvedPath: absolutePath, contentType: mimeType, fileSize, suffixResolution, }) .text( applySuffix( `${formatVideoDetails(resolvedDisplayPath, meta, fileSize, mimeType)}${selector ? "" : hint}`, ), ) .sourcePath(absolutePath) .done(); } let png: VideoPng; let label: string; try { if (selector) { png = await extractVideoFramePng(absolutePath, selector, signal); label = selector.kind === "time" ? `Video frame: ${resolvedDisplayPath} @ ${selector.raw}` : `Video frame: ${resolvedDisplayPath} (frame ${selector.frame})`; } else { const sheet = await buildVideoContactSheetPng(absolutePath, meta, signal); png = sheet.png; label = `Video preview grid: ${resolvedDisplayPath} (${sheet.thumbs} frames, ${sheet.cols}x${sheet.rows})`; } } catch (error) { if (error instanceof VideoError) throw new ToolError(error.message); throw error; } let image: ImageContent = { type: "image", data: png.data, mimeType: png.mimeType }; let dimensionNote: string | undefined; if (cfgImagesAutoResize.get(this.session.settings)) { try { const resized = await resizeImage(image, { excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()), }); dimensionNote = formatDimensionNote(resized); image = { type: "image", data: resized.data, mimeType: resized.mimeType }; } catch { // Fall back to the extracted PNG when resize fails. } } const previewBytes = Buffer.from(image.data, "base64").length; if (previewBytes > MAX_IMAGE_SIZE) { throw new ToolError( `Video preview too large: ${formatBytes(previewBytes)} exceeds ${formatBytes(MAX_IMAGE_SIZE)} limit.`, ); } const text = applySuffix( `${label}\n${formatVideoDetails(resolvedDisplayPath, meta, fileSize, mimeType)}${dimensionNote ? `\n${dimensionNote}` : ""}`, ); return toolResult({ resolvedPath: absolutePath, contentType: mimeType, fileSize, suffixResolution, }) .content([{ type: "text", text }, image]) .sourcePath(absolutePath) .done(); } async #readPdfPageScreenshot(options: PdfPageReadOptions): Promise> { const { readPath, absolutePdfPath, page, pdfFileSize, question, suffixResolution, signal } = options; const screenshot = await renderPdfPageScreenshot(this.session, absolutePdfPath, page, signal); const screenshotFile = Bun.file(screenshot.dest); const screenshotMetadata = await readImageMetadata(screenshot.dest); const loaded = await this.#loadImageContent({ readPath, absolutePath: screenshot.dest, mimeType: screenshot.mimeType, imageMetadata: screenshotMetadata, fileSize: screenshotFile.size, question, questionPath: readPath, signal, }); if (suffixResolution) { const firstText = loaded.content.find((entry): entry is TextContent => entry.type === "text"); if (firstText) firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution); } const image = loaded.content.find((entry): entry is ImageContent => entry.type === "image"); const details: ReadToolDetails = { ...loaded.details, resolvedPath: absolutePdfPath, contentType: image?.mimeType ?? screenshot.mimeType, fileSize: pdfFileSize, suffixResolution, }; return toolResult(details).content(loaded.content).sourcePath(loaded.sourcePath).done(); } /** * Build content for an image: answer an explicit question, return metadata * to text-only models, or inline pixels for image-capable models. */ async #loadImageContent(options: { readPath: string; absolutePath: string; mimeType: string; imageMetadata: ImageMetadata | null; fileSize: number; imageKind?: "svg" | "heif"; question?: string; questionPath?: string; signal?: AbortSignal; }): Promise<{ content: Array; details: ReadToolDetails; sourcePath: string }> { const { readPath, absolutePath, mimeType, imageMetadata, fileSize, imageKind, question, questionPath, signal } = options; if (!question && !(this.session.getActiveModel?.()?.input.includes("image") ?? true)) { const outputMime = imageMetadata?.mimeType ?? mimeType; const imageQuestionPath = questionPath ?? formatPathRelativeToCwd(absolutePath, this.session.cwd); const metadataLines = [ "Image metadata:", `- MIME: ${outputMime}`, `- Bytes: ${fileSize} (${formatBytes(fileSize)})`, imageMetadata?.width !== undefined && imageMetadata.height !== undefined ? `- Dimensions: ${imageMetadata.width}x${imageMetadata.height}` : "- Dimensions: unknown", imageMetadata?.channels !== undefined ? `- Channels: ${imageMetadata.channels}` : "- Channels: unknown", imageMetadata?.hasAlpha === true ? "- Alpha: yes" : imageMetadata?.hasAlpha === false ? "- Alpha: no" : "- Alpha: unknown", "", `To analyze the image, read \`${imageQuestionPath}?q=\` — the question is answered by a vision model and returned as text.`, ]; return { content: [{ type: "text", text: metadataLines.join("\n") }], details: {}, sourcePath: absolutePath }; } if (fileSize > MAX_IMAGE_SIZE) { const sizeStr = formatBytes(fileSize); const maxStr = formatBytes(MAX_IMAGE_SIZE); throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`); } try { const resolved = question ? resolveImageQuestionModel(this.session) : undefined; const imageLoadOptions = { path: readPath, cwd: this.session.cwd, autoResize: cfgImagesAutoResize.get(this.session.settings), maxBytes: MAX_IMAGE_SIZE, resolvedPath: absolutePath, excludeWebP: webpExclusionForModel(resolved?.model ?? this.session.getActiveModel?.()), }; const imageInput = imageKind === "svg" ? await loadSvgImageInput(imageLoadOptions) : imageKind === "heif" ? await loadHeifImageInput({ ...imageLoadOptions, detectedMimeType: mimeType }, signal) : await loadImageInput({ ...imageLoadOptions, detectedMimeType: mimeType }); if (!imageInput) { throw new ToolError( imageKind === "svg" ? "The ':img' selector only rasterizes .svg and .svgz files. Read a raster image such as PNG or JPEG without ':img'." : `Read image file [${mimeType}] failed: unsupported image format.`, ); } if (question && resolved) { const answer = await askImageQuestion( this.session, resolved, imageInput, question, signal, this.completeImageRequest, ); return { content: [{ type: "text", text: answer.text }], details: { resolvedPath: absolutePath, contentType: imageInput.mimeType }, sourcePath: imageInput.resolvedPath, }; } return { content: [ { type: "text", text: imageInput.textNote }, { type: "image", data: imageInput.data, mimeType: imageInput.mimeType }, ], details: {}, sourcePath: imageInput.resolvedPath, }; } catch (error) { // Both surface as an actionable tool error so the model can fix the input // instead of the corrupt/oversized payload entering the transcript. if (error instanceof ImageInputTooLargeError || error instanceof InvalidImageDataError) { throw new ToolError(error.message); } throw error; } } /** * Render multiple non-contiguous ranges of a local file. ACP bridge takes * priority when present (editor buffer is source of truth); otherwise ranges * are sliced out of `buffered` when the caller already materialized the file, * and streamed independently with their own line/byte budget when it did not. * Out-of-bounds ranges surface as inline notices rather than aborting the read. */ async #readLocalFileMultiRange( absolutePath: string, ranges: readonly LineRange[], fileSize: number, buffered: BufferedFileText | undefined, parsed: ParsedSelector, displayMode: { hashLines: boolean; lineNumbers: boolean }, suffixResolution: { from: string; to: string } | undefined, /** Target the in-body continuation hints name (a located read's URL, else empty). */ selectorBase: string, signal: AbortSignal | undefined, allowBridge = true, ): Promise<{ outputText: string; columnCut: ColumnCut | undefined; details?: ReadToolDetails; bridgeResult?: AgentToolResult; }> { const rawSelector = isRawSelector(parsed); // ACP bridge first — the editor's in-memory buffer is source of truth. const bridgePromise = allowBridge ? routeReadThroughBridge(this.session, absolutePath) : undefined; if (bridgePromise !== undefined) { try { const bridgeText = await bridgePromise; const bridgeResult = await buildInMemoryMultiRangeResult(this.session, bridgeText, ranges, { details: markMarkdownContentType( this.session, { resolvedPath: absolutePath, suffixResolution }, absolutePath, ), sourcePath: absolutePath, entityLabel: "file", raw: rawSelector, }); if (suffixResolution) { const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text"); if (firstText) firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution); } return { outputText: "", columnCut: undefined, bridgeResult }; } catch (error) { logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error }); } } const shouldAddHashLines = !rawSelector && displayMode.hashLines; const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers; const maxColumns = resolveOutputMaxColumns(this.session.settings); const blocks: string[] = []; const notices: string[] = []; const visibleSpans: Array<{ startLine: number; endLine: number }> = []; const displayLineByNumber = new Map(); const fullLines = rawSelector ? undefined : buffered?.addressableLines; let columnCut: ColumnCut | undefined; let cutLines: Set | undefined; let displayContent: { text: string; startLine: number; lineNumbers?: Array } | undefined; for (const range of ranges) { const rangeStart = range.startLine - 1; // 0-indexed const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit; const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES); // The file is already in memory for everything within the snapshot byte // cap, so slice ranges out of it instead of re-streaming per range. Raw // mode keeps CR bytes, so it slices the original segments instead. let collectedLines: string[]; let totalFileLines: number; let endsAtTerminalNewline = false; let prefixLines: Map | undefined; let stoppedInRange = false; const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512); if (fullLines) { totalFileLines = fullLines.length; collectedLines = fullLines.slice(rangeStart, rangeStart + maxLines); } else { const displayColumns = rawSelector ? 0 : maxColumns; const window = buffered ? collectLineWindowFromBuffer(buffered, rangeStart, maxLines, maxBytesForRead, maxLines, displayColumns) : await streamLinesFromFile(absolutePath, rangeStart, maxLines, maxBytesForRead, maxLines, signal, { stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES, displayColumns, }); totalFileLines = window.totalFileLines; collectedLines = window.lines; prefixLines = window.prefixLines; // The read budget can end a range early (a line too large for it, or one whose cut display overflows it). stoppedInRange = window.stoppedByByteLimit && rangeStart + collectedLines.length < Math.min(rangeStart + maxLines, totalFileLines); // An open raw range that reaches EOF keeps the file's terminal newline, as single-range reads do. endsAtTerminalNewline = range.endLine === undefined && window.reachedEof && window.hasTrailingNewline && rangeStart + collectedLines.length === totalFileLines; } const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`; if (rangeStart >= totalFileLines) { notices.push(`[Range ${bound} is beyond end of file (${totalFileLines} lines total); skipped]`); continue; } if (stoppedInRange) { const nextLine = range.startLine + collectedLines.length; const rest = range.endLine !== undefined ? `${nextLine}-${range.endLine}` : `${nextLine}`; notices.push( `[Range ${bound} stopped before line ${nextLine} at the ${formatBytes(maxBytesForRead)} read budget. Use ${selectorBase}:${rest} to continue]`, ); } // Column truncation is display-only; clone before stamping ellipsis so // the original on-disk lines stay intact for display reconstruction. let displayLines: string[] = collectedLines; if (!rawSelector && maxColumns > 0) { let cloned: string[] | undefined; for (let i = 0; i < collectedLines.length; i++) { const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns); if (wasTruncated) { if (!cloned) cloned = collectedLines.slice(); cloned[i] = text; columnCut = addColumnCut( columnCut, maxColumns, range.startLine + i, prefixLines?.get(i) ?? Buffer.byteLength(collectedLines[i], "utf-8"), ); (cutLines ??= new Set()).add(range.startLine + i); } } if (cloned) displayLines = cloned; } if (displayLines.length > 0) { const endLine = range.startLine + displayLines.length - 1; visibleSpans.push({ startLine: range.startLine, endLine }); for (let i = 0; i < displayLines.length; i++) { displayLineByNumber.set(range.startLine + i, displayLines[i] ?? ""); } if (!fullLines || rawSelector) { const blockText = displayLines.join("\n") + (rawSelector && endsAtTerminalNewline ? "\n" : ""); blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); } } } let outputText: string; if (!rawSelector && fullLines && visibleSpans.length > 0) { if (buffered && !spansCoverEveryLine(visibleSpans, fullLines.length)) { await warmBlockContext({ path: absolutePath, text: buffered.normalizedText }); } const entries = buildLineEntriesWithBlockContext( fullLines, visibleSpans, { path: absolutePath, text: buffered?.normalizedText }, { lineText: (lineNumber, sourceText) => { const visibleText = displayLineByNumber.get(lineNumber); if (visibleText !== undefined) return visibleText; if (maxColumns <= 0) return sourceText; const truncated = truncateLine(sourceText, maxColumns); if (truncated.wasTruncated) { columnCut = addColumnCut( columnCut, maxColumns, lineNumber, Buffer.byteLength(sourceText, "utf-8"), ); (cutLines ??= new Set()).add(lineNumber); } return truncated.text; }, }, ); const firstLine = entries.find(entry => entry.kind === "line"); displayContent = { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), startLine: firstLine?.kind === "line" ? firstLine.lineNumber : (visibleSpans[0]?.startLine ?? 1), lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)), }; outputText = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); } else { outputText = blocks.join(rawSelector ? "\n" : "\n\n…\n\n"); } if (shouldAddHashLines && outputText) { const tag = buffered ? getEditStore(this.session).recordSnapshot(absolutePath, buffered.normalizedText) : getEditStore(this.session).recordSnapshotFile(absolutePath); if (tag) { recordFullySeenRows(getEditStore(this.session), absolutePath, tag, outputText, cutLines); outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`; } } else if (rawSelector && visibleSpans.length > 0) { const rawSeenLines = lineNumbersFromSpans(visibleSpans); if (rawSeenLines.length > 0) { if (buffered) { getEditStore(this.session).recordSnapshot(absolutePath, buffered.normalizedText, rawSeenLines); } else { getEditStore(this.session).recordSnapshotFile(absolutePath, rawSeenLines); } } } const details: ReadToolDetails = { ...(displayContent && { displayContent }), rowPrefixes: shouldAddHashLines || shouldAddLineNumbers, raw: rawSelector || undefined, }; if (notices.length > 0) outputText = appendReadNotice(outputText, notices.join("\n"), details); return { outputText, columnCut, details }; } async execute( toolCallId: string, params: ReadParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, toolContext?: AgentToolContext, ): Promise> { const inner = await this.#executeInner(toolCallId, params, signal, onUpdate, toolContext); const innerDetails: ReadDetailsWithWindow | undefined = inner.details; const result = await shapeToolResult( this.session, toolCallId, { tool: "read", source: `read ${params.path}`, path: params.path, read: innerDetails?.[kSpotlightWindow] }, inner, signal, { plain: innerDetails?.[kSpotlightWide] ? () => this.#executeInner( toolCallId, params, signal, undefined, toolContext, undefined, undefined, undefined, true, ) : undefined, }, ); const displayTarget = InternalUrlRouter.instance().locateSync(params.path); if (displayTarget && result.details) result.details.displayTarget = displayTarget; appendRepeatReadHint(this.session, params.path, result); return result; } async #executeInner( _toolCallId: string, params: ReadParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, toolContext?: AgentToolContext, onBufferedFile?: (bytes: Buffer) => void, onConflictMarkers?: () => void, lexicalAbsolutePath?: string, plainWindow?: boolean, ): Promise> { let { path: readPath } = params; if (readPath.startsWith("file://")) { readPath = expandPath(readPath); } readPath = recoverConflictUriPrefix(readPath).path; // A `;` list mixing URLs with local paths must split before URL detection // claims the whole string as one fetch or one internal/MCP resource. const mixedParts = await splitMixedUrlPathList( readPath, this.session.cwd, part => parseReadUrlTarget(part) !== null || InternalUrlRouter.instance().canResolve(part), ); if (mixedParts) return this.#readDelimitedParts(mixedParts, signal, toolContext); // Filesystem-backed reads choose JSON versus image questions after resolving // the literal file and sniffing its content, not from its final extension. // A `local://`/`artifact://` URL is resolved by its handler first, so a question // on a media target (`:img`, an image, video or PDF page) is peeled here as before. const scheme = extractUriScheme(readPath); const imageQuestion = !scheme || ((scheme === "local" || scheme === "artifact") && !isMediaQuestionTarget(readPath)) ? { path: readPath } : splitImageQuestionTarget(readPath); readPath = imageQuestion.path; const question = imageQuestion.question; const parsedUrlTarget = parseReadUrlTarget(readPath); if (parsedUrlTarget) { if (!cfgFetchEnabled.get(this.session.settings)) { throw new ToolError("URL reads are disabled by settings."); } const urlSel = parsedUrlTarget.sel; const urlRaw = isRawSelector(urlSel); if (urlSel.kind === "lines" || urlSel.kind === "tail") { const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, { ensureArtifact: true, }); return buildInMemorySelectorResult(this.session, entry.content, urlSel, { details: { ...entry.details }, sourceUrl: entry.details.finalUrl, entityLabel: "URL output", immutable: true, }); } return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal); } // Handle native OMP URLs and custom-scheme resources advertised by MCP servers. const internalRouter = InternalUrlRouter.instance(); const delimitedInternalResult = internalRouter.canResolve(readPath) ? await this.#tryReadDelimitedPaths(readPath, signal, toolContext, entry => internalRouter.canResolve(entry)) : null; if (delimitedInternalResult) return delimitedInternalResult; let located: LocatedRead | undefined; const normalizedPath = internalRouter.normalize(readPath); if (internalRouter.canResolve(normalizedPath)) { // Reject a malformed peeled selector before any handler resolves the URL. const peeled = internalRouter.split(normalizedPath); const parsed = parseSel(peeled.sel); if (peeled.sel !== undefined && parsed.kind === "none") { throw new ToolError( `Invalid selector ':${peeled.sel}' on '${peeled.path}'. Use :N, :N-M, :N+K, :N- (open-ended), :-N (last N lines), a comma-separated list of ranges, :raw, :img for SVG rendering, or a range combined with raw (e.g. :raw:50-100).`, ); } // SSH owns arbitrary URL queries. Only a recognized read-only JSON selector // is peeled here; its filter never reaches the remote path or shell. const sshQuery = extractUriScheme(peeled.path) === "ssh" ? splitJsonQueryTarget(peeled.path) : null; const sshSelector = sshQuery && !parseInternalUrl(peeled.path).hash && [...new URLSearchParams(sshQuery.queryString).keys()].every(key => ["q", "query", "raw", "compact", "offset", "limit"].includes(key), ) ? parseJsonSelector(sshQuery.queryString) : null; const target = await internalRouter.target( sshQuery && sshSelector ? sshQuery.jsonPath : normalizedPath, sessionResolveContext(this.session, { signal }), ); if (target) { const url = target.url.rawHref ?? target.url.href; if (sshSelector) { const resource = await internalRouter.resolve(url, sessionResolveContext(this.session, { signal })); if (resource.isDirectory) throw new ToolError("JSON selectors require a regular SSH file, not a directory."); return readJson( this.session, { url: peeled.path, content: resource.content, selector: sshSelector }, peeled.sel, signal, ); } if (target.kind === "resource") { try { return await this.#handleInternalUrl(url, target.spec, parsed, question, signal); } catch (error) { // An opaque `name:rest` that no MCP server advertises is most likely a plain // path (`Dockerfile:5`), so read it as one instead of reporting an MCP miss. if (!(error instanceof McpResourceNotFoundError) || url.includes("://")) throw error; } } else { located = { url, path: target.path, sel: target.sel, spec: target.spec }; } } } if (!located) { return this.#readFilesystemPath(readPath, { question, signal, onBufferedFile, onConflictMarkers, lexicalAbsolutePath, toolContext, plainWindow, }); } // A located URL reads its backing file through the filesystem pipeline (images, `:img`, // `?q=`, archives, sqlite, streaming, paging) while the URL stays the read's identity. const result = await this.#readFilesystemPath(located.path, { located, question, questionPath: readPath, signal, toolContext, plainWindow, }); const details: ReadToolDetails = result.details ?? {}; details.resolvedPath ??= located.path; // Protocol reads render Markdown regardless of `read.renderMarkdown`, as their resources always did. if (!details.contentType && isMarkdownPath(located.path)) details.contentType = "text/markdown"; details.meta = { ...details.meta, source: { type: "internal", value: located.url } }; // Nested skill reads need their own provenance: the outer invocation may belong to another plugin. if (extractUriScheme(located.url) === "skill" && !isRawSelector(parseSel(located.sel))) { const provenance = `[Skill file: ${located.path}]`; const firstText = result.content.find((block): block is TextContent => block.type === "text"); // The TUI falls back to this block when no structured display content exists. if (firstText) firstText.text = `${provenance}\n${firstText.text}`; else result.content.unshift({ type: "text", text: provenance }); } return { ...result, details }; } /** * The filesystem read pipeline: directories, archives, sqlite, PDFs, video, * images, documents, notebooks, and streamed/paged text. `located` carries * the URL a routed read resolved to `readPath`; its hints name that URL. * A whole read of a file Tern's media viewer shows records it as display-only * `details.mediaPath`, whatever content the model receives. */ async #readFilesystemPath( readPath: string, options: FilesystemReadOptions, ): Promise> { const media: { path?: string } = {}; const result = await this.#readFilesystemPipeline(readPath, options, media); if (media.path === undefined || result.isError) return result; return { ...result, details: { ...result.details, mediaPath: media.path } }; } /** {@link #readFilesystemPath}'s body; sets `media.path` once the read is known to be a whole Tern-viewable file. */ async #readFilesystemPipeline( readPath: string, options: FilesystemReadOptions, media: { path?: string }, ): Promise> { const { located, questionPath, signal, onBufferedFile, onConflictMarkers, lexicalAbsolutePath, toolContext, plainWindow, } = options; let { question } = options; const rawImages = toolContext?.rawImages === true; const immutable = located?.spec.immutable === true; const displayMode = resolveFileDisplayMode(this.session, { immutable }); // In-body continuation hints name the URL for located reads, so paging stays on the URL. const selectorBase = located?.url ?? ""; // One suffix-glob memo per read call — archive, sqlite, and plain-path // resolution share misses instead of re-globbing the workspace. const suffixCache: SuffixMatchCache = new Map(); // Prefer a literal filesystem match over selector interpretation so real // POSIX filenames containing selector-looking suffixes win over structured // archive / sqlite / unsupported PDF-image dispatch. A located URL's selector // stays separate so it cannot be mistaken for part of the resolved path. let literalSplit = located ? { path: readPath, sel: located.sel } : await splitPathAndSelPreferringLiteral(readPath, this.session.cwd); const rawPathIsLiteral = !located && literalSplit.sel === undefined && (splitPathAndSel(readPath).sel !== undefined || (readPath.includes("?") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing")); let pdfImageRead: PdfImageReadTarget | null = null; if (!rawPathIsLiteral) { const archivePath = await resolveArchiveReadPath(this.session, readPath, suffixCache, signal); if (archivePath?.suffixResolution) { markRawSuffixResolution(archivePath.suffixResolution, isRawSelector(parseSel(literalSplit.sel))); } if (archivePath) { if (question !== undefined) throw new ToolError(IMAGE_QUESTION_SELECTOR_ERROR); const archiveSubPath = located ? { path: archivePath.archiveSubPath, sel: located.sel } : splitPathAndSel(archivePath.archiveSubPath); const archiveParsed = parseSel(archiveSubPath.sel); return readArchive( this.session, located?.url ?? readPath, archiveParsed, { ...archivePath, archiveSubPath: archiveSubPath.path }, options => this.#readPdfPageScreenshot(options), signal, ); } const sqlitePath = await resolveSqliteReadPath(this.session, readPath, suffixCache, signal); if (sqlitePath) { return readSqlite(sqlitePath, signal); } // local:// and artifact:// locate backing files without their URL query; // restore it only for JSON reads, leaving other schemes' query semantics alone. const jsonQuery = located?.url.indexOf("?") ?? -1; const jsonScheme = jsonQuery >= 0 && located ? extractUriScheme(located.url) : null; const jsonReadPath = located && (jsonScheme === "local" || jsonScheme === "artifact") ? `${literalSplit.path}${located.url.slice(jsonQuery)}` : literalSplit.path; const jsonPath = located && jsonQuery < 0 ? null : await resolveJsonReadPath(this.session, jsonReadPath, suffixCache, signal); if (jsonPath) { if (jsonPath.suffixResolution) { markRawSuffixResolution(jsonPath.suffixResolution, isRawSelector(parseSel(literalSplit.sel))); } return readJson(this.session, jsonPath, literalSplit.sel, signal); } // JSON admission has ruled out this target (for example, raster image // bytes or an SVG/PDF image selector). Preserve the existing vision route. const questionTarget = splitJsonQueryTarget(located?.url ?? readPath); const imageQuestion = questionTarget ? new URLSearchParams(questionTarget.queryString).get("q") : null; if (questionTarget && imageQuestion) { question = imageQuestion; if (!located) { readPath = questionTarget.jsonPath; literalSplit = await splitPathAndSelPreferringLiteral(readPath, this.session.cwd); } } // `bin:main`, `bin:imports`, `bin:main:10-40`: an executable/IDB prefix // routes to an IDA view; `:raw` keeps the byte-verbatim escape hatch. if (question === undefined) { const binaryParsed = parseSel(literalSplit.sel); if (!isRawSelector(binaryParsed)) { const binaryView = await resolveBinaryViewPath(this.session, literalSplit.path); if (binaryView) return readBinary(this.session, binaryView, binaryParsed, signal); } } const pdfCandidate = literalSplit.sel === undefined ? splitPdfImageReadPath(readPath) : null; pdfImageRead = pdfCandidate && (await probeLiteralPathExists(readPath, this.session.cwd)) === "missing" ? pdfCandidate : null; } const localTarget = pdfImageRead ? { path: pdfImageRead.pdfPath, sel: undefined } : await this.#applyVideoSelectorFallback(literalSplit, readPath); const localReadPath = localTarget.path; // Video frame selectors (`:412`, `:1h5m42s`, `:0:05`) are not line selectors; // keep them out of the line parser so they reach the video reader intact. const parsed = isVideoPath(localTarget.path) && (localTarget.sel === undefined || parseVideoSelector(localTarget.sel) !== null) ? { kind: "none" as const } : parseSel(localTarget.sel); let absolutePath = await resolveReadPathAsync(localReadPath, this.session.cwd); let suffixResolution: { from: string; to: string } | undefined; let isDirectory = false; let fileSize = 0; let specialKind: string | undefined; try { const stat = await Bun.file(absolutePath).stat(); fileSize = stat.size; isDirectory = stat.isDirectory(); specialKind = specialFileKind(stat); } catch (error) { // An explicit list can exceed NAME_MAX while each member is valid. // Only list admission treats that error as recoverable, after the // literal/archive/SQLite paths above; a single filename keeps its error. if (!located && readPath.includes(";") && (isNotFoundError(error) || hasFsCode(error, "ENAMETOOLONG"))) { const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal, toolContext); if (delimitedResult) return delimitedResult; } // A located file vanished after routing: the handler owns the canonical not-found error. if (located && isNotFoundError(error)) { return this.#handleInternalUrl(located.url, located.spec, parsed, question, signal); } if (isNotFoundError(error)) { // Attempt unique suffix resolution before falling back to the approved-plan // alias or fuzzy suggestions. Existing workspace files retain precedence. if (!isRemoteMountPath(absolutePath)) { const suffixMatch = await findSuffixMatchCached(this.session, suffixCache, localReadPath, signal); if (suffixMatch) { try { const retryStat = await Bun.file(suffixMatch.absolutePath).stat(); absolutePath = suffixMatch.absolutePath; fileSize = retryStat.size; isDirectory = retryStat.isDirectory(); specialKind = specialFileKind(retryStat); suffixResolution = { from: localReadPath, to: suffixMatch.displayPath }; } catch { // Suffix match candidate no longer stats — continue through // approved-plan recovery and the original not-found error. } } } let recoveredApprovedPlan = false; if (!suffixResolution) { const approvedPlanPath = await this.#approvedPlanAlias(absolutePath, signal); if (approvedPlanPath) { try { const approvedPlanStat = await Bun.file(approvedPlanPath).stat(); absolutePath = approvedPlanPath; fileSize = approvedPlanStat.size; isDirectory = approvedPlanStat.isDirectory(); specialKind = specialFileKind(approvedPlanStat); recoveredApprovedPlan = true; } catch { // The referenced plan disappeared after resolution; continue through // the ordinary delimited-path fallback and not-found error. } } } if (!recoveredApprovedPlan && !suffixResolution) { const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal, toolContext); if (delimitedResult) return delimitedResult; throw new ToolError(`Path '${localReadPath}' not found`); } } else { throw error; } } if (specialKind) { throw new ToolError( `Cannot read '${localReadPath}': it is a ${specialKind}, not a regular file or directory.`, ); } // Speculative reads open the authorized resolved target (absolutePath) // but must behave exactly like an ordinary read of the requested // lexical path. All display derivations AND format/classification // decisions (extension, prose/markdown detection) below use this; // filesystem access keeps using absolutePath. Ordinary callers pass no // lexical path, so this is identical to absolutePath for them. const renderAbsolutePath = lexicalAbsolutePath ?? absolutePath; markRawSuffixResolution(suffixResolution, isRawSelector(parsed)); if (isDirectory) { if (question !== undefined) throw new ToolError(IMAGE_QUESTION_SELECTOR_ERROR); // A located directory lists through its handler, which names entries by URL. if (located) return this.#handleInternalUrl(located.url, located.spec, parsed, undefined, signal); if (isMultiRange(parsed)) { throw new ToolError("Multi-range line selectors are not supported for directory listings."); } // Directory listings are deterministic and fast; never abort them mid-scan // (an interrupt would otherwise surface a misleading "Operation aborted"). const dirResult = await this.#readDirectory(absolutePath, parsed, undefined); if (suffixResolution) { dirResult.details ??= {}; dirResult.details.suffixResolution = suffixResolution; } return dirResult; } if (parsed.kind === "conflicts") { return this.#readFileConflicts(absolutePath, suffixResolution, signal); } if (pdfImageRead) { return this.#readPdfPageScreenshot({ readPath, absolutePdfPath: absolutePath, page: pdfImageRead.page, pdfFileSize: fileSize, question, suffixResolution, signal, }); } // Located files are often large session storage (spilled tool output): a bare // `:raw` read inlines one only below the budget; bigger ones page with ranges. if (located && !located.spec.unbounded && parsed.kind === "raw" && fileSize > MAX_URL_RAW_INLINE_BYTES) { return toolResult({ resolvedPath: absolutePath }) .text(formatLocatedFileNotice(located.url, absolutePath, fileSize, true)) .done(); } // Whole-file reads only (no selector, `:raw`, `:img`); line ranges and video frames show their slice. if ( (localTarget.sel === undefined || parsed.kind === "raw" || parsed.kind === "image") && ternShowsMedia(absolutePath) ) { media.path = absolutePath; } const ext = path.extname(renderAbsolutePath).toLowerCase(); // Buffer once for every consumer below: the image sniff, the binary sniff, // the structural summary, the rendered window, bracket context, and the // snapshot hash all want the same bytes. Magic bytes come from the // in-memory header whenever the file is buffered (no extra open/read), // so a PNG stored as `.txt` still resolves at any size under the cap; // unbuffered files keep one centralized 256KB peek regardless of // extension, since only magic bytes — never the name — decide. const wholeFileBytes = fileSize <= SNAPSHOT_MAX_BYTES ? await readWholeFile(absolutePath) : undefined; const imageHeader = isRawSelector(parsed) && !rawImages ? undefined : (wholeFileBytes?.subarray(0, IMAGE_METADATA_HEADER_BYTES) ?? (await peekFile(absolutePath, IMAGE_METADATA_HEADER_BYTES, header => header))); const imageMetadata = imageHeader ? parseImageMetadata(imageHeader) : null; const mimeType = imageMetadata?.mimeType; // HEIC, AVIF and other HEIF stills reach the model decoded by ffmpeg instead of as binary. const heifMimeType = imageHeader && !mimeType ? parseHeifImageMimeType(imageHeader) : null; const resolvedDisplayPath = located?.url ?? formatPathRelativeToCwd(renderAbsolutePath, this.session.cwd); const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext); // Profiler reports (macOS `sample` call trees, V8 `.cpuprofile` JSON): // a whole-file read gets a bottleneck summary (hot paths, top self // time/samples) instead of the raw dump. Line selectors and `:raw` read the // file itself, so their line numbers and snapshot tag are the file's; text // that merely wears the extension falls through to the plain-text path. if (!mimeType && parsed.kind === "none" && fileSize <= MAX_PROFILE_SUMMARY_BYTES) { let rendered: string | null = null; if (isSampleProfilePath(renderAbsolutePath)) rendered = renderSampleProfile(await Bun.file(absolutePath).text()); else if (isCpuProfilePath(renderAbsolutePath)) rendered = renderCpuProfile(await Bun.file(absolutePath).text()); if (rendered) { // One unnumbered document: `:N` addresses the file, so the summary carries no row // numbers, snapshot tag or `:N` continuation, and no line count that would mark it a // complete read of the file. An oversized summary goes to the central artifact spill. return toolResult({ resolvedPath: renderAbsolutePath }) .text(rendered) .sourcePath(renderAbsolutePath) .done(); } } // Read the file based on type let content: Array | undefined; let details: ReadToolDetails = {}; let sourcePath: string | undefined; let pagedSource = false; let columnCut: ColumnCut | undefined; let truncationInfo: | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number; nextOffset?: number | null; maxBytes?: number; }; } | undefined; if (isVideoPath(absolutePath)) { return this.#readVideoFile(absolutePath, localTarget.sel, fileSize, suffixResolution, question, signal); } if (parsed.kind === "image") { ({ content, details, sourcePath } = await this.#loadImageContent({ readPath: localReadPath, absolutePath, mimeType: "image/svg+xml", imageMetadata: null, fileSize, imageKind: "svg", question, questionPath: questionPath ?? `${resolvedDisplayPath}:img`, signal, })); } else if (mimeType) { ({ content, details, sourcePath } = await this.#loadImageContent({ readPath, absolutePath, mimeType, imageMetadata, fileSize, question, questionPath, signal, })); } else if (heifMimeType) { ({ content, details, sourcePath } = await this.#loadImageContent({ readPath, absolutePath, mimeType: heifMimeType, imageMetadata: null, fileSize, imageKind: "heif", question, questionPath, signal, })); } else if (question !== undefined) { throw new ToolError(IMAGE_QUESTION_SELECTOR_ERROR); } else if (renderAbsolutePath.toLowerCase().endsWith(".ipynb") && !isRawSelector(parsed)) { let notebookJson: string; try { notebookJson = await Bun.file(absolutePath).text(); } catch (error) { if (isEnoent(error)) throw new Error(`File not found: ${resolvedDisplayPath}`); throw error; } const notebookText = notebookToEditableText(notebookJson, resolvedDisplayPath); return buildInMemorySelectorResult(this.session, notebookText, parsed, { details: { resolvedPath: renderAbsolutePath }, sourcePath: renderAbsolutePath, entityLabel: "notebook", }); } else if (shouldConvertWithMarkit) { // Convert document via markit. const result = await convertFileWithMarkit(absolutePath, signal); if (result.ok) { const renderedContent = result.content; // Route the converted markdown through the in-memory text builder // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and // raw mode apply against the converted output. Without this, // `file.pdf:50-100` silently returned the head of the document // because only `truncateHead` was being applied. return buildInMemorySelectorResult(this.session, renderedContent, parsed, { details: { resolvedPath: renderAbsolutePath, contentType: cfgReadRenderMarkdown.get(this.session.settings) ? "text/markdown" : undefined, }, sourcePath: renderAbsolutePath, entityLabel: "document", }); } else if (result.error) { content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }]; } else { content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }]; } } else { // `wholeFileBytes` was materialized once above for the image sniff; // reuse it here instead of a second full read. // Binary sniff before any UTF-8 text materialization. A binary file // (font, object, archive, packed blob) decodes to NUL/control bytes and // U+FFFD mojibake that corrupts the terminal and burns context. Images, // notebooks, and markit-convertible documents were already routed above; // everything reaching here is meant to be plain text. `:raw` stays the // explicit escape hatch for reading bytes verbatim. This single guard // covers both the multi-range and single-range disk paths below. const looksBinary = !isRawSelector(parsed) && (wholeFileBytes ? isProbablyBinaryHeader(wholeFileBytes.subarray(0, BINARY_SNIFF_BYTES)) : await isProbablyBinary(absolutePath)); // Executables and IDBs open in IDA instead of being refused. Speculative // reads (lexicalAbsolutePath set) never launch IDA; they fall back to an // ordinary execution that does. if ( looksBinary && lexicalAbsolutePath === undefined && cfgIdaAvailable.get(this.session.settings) && (isIdaDatabasePath(absolutePath) || (wholeFileBytes ? isExecutableHeader(wholeFileBytes.subarray(0, EXECUTABLE_SNIFF_BYTES)) : await isExecutableFile(absolutePath))) ) { return readBinary(this.session, { absolutePath, view: "" }, parsed, signal); } if (looksBinary) { return toolResult({ resolvedPath: renderAbsolutePath, suffixResolution }) .text( prependSuffixResolutionNotice( `[Cannot read binary file '${resolvedDisplayPath}' (${formatBytes(fileSize)}); not valid UTF-8 text. Use ':raw' to read bytes verbatim.]`, suffixResolution, ), ) .sourcePath(renderAbsolutePath) .done(); } // Decode only what survived the sniff. const buffered = wholeFileBytes ? deriveBufferedFileText(wholeFileBytes) : undefined; if (buffered) onBufferedFile?.(buffered.bytes); // Unbounded schemes (instruction documents) read whole: no summary, no result limits. if (located?.spec.unbounded) { const text = buffered?.strippedText ?? (await Bun.file(absolutePath).text()); return buildInMemorySelectorResult(this.session, text, parsed, { details: { resolvedPath: renderAbsolutePath }, sourcePath: renderAbsolutePath, entityLabel: "file", ignoreResultLimits: true, immutable, }); } if ( parsed.kind === "none" && cfgReadSummarizeEnabled.get(this.session.settings) && !nativeShapingOff(this.session) && (cfgReadSummarizeProse.get(this.session.settings) || !isProseSummaryPath(renderAbsolutePath)) ) { const summary = await trySummarize( this.session, absolutePath, fileSize, signal, buffered?.strippedText, renderAbsolutePath, ); if (summary?.parsed && summary.elided) { const renderedSummary = renderSummary(this.session, summary); const footer = formatSummaryElisionFooter( resolvedDisplayPath, renderedSummary.elidedRanges, renderedSummary.elidedLines, ); const summaryDisplayPath = formatPathRelativeToCwd(renderAbsolutePath, this.session.cwd); const summaryHashContext = displayMode.hashLines ? buffered ? hashlineHeaderContextForText( this.session, absolutePath, this.session.cwd, buffered.normalizedText, summaryDisplayPath, ) : await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd, summaryDisplayPath) : undefined; const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text; const modelText = prependHashlineHeader(bodyText, summaryHashContext); if (summaryHashContext?.tag) { getEditStore(this.session).recordSeenLinesFromBody( absolutePath, summaryHashContext.tag, renderedSummary.text, ); } details = { displayContent: { text: renderedSummary.displayText, startLine: 1 }, rowPrefixes: renderedSummary.rowPrefixes, summary: { lines: countTextLines(renderedSummary.text), elidedSpans: renderedSummary.elidedRanges.length, elidedLines: renderedSummary.elidedLines, }, }; sourcePath = renderAbsolutePath; content = [{ type: "text", text: modelText }]; } } if (!content) { const { sel, seekTo: tailSeekTo } = await resolveFileTailSelector(parsed, absolutePath, buffered); if (sel.kind === "lines" && sel.ranges.length > 1) { const multiResult = await this.#readLocalFileMultiRange( absolutePath, sel.ranges, fileSize, buffered, sel, displayMode, suffixResolution, selectorBase, undefined, // plain-file read: deterministic and fast, never abort mid-read !located, // located URLs read their backing file directly, as their handlers do ); if (multiResult.bridgeResult) return multiResult.bridgeResult; content = [{ type: "text", text: multiResult.outputText }]; sourcePath = absolutePath; details = multiResult.details ?? {}; columnCut = multiResult.columnCut; } else { // Raw text or line-range mode const { offset, limit } = selToOffsetLimit(sel); // Try ACP bridge first — editor's in-memory buffer is source of truth. // Request full text so local range rendering keeps normal context and line numbers. // Located URLs read their backing file directly, as their handlers do. const bridgePromise = located ? undefined : routeReadThroughBridge(this.session, absolutePath); if (bridgePromise !== undefined) { try { const bridgeText = await bridgePromise; const bridgeResult = await buildInMemoryTextResult(this.session, bridgeText, offset, limit, { details: markMarkdownContentType( this.session, { resolvedPath: absolutePath, suffixResolution }, absolutePath, ), sourcePath: absolutePath, entityLabel: "file", raw: isRawSelector(sel), }); if (suffixResolution) { const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text"); if (firstText) firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution); } return bridgeResult; } catch (error) { logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error }); } } // User-requested 0-indexed range start. Lines BEFORE this become // leading context (added below if offset is explicit). Raw mode // never adds context: without line numbers the padding is // indistinguishable from requested content, so `raw:31-31` must // return line 31 and nothing else. const rawSelector = isRawSelector(sel); const requestedStart = offset ? Math.max(0, offset - 1) : 0; const expandStart = !rawSelector && offset !== undefined && offset > 1; const expandEnd = !rawSelector && limit !== undefined; let leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0; const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0; let startLine = requestedStart - leadingContext; let startLineDisplay = startLine + 1; // Spotlight shaping outlines a whole-file read in place of the summarizer, so it collects // the file up to the summarizer's own caps instead of one page; the shaped text then goes // through the inline cap and spill like any result. const wholeFileShaping = offset === undefined && limit === undefined && !rawSelector && fileSize <= MAX_SUMMARY_BYTES && !plainWindow && shapesOutput(this.session, "read"); const lineCap = wholeFileShaping ? MAX_SUMMARY_LINES : DEFAULT_MAX_LINES; const DEFAULT_LIMIT = wholeFileShaping ? MAX_SUMMARY_LINES : this.#defaultLimit; const effectiveLimit = limit ?? DEFAULT_LIMIT; let maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, lineCap); let selectedLineLimit = effectiveLimit + leadingContext + trailingContext; // Scale byte budget with line limit so the configured line count actually fits // (~512 bytes/line, never below the shared default). Pages that would otherwise // spill also stop at the spill threshold: past it the page would be middle-elided // into an artifact, while stopping here pages at a line boundary with an exact // `:N` continuation. Row prefixes (`N|`/`N:`) count against that threshold, so // their worst-case width is reserved (at most half the threshold). Artifact-storage // pages never spill, so they keep the full budget. const budgetFor = (firstLine: number, lineCount: number): number => { if (wholeFileShaping) return MAX_SUMMARY_BYTES; const scaled = Math.max(DEFAULT_MAX_BYTES, lineCount * 512); if (located?.spec.artifactStore) return scaled; const threshold = resolveArtifactSpillThreshold(this.session.settings); const prefixWidth = !rawSelector && displayMode.lineNumbers ? String(firstLine + lineCount).length + 1 : 0; const prefixReserve = Math.min(lineCount * prefixWidth, Math.floor(threshold / 2)); return Math.min(scaled, threshold - prefixReserve); }; // Per-line column cap. Skipped in raw mode so `:raw` always returns // verbatim bytes for paste-back-into-tool workflows. Numbered pages // charge a cut line its capped width, so the budget bounds what is shown. const maxColumns = resolveOutputMaxColumns(this.session.settings); const displayColumns = rawSelector ? 0 : maxColumns; const collectWindow = async ( firstLine: number, lineCount: number, maxBytes: number, selectedLimit: number, ) => buffered ? collectLineWindowFromBuffer( buffered, firstLine, lineCount, maxBytes, selectedLimit, displayColumns, ) : await streamLinesFromFile( absolutePath, firstLine, lineCount, maxBytes, selectedLimit, undefined, { stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES, seek: tailSeekTo?.(firstLine), displayColumns, }, ); let maxBytesForRead = budgetFor(startLine, maxLinesToCollect); let lineWindow = await collectWindow(startLine, maxLinesToCollect, maxBytesForRead, selectedLineLimit); // Leading context is optional padding. If the byte budget stopped // before any requested line, retry from the requested start so an // oversized context line cannot hide the page that was asked for. // When the context itself fit (a cut wide line can), keep it unless // the retry shows the requested line: a requested line too large // alone keeps its "could not fit after preceding context" notice. if (leadingContext > 0 && lineWindow.stoppedByByteLimit && lineWindow.lines.length <= leadingContext) { const retryLinesToCollect = Math.min(effectiveLimit + trailingContext, DEFAULT_MAX_LINES); const retryBytes = budgetFor(requestedStart, retryLinesToCollect); const retryWindow = await collectWindow( requestedStart, retryLinesToCollect, retryBytes, effectiveLimit + trailingContext, ); if (lineWindow.lines.length < leadingContext || retryWindow.lines.length > 0) { leadingContext = 0; startLine = requestedStart; startLineDisplay = startLine + 1; maxLinesToCollect = retryLinesToCollect; selectedLineLimit = effectiveLimit + trailingContext; maxBytesForRead = retryBytes; lineWindow = retryWindow; } } const { lines: collectedLines, totalFileLines, collectedBytes, selectedBytes, stoppedByByteLimit, byteLimitLine, firstLinePreview, firstLineByteLength, reachedEof, prefixLines, hasTrailingNewline, } = lineWindow; // Check if offset is out of bounds - return graceful message instead of throwing if (requestedStart >= totalFileLines) { const suggestion = totalFileLines === 0 ? "The file is empty." : `Use ${selectorBase}:1 to read from the start, or ${selectorBase}:${totalFileLines} to read the last line.`; const boundsDetails: ReadToolDetails = { resolvedPath: renderAbsolutePath, suffixResolution, raw: rawSelector || undefined, }; const notice = `Line ${requestedStart + 1} is beyond end of file (${totalFileLines} lines total). ${suggestion}`; return toolResult(boundsDetails) .text(appendReadNotice("", notice, boundsDetails)) .done(); } // `truncation.totalBytes` keeps reflecting the source; `outputBytes` // counts the capped view, the bytes the page budget charged. // Column truncation is display-only. `collectedLines` MUST stay // byte-for-byte with the on-disk content (above the snapshot cap a // cut line may be only the head its display needs: `prefixLines`) // so the snapshot recorded below can be verified against the live // file. Mutating it with ellipsis-truncated text made every long-line // file uneditable on the next edit attempt. let displayLines: string[] = collectedLines; let cutLines: Set | undefined; if (!rawSelector && maxColumns > 0) { let cloned: string[] | undefined; for (let i = 0; i < collectedLines.length; i++) { const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns); if (wasTruncated) { if (!cloned) cloned = collectedLines.slice(); cloned[i] = text; columnCut = addColumnCut( columnCut, maxColumns, startLineDisplay + i, prefixLines?.get(i) ?? Buffer.byteLength(collectedLines[i], "utf-8"), ); (cutLines ??= new Set()).add(startLineDisplay + i); } } if (cloned) displayLines = cloned; } const displayLineByNumber = new Map(); for (let i = 0; i < displayLines.length; i++) { displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? ""); } const bracketContextFullLines = rawSelector ? undefined : buffered?.addressableLines; const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1); let selectedContent = displayLines.join("\n"); if ( rawSelector && limit === undefined && reachedEof && hasTrailingNewline && startLine + displayLines.length === totalFileLines ) { selectedContent += "\n"; } const userLimitedLines = collectedLines.length; const totalSelectedLines = totalFileLines - startLine; const selectedWindowLines = rawSelector && limit !== undefined ? Math.min(limit, totalSelectedLines) : totalSelectedLines; // A byte stop truncates the page only when it left a line out: a sole last line whose cut // display overflows the budget is still shown whole in its cut form. const wasTruncated = reachedEof && collectedLines.length < selectedWindowLines; // The collector rejects a first line only when neither its source nor its cut display fits. const firstLineExceedsLimit = collectedLines.length === 0 && stoppedByByteLimit; if ( bracketContextFullLines && buffered && !firstLineExceedsLimit && !spansCoverEveryLine( [{ startLine: startLineDisplay, endLine: displayedEndLine }], bracketContextFullLines.length, ) ) { await warmBlockContext({ path: absolutePath, text: buffered.normalizedText }); } const omittedSelectedLine = omittedRequestedLine( byteLimitLine, requestedStart, rawSelector, leadingContext, collectedLines.length, ); // A first line larger than the byte budget collects no complete // line, yet the window still renders a byte-capped preview. // Account for that preview so the notice/meta describe the // delivered partial line rather than reporting zero over the // ~50 KB shown on screen. const previewBytes = firstLineExceedsLimit ? (firstLinePreview?.bytes ?? 0) : 0; const truncation: TruncationResult | undefined = reachedEof ? { content: selectedContent, truncated: wasTruncated, truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined, totalLines: totalSelectedLines, totalBytes: firstLineExceedsLimit ? (firstLineByteLength ?? previewBytes) : selectedBytes, outputLines: firstLineExceedsLimit ? (previewBytes > 0 ? 1 : 0) : collectedLines.length, outputBytes: firstLineExceedsLimit ? previewBytes : collectedBytes, lastLinePartial: false, firstLineExceedsLimit, } : undefined; const shouldAddHashLines = !rawSelector && displayMode.hashLines; const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers; let hashContext: HashlineHeaderContext | undefined; if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) { // The tag is a content hash of the WHOLE file, so any anchor the // model returns validates while the live file is unchanged. The // buffered text is that whole file; above the snapshot cap only a // non-truncated whole-file window of whole lines can supply it. const isWholeFile = reachedEof && offset === undefined && limit === undefined && !wasTruncated && prefixLines === undefined; const tag = buffered ? getEditStore(this.session).recordSnapshot(absolutePath, buffered.normalizedText) : isWholeFile ? getEditStore(this.session).recordSnapshot( absolutePath, normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`), ) : getEditStore(this.session).recordSnapshotFile(absolutePath); if (tag) { hashContext = hashlineHeaderContext( formatPathRelativeToCwd(renderAbsolutePath, this.session.cwd), tag, ); } } let capturedDisplayContent: | { text: string; startLine: number; lineNumbers?: Array } | undefined; let emittedHashlineHeader = false; const formatText = (text: string, startNum: number): string => { const lineCount = countTextLines(text); capturedDisplayContent = { text, startLine: startNum, lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i), }; const formatted = formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; const formatBracketAwareText = (): string | undefined => { if (!bracketContextFullLines) return undefined; const entries = buildLineEntriesWithBlockContext( bracketContextFullLines, [{ startLine: startLineDisplay, endLine: displayedEndLine }], { path: absolutePath, text: buffered?.normalizedText }, { lineText: (lineNumber, sourceText) => { const visibleText = displayLineByNumber.get(lineNumber); if (visibleText !== undefined) return visibleText; if (maxColumns <= 0) return sourceText; const truncated = truncateLine(sourceText, maxColumns); if (truncated.wasTruncated) { columnCut = addColumnCut( columnCut, maxColumns, lineNumber, Buffer.byteLength(sourceText, "utf-8"), ); (cutLines ??= new Set()).add(lineNumber); } return truncated.text; }, }, ); const firstLine = entries.find(entry => entry.kind === "line"); capturedDisplayContent = { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay, lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)), }; const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; let outputText: string; if (firstLineExceedsLimit) { const firstLineBytes = firstLineByteLength ?? 0; const snippet = firstLinePreview ?? { text: "", bytes: 0 }; if (shouldAddHashLines) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`; } else { outputText = formatText(snippet.text, startLineDisplay); } if (snippet.text.length === 0) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`; } sourcePath = renderAbsolutePath; if (truncation) { details = { truncation: toReadTruncationStats(truncation) }; truncationInfo = { result: truncation, options: { direction: "head", startLine: startLineDisplay, totalFileLines, maxBytes: maxBytesForRead, }, }; } else { outputText += shouldAddHashLines ? "\n\n[File not scanned to EOF]" : `\n\n[Showing line ${startLineDisplay} (partial, ${formatBytes( snippet.bytes, )} of ${formatBytes(firstLineBytes)}); file not scanned to EOF]`; details = {}; } } else if (!reachedEof) { const nextOffset = startLine + userLimitedLines + 1; outputText = formatBracketAwareText() ?? formatText(selectedContent, startLineDisplay); if (omittedSelectedLine) { const lineNumber = omittedSelectedLine.index + 1; outputText += `\n\n${formatOmittedRequestedLineNotice( omittedSelectedLine, maxBytesForRead, `${selectorBase}:raw:${lineNumber}-${lineNumber}`, )}`; } else if (!rawSelector || limit === undefined || userLimitedLines < limit) { outputText += `\n\n[More lines in file (${formatBytes( fileSize, )} total; not scanned to EOF). Use ${selectorBase}:${nextOffset} to continue]`; } details = {}; sourcePath = renderAbsolutePath; } else if (truncation?.truncated) { outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); if (omittedSelectedLine) { const lineNumber = omittedSelectedLine.index + 1; outputText += `\n\n${formatOmittedRequestedLineNotice( omittedSelectedLine, maxBytesForRead, `${selectorBase}:raw:${lineNumber}-${lineNumber}`, )}`; } details = { truncation: toReadTruncationStats(truncation) }; sourcePath = renderAbsolutePath; truncationInfo = { result: truncation, options: { direction: "head", startLine: startLineDisplay, totalFileLines, nextOffset: omittedSelectedLine ? null : undefined, maxBytes: maxBytesForRead, }, }; } else if (!rawSelector && startLine + userLimitedLines < totalFileLines) { const nextOffset = startLine + userLimitedLines + 1; outputText = formatBracketAwareText() ?? formatText(selectedContent, startLineDisplay); outputText += `\n\n[${ totalFileLines - (startLine + userLimitedLines) } more lines in file. Use ${selectorBase}:${nextOffset} to continue]`; details = {}; sourcePath = renderAbsolutePath; } else { // No truncation, no user limit exceeded outputText = formatBracketAwareText() ?? formatText(selectedContent, startLineDisplay); details = {}; sourcePath = renderAbsolutePath; } if (reachedEof) details.totalLines = totalFileLines; details.rowPrefixes = shouldAddHashLines || shouldAddLineNumbers; if ( wholeFileShaping && (collectedLines.length < totalFileLines || collectedLines.length > this.#defaultLimit || collectedBytes > Math.min(DEFAULT_MAX_BYTES, resolveArtifactSpillThreshold(this.session.settings) / 2)) ) { (details as ReadDetailsWithWindow)[kSpotlightWide] = true; } if (hashContext?.tag) { recordFullySeenRows(getEditStore(this.session), absolutePath, hashContext.tag, outputText, cutLines); } if (rawSelector && !immutable && !firstLineExceedsLimit && collectedLines.length > 0) { // A raw read emits no header, but recording the range it displayed // lets a same-content hashline tag inherit its provenance. const seenLines = contiguousLineNumbers(startLineDisplay, collectedLines.length); if (buffered) { getEditStore(this.session).recordSnapshot(absolutePath, buffered.normalizedText, seenLines); } else { getEditStore(this.session).recordSnapshotFile(absolutePath, seenLines); } } if (capturedDisplayContent) { details.displayContent = capturedDisplayContent; } // Immutable sources have no edit path, so their markers are content, not conflicts. if (!immutable && !firstLineExceedsLimit && collectedLines.length > 0) { // A block holding a line kept only as its display head would register a cut side. const blocks = scanConflictLines(collectedLines, startLineDisplay).filter( block => !prefixLines || ![...prefixLines.keys()].some( index => block.startLine <= startLineDisplay + index && startLineDisplay + index <= block.endLine, ), ); if (blocks.length > 0) { onConflictMarkers?.(); const history = getConflictHistory(this.session); const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd); const entries = blocks.map(block => history.register({ absolutePath, displayPath: displayPathForWarning, ...block, }), ); // Cheap full-file scan only when the window already showed // at least one conflict — otherwise pay nothing on clean files. let totalInFile = entries.length; let truncatedAtLine: number | undefined; try { const fileScan = await scanFileForConflicts(absolutePath); totalInFile = Math.max(entries.length, fileScan.blocks.length); truncatedAtLine = fileScan.truncatedAtLine; } catch { // Best-effort enrichment; fall back to window-only count. } const warning = formatConflictWarning(entries, { totalInFile, displayPath: displayPathForWarning, truncatedAtLine, }); if (rawSelector) details.notes = [...(details.notes ?? []), warning]; else outputText += warning; details.conflictCount = entries.length; } } content = [{ type: "text", text: outputText }]; if ( !rawSelector && !firstLineExceedsLimit && collectedLines.length > 0 && shapingExperiment(this.session) ) { const shown = displayLines.map((line, i) => formatSingleLine(startLineDisplay + i, line, shouldAddHashLines, shouldAddLineNumbers), ); // Shaping replaces the window where it appears verbatim in the text; a window the text // renders otherwise is shaped as plain text. if (outputText.includes(shown.join("\n"))) { (details as ReadDetailsWithWindow)[kSpotlightWindow] = { path: resolvedDisplayPath, lines: collectedLines, shown, firstLine: startLineDisplay, selected: offset !== undefined || limit !== undefined, }; } } // `maxBytesForRead` bounds only the selected source lines; row prefixes and // off-window block context ride on top. Skip the spill only while the final page // still fits the inline cap, so block context can never bypass the bound. pagedSource = Buffer.byteLength(outputText, "utf-8") <= resolveInlineByteCapBudget(this.session.settings); } } // A page of artifact storage is re-readable with selectors: spilling it would only // duplicate it into another artifact. Every other unpaged located read spills like a plain file. if (located?.spec.artifactStore) pagedSource = true; // Immutable sources have no write path, so the hint never lands in a written-back file. if (located && immutable && !isRawSelector(parsed) && fileSize > MAX_URL_RAW_INLINE_BYTES) { const firstText = content.find((c): c is TextContent => c.type === "text"); if (firstText) { firstText.text += `\n\n[${formatLocatedFileNotice(located.url, absolutePath, fileSize, false)}]`; } } } details.fileSize = fileSize; if (isRawSelector(parsed)) details.raw = true; markMarkdownContentType(this.session, details, renderAbsolutePath); if (suffixResolution) { details.suffixResolution = suffixResolution; const firstText = content.find((c): c is TextContent => c.type === "text"); if (firstText) { firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution); } else { const notice = prependSuffixResolutionNotice("", suffixResolution); if (notice) content = [{ type: "text", text: notice }, ...content]; } } const resultBuilder = toolResult(details).content(content); if (sourcePath) { resultBuilder.sourcePath(sourcePath); } if (pagedSource) { resultBuilder.pagedSource(); } if (truncationInfo) { resultBuilder.truncation(truncationInfo.result, truncationInfo.options); } if (columnCut) { // A `:raw:N-N` read gets the budget of a one-line raw page; a cut line past it comes back byte-capped. const rawLineBudget = located?.spec.artifactStore ? DEFAULT_MAX_BYTES : Math.min(DEFAULT_MAX_BYTES, resolveArtifactSpillThreshold(this.session.settings)); resultBuilder.limits({ columnMax: columnCut.maxColumn, columnLines: { first: columnCut.first, last: columnCut.last }, columnSelectorBase: selectorBase, columnRawByteCap: columnCut.firstBytes > rawLineBudget ? rawLineBudget : undefined, }); } return resultBuilder.done(); } /** * Implement the `:conflicts` read selector: scan the whole file once, register * every block in the session's conflict history, and return a compact * `#N L_a-L_b` index instead of file content. Designed for heavily * conflicted files where dumping every body would be wasteful. */ async #readFileConflicts( absolutePath: string, suffixResolution: { from: string; to: string } | undefined, signal: AbortSignal | undefined, ): Promise> { throwIfAborted(signal); const scan = await scanFileForConflicts(absolutePath); const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd); const history = getConflictHistory(this.session); history.reconcilePath(absolutePath, scan); const entries = scan.blocks.map(block => history.register({ absolutePath, displayPath, ...block, }), ); let summary: string; if (entries.length > 0) { summary = formatConflictSummary(entries, { displayPath, truncatedAtLine: scan.truncatedAtLine }); } else if (scan.truncatedAtLine !== undefined) { summary = `No unresolved git merge conflicts before line ${scan.truncatedAtLine} of ${displayPath}.\n${formatScanCapNote(displayPath, scan.truncatedAtLine)}`; } else { summary = `No unresolved git merge conflicts in ${displayPath}.`; } const details: ReadToolDetails = { resolvedPath: absolutePath, suffixResolution, conflictCount: entries.length, }; return toolResult(details).text(summary).sourcePath(absolutePath).done(); } /** * Read a routed URL through its handler: virtual, remote, and device schemes, * plus located URLs whose target is a directory or has no local file. * Discrete values (`shape: "value"`) return as-is; documents page through * line selectors. */ async #handleInternalUrl( url: string, spec: SchemeSpec, parsedSel: ParsedSelector, question: string | undefined, signal?: AbortSignal, ): Promise> { if (parsedSel.kind === "image") { // A file-backed URL that locate deferred to a remote resolve (an unknown skill:// while // MCP is available) reports its local miss (`Unknown skill`), not the selector refusal. if (spec.backing === "file") { await InternalUrlRouter.instance().locate(url, sessionResolveContext(this.session, { signal }), { localOnly: true, }); } throw new ToolError("The ':img' selector requires a file-backed path."); } const resource = await InternalUrlRouter.instance().resolve(url, sessionResolveContext(this.session, { signal })); if (question !== undefined) throw new ToolError(IMAGE_QUESTION_SELECTOR_ERROR); const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType, ...resource.details, }; if (resource.shape === "value") { if (parsedSel.kind !== "none" && parsedSel.kind !== "raw") { throw new ToolError("Cannot combine query extraction with line selectors"); } if (parsedSel.kind === "raw") details.raw = true; return toolResult(details).text(resource.content).sourceInternal(url).done(); } return buildInMemorySelectorResult(this.session, resource.content, parsedSel, { details, sourcePath: resource.sourcePath, sourceStartLine: resource.sourceStartLine, sourceInternal: url, entityLabel: "resource", ignoreResultLimits: resource.unbounded ?? spec.unbounded === true, immutable: resource.immutable, }); } /** * Read directory contents as a formatted listing, sliced by a single-range or tail selector. * * The root level is uncapped; long listings page through line selectors and the * byte-truncation `:N` continuation. Child directories cap at * `READ_DIRECTORY_CHILD_LIMIT` entries and render an inline `… N more` marker; * reading the sub-path expands it, so no trailing limit notice is emitted. */ async #readDirectory( absolutePath: string, parsed: ParsedSelector, signal?: AbortSignal, ): Promise> { const READ_DIRECTORY_MAX_DEPTH = 2; const READ_DIRECTORY_CHILD_LIMIT = 12; throwIfAborted(signal); let tree: DirectoryTree; try { tree = await buildDirectoryTree(absolutePath, { maxDepth: READ_DIRECTORY_MAX_DEPTH, perDirLimit: READ_DIRECTORY_CHILD_LIMIT, rootLimit: null, // `lineCap` truncates the rendered tree itself; selectors slice the full // rendering below instead so `:N-M` and `:-N` see the same listing. lineCap: null, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new ToolError(`Cannot read directory: ${message}`); } throwIfAborted(signal); const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered; const details: ReadToolDetails = { isDirectory: true, resolvedPath: tree.rootPath, }; // Slice the rendered listing when the caller passed an offset/limit. We do this // instead of passing the selector down to `buildDirectoryTree` because the tree // builder lays out entries hierarchically (per-dir caps, recent-then-elided // summaries); line-based slicing operates on the formatted text and matches what // users expect from `:N-M` on long listings. if (parsed.kind === "lines" || parsed.kind === "tail") { const allLines = output.split("\n"); const { offset, limit } = selToOffsetLimit(resolveTailSelector(parsed, allLines.length)); const start = offset ? Math.max(0, offset - 1) : 0; if (start >= allLines.length) { const suggestion = allLines.length === 0 ? "The listing is empty." : `Use :1 to read from the start, or :${allLines.length} to read the last line.`; return toolResult(details) .text(`Line ${start + 1} is beyond end of listing (${allLines.length} lines total). ${suggestion}`) .sourcePath(tree.rootPath) .done(); } const end = limit !== undefined ? Math.min(start + limit, allLines.length) : allLines.length; const sliced = allLines.slice(start, end).join("\n"); const resultBuilder = toolResult(details).sourcePath(tree.rootPath); let text = sliced; if (end < allLines.length) { const remaining = allLines.length - end; text += `\n\n[${remaining} more lines in listing. Use :${end + 1} to continue]`; } return resultBuilder.text(text).done(); } const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER }); const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath); if (truncation.truncated) { resultBuilder.truncation(truncation, { direction: "head" }); details.truncation = toReadTruncationStats(truncation); } return resultBuilder.done(); } }