import type { GrepToolDetails } from "@oh-my-pi/pi-tui/tools/grep"; import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type EditStore, type GrepMatch, GrepOutputMode, type GrepResult } from "@oh-my-pi/pi-natives"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { LRUCache } from "@oh-my-pi/pi-utils/lru"; import { type ArchiveReader, type ExtractedArchiveFile, openArchive, parseArchivePathCandidates, } from "@oh-my-pi/pi-utils/ar"; import { getEditStore, recordFullySeenRows } from "../edit/store"; import { sessionResolveContext } from "../internal-urls/context"; import { InternalUrlRouter } from "../internal-urls/router"; import { InternalUrlFilesystem } from "../internal-urls/url-filesystem"; import { grep } from "../ovnfs/search"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, } from "@oh-my-pi/pi-tui/tools/streaming-output"; import { sessionDelegationBias } from "../task/prompt-policy"; import { isScoutSpawnable } from "../task/spawn-policy"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { resolveToolTier } from "./approval"; import { materializeReadUrlToFile, parseReadUrlTarget } from "./fetch"; import { createFileRecorder, formatResultPath, resultSnapshotPath } from "./file-recorder"; import { type FileMatchSection, formatFileMatches } from "@oh-my-pi/pi-tui/tools/grouped-file-output"; import { formatMatchLine } from "@oh-my-pi/pi-tui/tools/match-line-format"; import { isFindEnabled } from "./jfind"; import { expandDelimitedPathEntries, formatPathRelativeToCwd, hasGlobPathChars, isLineInRanges, normalizePathLikeInput, parseSearchPath, probeLiteralPathExists, probeLiteralPathExistsSync, relativeSearchResultPath, resolveReadPath, resolveSearchResultPath, resolveToolSearchScope, resolveToCwd, searchPathTier, splitPathAndSelPreferringLiteral, splitPathAndSelPreferringLiteralSync, } from "./path-utils"; import { RACY_STAMP_WINDOW_MS } from "./racy-stamp"; import { APPROVAL_ROOT_DELIMITERS, approvalRootCandidates, searchApprovalCacheTargets, } from "./search-approval-targets"; import { type LineRange, parseLineRanges, selectorLineRanges } from "@oh-my-pi/pi-tui/tools/line-ranges"; import { isReadableUrlPath, splitPathAndSel } from "@oh-my-pi/pi-tui/tools/read"; import { toPathList } from "@oh-my-pi/pi-tui/render/render-utils"; import { isRawSelector } from "./read-selector"; import { formatCodeFrameLine } from "@oh-my-pi/pi-tui/render/render-utils"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { toolResult } from "./tool-result"; import { cfgGrepContextAfter, cfgGrepContextBefore } from "./settings"; import { cfgTaskDisabledAgents, ovnfsCommand } from "../task/settings"; const searchSchema = type({ pattern: type("string"), "path?": "string", "case?": "boolean", "gitignore?": "boolean", "skip?": type("number").or("null"), }); export type GrepToolInput = typeof searchSchema.infer; /** Maximum number of distinct files surfaced in a single response. The * agent paginates further pages via `skip`. */ export const DEFAULT_FILE_LIMIT = 20; /** Per-file match cap for multi-file searches — keeps a single hot file * from crowding out diverse hits. Applied in JS after grep returns. */ export const MULTI_FILE_PER_FILE_MATCHES = 20; /** Per-file match cap for single-file searches — there's no diversity * concern when the scope is one file. */ export const SINGLE_FILE_MATCHES = 200; /** Hard safety ceiling on how many matches we fetch from native grep * before JS-side grouping. Sized to comfortably cover the file window * (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus * pagination headroom so the caller can see total file count. */ const INTERNAL_TOTAL_CAP = 2000; /** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep * searches only the first `MAX_FILE_BYTES` of a larger file (a leading mmap * window) and drops the rest; matches beyond the window are not returned. We * surface a partial-coverage note when the caller explicitly targeted such a * file so they know matches past the window are not shown. */ const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024; /** Wall-clock budget for a single native grep invocation. Without it, an * aborted or runaway search (huge tree, network mount) keeps burning CPU on * the native thread pool after the JS promise is abandoned. */ const SEARCH_GREP_TIMEOUT_MS = 30_000; /** * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional * line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …) * chunk via {@link splitPathAndSel}. */ interface GrepPathSpec { original: string; clean: string; literalFilesystemMatch?: boolean; /** An external URL entry: fetched, so never an archive member path (`file.zip:raw` is a selector). */ externalUrl?: true; ranges?: [LineRange, ...LineRange[]]; } /** * Mirror of read's `parseSel` selector grammar (`read.ts`) so `grep` accepts * exactly the internal-URL selectors `read` accepts: a single chunk that is a * line range, `raw`, or `conflicts`; or a two-chunk compound of exactly one `raw` * plus one line range. Everything else (`:1-1:1-2`, `:conflicts:1-1`, * `:raw:conflicts`) is rejected. Read's `:-N` tail is rejected too: a tail is * only meaningful once the resource's line count is known, and search filters * matches by absolute line number. * * This mirrors the *accepted set* of `parseSel`; `read` rejects the same shapes * caller-side when a peeled internal-URL selector parses as `none`, so neither * tool silently widens on a malformed compound. Keep in sync with `read.parseSel`. */ function isReadSelectorGrammar(sel: string): boolean { if (sel.includes(":")) { const chunks = sel.split(":"); if (chunks.length !== 2) return false; const [a, b] = chunks as [string, string]; const aIsRaw = a.toLowerCase() === "raw"; const bIsRaw = b.toLowerCase() === "raw"; const rangeChunk = aIsRaw ? b : bIsRaw ? a : null; return rangeChunk !== null && parseLineRanges(rangeChunk) !== null; } const lower = sel.toLowerCase(); return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null; } /** * External URL entries (`https://…`) carry read's display selectors. `read` * forwards `:raw` to the fetch (`parseReadUrlTarget` + `isRawSelector`), so * grep forwards it too instead of rejecting the entry as an unsupported * line-range selector (issue #14092). `:conflicts` is a display-only mode * with no meaning for content search — the whole fetched resource is searched * either way. A line range stays a JS-side match filter. * * Returns `null` for entries that are not external URLs, so the caller keeps * its filesystem-path handling. */ function parseExternalUrlSpec(entry: string): { clean: string; ranges?: [LineRange, ...LineRange[]] } | null { const split = splitPathAndSel(entry); if (split.sel === undefined) return null; // A plain line range needs no special handling — the caller filters matches // by line number, exactly like it does for a filesystem path. if (parseLineRanges(split.sel) !== null) return null; if (!isReadableUrlPath(split.path)) return null; // `:conflicts` is display-only: drop it, then read whatever selectors precede it // (`…:10-20:conflicts` keeps its range, `…:-5:conflicts` is still rejected). const conflicts = split.sel.toLowerCase() === "conflicts"; const target = parseReadUrlTarget(conflicts ? split.path : entry); if (!target) return conflicts ? { clean: split.path } : null; if (target.sel.kind === "tail") { throw new ToolError( `path entry "${entry}" has an invalid selector ":-${target.sel.count}" — use ":N-M" line ranges, ":raw"/":conflicts", a range plus ":raw", or percent-encode a literal ":" as %3A`, ); } // No URL selector left: only `:conflicts` names the whole resource; anything else (`:img`) is not URL grammar. if (target.sel.kind === "none") return conflicts ? { clean: target.path } : null; if (target.sel.kind === "raw") { // Forward the selector verbatim — the fetch path reads it back. return { clean: `${target.path}:raw` }; } if (target.sel.kind === "lines") { return { clean: target.sel.raw ? `${target.path}:raw` : target.path, ranges: target.sel.ranges, }; } // A selector the URL grammar does not know (`:img`, a typo) — leave it to // the filesystem path handling, which rejects it with a clear message. return null; } async function parsePathSpecs(rawEntries: readonly string[], cwd: string): Promise { const specs: GrepPathSpec[] = []; const router = InternalUrlRouter.instance(); for (const entry of rawEntries) { // Internal URLs (single-slash aliases included) use the router's splitter, // which peels selector-shaped tails only for schemes declaring line // selectors and leaves opaque server-defined URIs intact. Unlike filesystem paths, their // verbatim/index display modes (`raw`, `conflicts`) carry no meaning for // content search, so we accept them — searching the whole resource — and // still honor any embedded line range as a match filter. const internalSplit = router.split(entry); if (internalSplit.sel !== undefined) { // Reject selectors read's parseSel would reject (`:1-1:1-2`, `:conflicts:1-1`) // plus read-only tails (`:-10`) instead of silently widening the search or // dropping a chunk. if (!isReadSelectorGrammar(internalSplit.sel)) { throw new ToolError( `path entry "${entry}" has an invalid selector ":${internalSplit.sel}" — use ":N-M" line ranges, ":raw"/":conflicts", a range plus ":raw", or percent-encode a literal ":" as %3A`, ); } const ranges = selectorLineRanges(internalSplit.sel); if (ranges && router.isGlob(internalSplit.path)) { throw new ToolError(`Line-range selector requires a single file, not a glob: ${entry}`); } specs.push({ original: entry, clean: internalSplit.path, ranges }); continue; } // Prefer a literal filesystem match when one exists — a real file named // `test:1-2` (or `www.example.com:raw`) outranks the selector // interpretation (issue #4618). const strictSplit = splitPathAndSel(entry); const split = await splitPathAndSelPreferringLiteral(entry, cwd); const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined; // External URLs are fetched, not opened from disk, so their display // selectors follow the read tool's URL grammar (`:raw` reaches the // fetch) instead of the filesystem line-range grammar. const externalUrlSpec = literalFilesystemMatch ? null : parseExternalUrlSpec(entry); if (externalUrlSpec) { specs.push({ original: entry, clean: externalUrlSpec.clean, externalUrl: true, ranges: externalUrlSpec.ranges, }); continue; } let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry; let ranges: [LineRange, ...LineRange[]] | undefined; if (!literalFilesystemMatch && split.sel) { const parsed = parseLineRanges(split.sel); if (!parsed) { throw new ToolError( `path entry "${entry}" — only line-range selectors like ":50-100" are supported (no ":raw"/":conflicts")`, ); } if (hasGlobPathChars(split.path) && (await probeLiteralPathExists(split.path, cwd)) === "missing") { throw new ToolError(`Line-range selector requires a single file, not a glob: ${entry}`); } clean = split.path; ranges = parsed; } specs.push({ original: entry, clean, literalFilesystemMatch, ranges, }); } return specs; } function mergeRangesInto(map: Map, absKey: string, ranges: readonly LineRange[]): void { // Concat-without-merge is correct: `isLineInRanges` scans linearly, so // duplicates/overlaps only cost a few extra comparisons per match. const existing = map.get(absKey); if (existing) { existing.push(...ranges); } else { map.set(absKey, [...ranges]); } } /** * Pre-resolve any `paths` entries that point at a member inside an archive * (e.g. `bundle.zip:src/foo.ts`, `release.tar.gz:notes.md`). Native grep * cannot read archive members, so we materialize each text member to a * temp scratch file and substitute that path into the search inputs. After * grep returns, callers remap `match.path` back to the original * `archive:member` selector so it round-trips through the `read` tool. * * Returns the rewritten paths array (same length/order as input), a map * from absolute scratch path → original selector, a list of entries we * could not materialize (binary member, missing archive, etc.), and a * cleanup hook the caller MUST invoke in a `finally`. */ async function resolveArchiveSearchPaths( pathSpecs: readonly GrepPathSpec[], cwd: string, ): Promise<{ resolvedPaths: string[]; displayMap: Map; displaySet: Set; unreadable: string[]; cleanup: () => Promise; }> { const resolvedPaths = pathSpecs.map(spec => spec.clean); const displayMap = new Map(); const displaySet = new Set(); const unreadable: string[] = []; let tempDir: string | undefined; const archiveCache = new Map(); for (let idx = 0; idx < pathSpecs.length; idx++) { const spec = pathSpecs[idx]; if (!spec || spec.literalFilesystemMatch || spec.externalUrl) continue; const entry = spec.clean; const candidates = parseArchivePathCandidates(entry); const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry); if (!member) continue; const archiveAbs = resolveReadPath(member.archivePath, cwd); let archive = archiveCache.get(archiveAbs); if (!archive) { try { archive = await openArchive(archiveAbs); } catch (err) { unreadable.push(`${entry} (cannot open archive: ${(err as Error).message})`); continue; } archiveCache.set(archiveAbs, archive); } let extracted: ExtractedArchiveFile; try { extracted = await archive.readFile(member.subPath); } catch (err) { unreadable.push(`${entry} (${(err as Error).message})`); continue; } // UTF-8 only — binary members would just produce noise through ripgrep. if (extracted.bytes.some(byte => byte === 0)) { unreadable.push(`${entry} (binary archive entry)`); continue; } let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(extracted.bytes); } catch { unreadable.push(`${entry} (non-UTF-8 archive entry)`); continue; } if (!tempDir) { tempDir = await mkdtemp(path.join(tmpdir(), "omp-search-archive-")); } // Per-entry filename keeps the scratch path unique even when two selectors // resolve to members with the same basename. const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry"; const tempPath = path.join(tempDir, `${idx}-${safeBase}`); await writeFile(tempPath, text); resolvedPaths[idx] = tempPath; displayMap.set(tempPath, entry); displaySet.add(entry); } const cleanup = async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } }; return { resolvedPaths, displayMap, displaySet, unreadable, cleanup }; } function isImmutableSourcePath(filePath: string, immutableSourcePaths: ReadonlySet): boolean { for (const immutablePath of immutableSourcePaths) { if (filePath === immutablePath || filePath.startsWith(`${immutablePath}${path.sep}`)) { return true; } } return false; } /** Grouped-output header naming an internal URL (`## local://notes/a.md (2 matches)`). */ const URL_HEADER_RE = /^#+\s+([a-z][a-z0-9+.-]*:\/\/.*)$/i; const HEADER_SUFFIX_RE = /\s+\([^)]*\)\s*$/; /** * Stat identity a recorded snapshot tag was minted under. `ctime` moves on * every content write (it cannot be set by `utimes`), so an unchanged stamp * plus an unchanged store head means re-recording would read identical bytes. */ interface SnapshotStamp { readonly size: bigint; readonly mtimeNs: bigint; readonly ctimeNs: bigint; readonly ino: bigint; readonly tag: string; } const SNAPSHOT_STAMP_MAX = 256; const snapshotStampsByStore = new WeakMap>(); /** * Whole-file snapshot tags for `snapshotPaths`, in order (null = unreadable or * over the snapshot cap). Files whose stat identity is unchanged since this * store last recorded them, and whose recorded head still carries that tag, * skip the native re-read: recording identical text again would only refresh * recency, which `headHash` does too. Stats run concurrently and BEFORE the * read, so a write racing the read invalidates the stamp instead of pinning a * stale tag. */ async function recordSnapshotTags(store: EditStore, snapshotPaths: readonly string[]): Promise<(string | null)[]> { const stamps = snapshotStampsByStore.get(store) ?? new LRUCache({ max: SNAPSHOT_STAMP_MAX }); snapshotStampsByStore.set(store, stamps); const stats = await Promise.all(snapshotPaths.map(filePath => stat(filePath, { bigint: true }).catch(() => null))); return snapshotPaths.map((filePath, index) => { const st = stats[index]; if (!st) { stamps.delete(filePath); return store.recordSnapshotFile(filePath); } const prior = stamps.get(filePath); if ( prior && prior.size === st.size && prior.mtimeNs === st.mtimeNs && prior.ctimeNs === st.ctimeNs && prior.ino === st.ino && store.headHash(filePath) === prior.tag ) { return prior.tag; } const tag = store.recordSnapshotFile(filePath); const changedNs = st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs; if (tag && BigInt(Date.now() - RACY_STAMP_WINDOW_MS) * 1_000_000n >= changedNs) { stamps.set(filePath, { size: st.size, mtimeNs: st.mtimeNs, ctimeNs: st.ctimeNs, ino: st.ino, tag }); } else { stamps.delete(filePath); } return tag; }); } /** * Per-file native fetch budget that guarantees the JS range filter can still * surface `perFileKeep` in-range hits. Matches arrive one entry per matched * line in line order, so a bounded range's hits all sit within the first * `endLine` entries, and an open-ended range starting at S is preceded by at * most S-1 out-of-range entries — S-1+perFileKeep entries cover the kept * window or exhaust the file. Clamped to the native file-size ceiling, which * keeps the scaled global budget inside the native layer's u32 bounds; a * range past that many matched lines is not fetched. */ function lineRangeFetchCap(pathSpecs: readonly GrepPathSpec[], perFileKeep: number): number { let cap = 0; for (const spec of pathSpecs) { if (!spec.ranges) continue; for (const range of spec.ranges) { cap = Math.max(cap, range.endLine ?? range.startLine - 1 + perFileKeep); } } return Math.min(cap, NATIVE_GREP_MAX_FILE_BYTES); } /** Names of the files a per-file cap trimmed: the first few, then a count. */ function formatCappedFiles(paths: readonly string[]): string { const shown = paths.slice(0, 5).join(", "); const rest = paths.length - 5; return rest > 0 ? `${shown} and ${rest} more ${rest === 1 ? "file" : "files"}` : shown; } /** * Whether a path entry is a `!glob` exclusion rather than a path to search: it * starts with `!` and is confirmed missing on disk once a `:selector` is split * off the way `read` splits it, so `!notes.txt:1-2` still reads a real * `!notes.txt`, and a dangling `!link` or an inconclusive probe stays a path * whose error the search reports. Approval and execution both classify entries * through this. */ function isExclusionEntry(entry: string, cwd: string): boolean { if (!entry.startsWith("!")) return false; return probeLiteralPathExistsSync(splitPathAndSelPreferringLiteralSync(entry, cwd).path, cwd) === "missing"; } /** One exclusion with Windows separators and trailing separators normalized away. */ function normalizeExclusion(pattern: string): string { return pattern.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } /** * Where an anchored exclusion points: one with a `/` that does not start with * `**`, resolved against cwd like any tool path. `undefined` for a pattern that * matches at any depth, `null` for one that resolves to no filesystem path. */ function anchoredExclusionPath(pattern: string, cwd: string): string | null | undefined { if (!pattern.includes("/") || pattern.startsWith("**")) return undefined; try { return resolveToCwd(pattern, cwd); } catch { return null; } } /** `target` relative to `root` with `/` separators, or `undefined` unless it lies strictly inside `root`. */ function relativeInside(root: string, target: string): string | undefined { const relative = path.relative(root, target); // Across Windows drives `path.relative` returns an absolute path. if (relative === "" || path.isAbsolute(relative)) return undefined; const posix = relative.split(path.sep).join("/"); return posix === ".." || posix.startsWith("../") ? undefined : posix; } /** * Rebase `!glob` exclusions, written relative to cwd, onto one search root for * native grep, which matches them against root-relative paths. A pattern with * no `/` (or starting with `**`) matches at any depth and passes through; any * other is anchored to the root with a leading `/` (`!./gen` must not drop * `sub/gen`); one naming a place outside this root has nothing to exclude there. */ function exclusionsForRoot(patterns: readonly string[], cwd: string, root: string): string[] | undefined { const rebased: string[] = []; const internalRoot = InternalUrlRouter.instance().canHandle(root); for (const raw of patterns) { const pattern = normalizeExclusion(raw); if (!pattern) continue; const target = anchoredExclusionPath(pattern, cwd); if (target === undefined) { rebased.push(pattern); continue; } if (internalRoot || target === null) continue; const relative = relativeInside(root, target); if (relative !== undefined) rebased.push(`/${relative}`); } return rebased.length > 0 ? rebased : undefined; } type SearchParams = typeof searchSchema.infer; /** * Construction-time overrides for callers that are not the model. * * The model-facing schema deliberately does not grow these: Cursor's native * grep frames carry context widths and match caps that the shared tool cannot * accept per call. Unset means "use session settings / built-in caps" for * ordinary model-issued calls. */ export interface GrepToolOptions { /** Overrides both context widths unless the corresponding direction is also supplied. */ context?: number; /** Overrides the number of lines before each match. */ contextBefore?: number; /** Overrides the number of lines after each match. */ contextAfter?: number; /** Caps total surfaced matches. Applied on top of the built-in per-file and file-window caps, never above them. */ totalMatchLimit?: number; } export class GrepTool implements AgentTool { readonly name = "grep"; readonly approval = searchPathTier; /** * `always-ask-cached` keys: every file and folder this grep searches, so a * folder grant recorded by a `read` covers a search of it and a search of one * file shares that file's read grant. Roots are split the way `execute` splits * them (`splitPathAndSel` peels a line-range selector, `parseSearchPath` finds * the walkable base of a glob) and each costs a single stat; see * {@link searchApprovalCacheTargets}. */ readonly approvalCacheTargets = (args: unknown): readonly string[] | undefined => { const a = args as { path?: string | string[]; paths?: string | string[] } | null | undefined; const cwd = this.session.cwd; const splitBase = (entry: string) => parseSearchPath(splitPathAndSel(entry).path).basePath; const roots = toPathList(a?.path ?? a?.paths); const exclusionParts = (list: string) => approvalRootCandidates(list) .slice(1) .filter(part => isExclusionEntry(part, cwd)); // `!glob` exclusions search nothing and key nothing. A whole list holding one keys itself only when // it is a real path (execution then searches it literally); its other parts key as usual. const skipCandidate = (candidate: string, wholeList: boolean) => wholeList ? exclusionParts(candidate).length > 0 && probeLiteralPathExistsSync(splitPathAndSelPreferringLiteralSync(candidate, cwd).path, cwd) === "missing" : isExclusionEntry(candidate, cwd); // Execution searches cwd when every entry it splits out is an exclusion, and how it splits a list // depends on its own parser (braces, empty fields, which parts resolve). Rather than re-derive that, // any exclusion in a list keys cwd too: over-keying only adds a prompt, under-keying auto-allows a // recursive cwd read. const hasExclusion = roots.some(root => { const list = normalizePathLikeInput(root); return ( isExclusionEntry(list, cwd) || list.split(APPROVAL_ROOT_DELIMITERS).some(part => part.length > 0 && isExclusionEntry(part, cwd)) ); }); return searchApprovalCacheTargets( hasExclusion ? [...roots, "."] : roots, cwd, splitBase, undefined, skipCandidate, ); }; readonly label = "Grep"; readonly loadMode = "discoverable"; readonly summary = "Search file contents by regex"; get description(): string { const displayMode = resolveFileDisplayMode(this.session); const isHlMode = displayMode.hashLines; const isLineNumberMode = !displayMode.hashLines && displayMode.lineNumbers; const hasFind = this.session.isToolActive?.("find") ?? isFindEnabled(this.session); const eagerDelegation = sessionDelegationBias(this.session) === "eager"; const scoutAvailable = isScoutSpawnable( cfgTaskDisabledAgents.get(this.session.settings), this.session.getSessionSpawns?.() ?? "*", ); // Every render input is a boolean; pack them so repeat reads skip the template render. const key = (isHlMode ? 1 : 0) | (isLineNumberMode ? 2 : 0) | (hasFind ? 4 : 0) | (eagerDelegation ? 8 : 0) | (scoutAvailable ? 16 : 0); if (key !== this.#descriptionKey) { this.#description = prompt.render(grepDescription, { IS_HL_MODE: isHlMode, IS_LINE_NUMBER_MODE: isLineNumberMode, hasFind, eagerDelegation, scoutAvailable, }); this.#descriptionKey = key; } return this.#description; } readonly parameters = searchSchema; readonly strict = true; readonly #contextBeforeOverride?: number; readonly #contextAfterOverride?: number; readonly #totalMatchLimit?: number; #descriptionKey = -1; #description = ""; constructor( private readonly session: ToolSession, options?: GrepToolOptions, ) { const before = options?.contextBefore ?? options?.context; const after = options?.contextAfter ?? options?.context; this.#contextBeforeOverride = before !== undefined ? Math.max(0, Math.floor(before)) : undefined; this.#contextAfterOverride = after !== undefined ? Math.max(0, Math.floor(after)) : undefined; const total = options?.totalMatchLimit; this.#totalMatchLimit = total !== undefined ? Math.max(1, Math.floor(total)) : undefined; } async execute( _toolCallId: string, params: SearchParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _toolContext?: AgentToolContext, ): Promise> { const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params; return untilAborted(signal, async () => { // Preserve the pattern verbatim — leading/trailing whitespace is // meaningful in regexes (indentation anchors, trailing-space matches). if (!pattern.trim()) { throw new ToolError("Pattern must not be empty"); } const normalizedPattern = pattern; const normalizedSkip = skip === undefined || skip === null ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN; if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) { throw new ToolError("Skip must be a non-negative number"); } const scopedPaths = toPathList(rawPath); const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."]; const expandedEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd, { exclusionParts: true, }); // `!glob` entries exclude; the rest are what to search (all of cwd when only exclusions were given). // A file or folder whose name really starts with `!` is still searched. const exclusions: string[] = []; const searchEntries: string[] = []; for (const entry of expandedEntries) { if (isExclusionEntry(entry, this.session.cwd)) exclusions.push(entry.slice(1)); else searchEntries.push(entry); } const rawEntries = searchEntries.length > 0 ? searchEntries : ["."]; const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd); const resolveContext = sessionResolveContext(this.session, { signal }); // Internal URLs resolve inside the native search, bounded by the tier this call was approved at. const urlFilesystem = new InternalUrlFilesystem({ context: resolveContext, tier: resolveToolTier(this, params), }); const filesystem = urlFilesystem.shellFilesystem(); const materializedExternalPaths = new Map(); const materializeExternalUrlForSearch = async (rawPath: string) => { const target = parseReadUrlTarget(rawPath); if (!target) return undefined; const materialized = await materializeReadUrlToFile( this.session, { path: target.path, raw: isRawSelector(target.sel) }, signal, ); materializedExternalPaths.set(rawPath, materialized.path); return { sourcePath: materialized.path, immutable: true }; }; const { resolvedPaths, displayMap: archiveDisplayMap, displaySet: archiveDisplaySet, unreadable: archiveUnreadable, cleanup: cleanupArchiveScratch, } = await resolveArchiveSearchPaths(pathSpecs, this.session.cwd); try { const rangesByAbsPath = new Map(); if (archiveUnreadable.length > 0 && resolvedPaths.length === archiveUnreadable.length) { // All inputs were archive selectors we couldn't materialize; surface the // reason instead of a downstream "path not found" from the scope resolver. throw new ToolError( `Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` + `Read the member with \`read :\` and inspect the returned text, ` + `or pass a UTF-8 text member.`, ); } const normalizedContextBefore = this.#contextBeforeOverride ?? cfgGrepContextBefore.get(this.session.settings); const normalizedContextAfter = this.#contextAfterOverride ?? cfgGrepContextAfter.get(this.session.settings); const ignoreCase = !(caseSensitive ?? true); const useGitignore = gitignore ?? true; const ovnfs = ovnfsCommand(this.session.settings); const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n"); const effectiveMultiline = patternHasNewline; const scope = await resolveToolSearchScope({ rawPaths: resolvedPaths, cwd: this.session.cwd, internalUrlAction: "search", filesystem: urlFilesystem, resolveExternalUrl: materializeExternalUrlForSearch, trackImmutableSources: true, surfaceExactFilePaths: true, fanOutFileTargets: true, multipathStatHint: " (`path` list entries must each exist relative to cwd)", }); const { searchPath, isDirectory, multiTargets, exactFilePaths, missingPaths, globFilter } = scope; const immutableSourcePaths = scope.immutableSourcePaths; // Build the per-file line-range filter after URL materialization has run: // archive entries are keyed by scratch path, external URL entries by read-cache // content path, internal URLs by their URL, and ordinary files by their resolved path. const router = InternalUrlRouter.instance(); for (let idx = 0; idx < pathSpecs.length; idx++) { const spec = pathSpecs[idx]; if (!spec.ranges) continue; const resolved = resolvedPaths[idx]; if (!resolved) continue; const materializedExternalPath = materializedExternalPaths.get(spec.clean); if (materializedExternalPath) { mergeRangesInto(rangesByAbsPath, path.resolve(materializedExternalPath), spec.ranges); continue; } if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) { // Non-archive entry; ensure the cleaned path resolves to a regular file. const absKey = router.canHandle(resolved) ? resolved : path.resolve(resolveReadPath(resolved, this.session.cwd)); const stats = await urlFilesystem.stat(absKey).catch(() => null); if (!stats) { throw new ToolError(`Path not found for line-range selector: ${spec.original}`); } if (stats.type !== "file") { throw new ToolError(`Line-range selector requires a single file: ${spec.original} is a directory`); } mergeRangesInto(rangesByAbsPath, absKey, spec.ranges); } else { mergeRangesInto(rangesByAbsPath, path.resolve(resolved), spec.ranges); } } // When the only input was an archive selector, surface that selector instead // of the temp scratch path the resolver substituted in. const scopePath = resolvedPaths.length === 1 && archiveDisplayMap.get(searchPath) ? (archiveDisplayMap.get(searchPath) as string) : scope.scopePath; if (missingPaths.length > 0 && missingPaths.length === resolvedPaths.length) { const archiveHint = archiveUnreadable.length > 0 ? ` (archive members were not searchable: ${archiveUnreadable.join(", ")})` : ""; throw new ToolError( `Path not found: ${missingPaths.join(", ")}; list each target in the semicolon-delimited \`path\`${archiveHint}`, ); } const baseDisplayMode = resolveFileDisplayMode(this.session); const effectiveOutputMode = GrepOutputMode.Content; const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets); const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES; // Range filtering happens in JS after the native fetch, so out-of-range // matches consume fetch budget. Widen the per-file budget just enough // that filtering can still yield `perFileMatchCap` in-range hits, and // scale the global safety ceiling by the same amplification so ranged // searches keep the baseline file coverage while staying finite. const hasLineRangeFilters = pathSpecs.some(spec => spec.ranges); const nativeMaxCountPerFile = hasLineRangeFilters ? Math.max(perFileMatchCap + 1, lineRangeFetchCap(pathSpecs, perFileMatchCap + 1)) : perFileMatchCap + 1; const nativeMaxCount = hasLineRangeFilters ? Math.ceil(INTERNAL_TOTAL_CAP / (perFileMatchCap + 1)) * nativeMaxCountPerFile : INTERNAL_TOTAL_CAP; // Run grep let result: GrepResult = { matches: [], totalMatches: 0, filesWithMatches: 0, filesSearched: 0, limitReached: false, }; let skippedOversizedCount = 0; // Native sets `limitReached` for the per-file cap too; only the total cap // stops the walk before every file has been surfaced. let totalCapReached = false; // Only a glob spelled without a directory prefix matches at any depth. // The parsed base alone cannot distinguish `*.ts` from `./*.ts`. try { if (exactFilePaths || multiTargets) { const matches: GrepMatch[] = []; const seenMatchKeys = new Set(); let limitReached = false; let totalMatches = 0; let filesSearched = 0; const targets = exactFilePaths ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined, bareGlob: false, })) : (multiTargets ?? []); for (const target of targets) { const targetResult = await grep( { pattern: normalizedPattern, path: target.basePath, glob: target.glob, recursive: target.bareGlob === true, ignoreCase, multiline: effectiveMultiline, hidden: true, gitignore: useGitignore, maxCount: nativeMaxCount, contextBefore: normalizedContextBefore, contextAfter: normalizedContextAfter, maxColumns: DEFAULT_MAX_COLUMN, mode: effectiveOutputMode, maxCountPerFile: nativeMaxCountPerFile, exclude: exclusionsForRoot(exclusions, this.session.cwd, target.basePath), signal, timeoutMs: SEARCH_GREP_TIMEOUT_MS, filesystem, }, undefined, ovnfs, ); skippedOversizedCount += targetResult.skippedOversized ?? 0; limitReached = limitReached || Boolean(targetResult.limitReached); totalCapReached ||= targetResult.matches.length >= nativeMaxCount; totalMatches += targetResult.totalMatches; filesSearched += targetResult.filesSearched; for (const match of targetResult.matches) { const absolute = resolveSearchResultPath(target.basePath, match.path); // Overlapping targets (a directory plus a file nested // inside it) surface the same physical line twice; // keep the first occurrence. const matchKey = `${absolute}\0${match.lineNumber}`; if (seenMatchKeys.has(matchKey)) { totalMatches = Math.max(0, totalMatches - 1); continue; } seenMatchKeys.add(matchKey); matches.push({ ...match, path: relativeSearchResultPath(searchPath, absolute) }); } } result = { matches, totalMatches: exactFilePaths ? matches.length : totalMatches, filesWithMatches: new Set(matches.map(match => match.path)).size, filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched, limitReached, }; } else { result = await grep( { pattern: normalizedPattern, path: searchPath, glob: globFilter, recursive: scope.bareGlob, ignoreCase, multiline: effectiveMultiline, hidden: true, gitignore: useGitignore, maxCount: nativeMaxCount, contextBefore: normalizedContextBefore, contextAfter: normalizedContextAfter, maxColumns: DEFAULT_MAX_COLUMN, mode: effectiveOutputMode, maxCountPerFile: nativeMaxCountPerFile, exclude: exclusionsForRoot(exclusions, this.session.cwd, searchPath), signal, timeoutMs: SEARCH_GREP_TIMEOUT_MS, filesystem, }, undefined, ovnfs, ); skippedOversizedCount = result.skippedOversized ?? 0; totalCapReached = result.matches.length >= nativeMaxCount; } } catch (err) { if (err instanceof Error && /^regex(?: parse)? error/i.test(err.message)) { throw new ToolError(err.message.replace(/^regex(?: parse)? error:?\s*/i, "Invalid regex: ")); } if (err instanceof Error && err.message.includes("Aborted: Timeout")) { throw new ToolError( `Grep timed out after ${SEARCH_GREP_TIMEOUT_MS / 1000}s; narrow paths or pattern, or scope with \`glob\` first`, ); } throw err; } if (rangesByAbsPath.size > 0) { const filteredMatches: GrepMatch[] = []; for (const match of result.matches) { const abs = resolveSearchResultPath(searchPath, match.path); // Native absolute matches can retain forward slashes on Windows; range keys use path.resolve. const ranges = rangesByAbsPath.get(path.isAbsolute(match.path) ? path.resolve(abs) : abs); if (!ranges) { // Path has no line-range constraint (e.g. a peer entry without `:N-M`). filteredMatches.push(match); continue; } if (!isLineInRanges(match.lineNumber, ranges)) continue; // Drop context lines that fall outside the allowed ranges; they would // otherwise leak content the caller explicitly excluded. const trimBefore = match.contextBefore?.filter(c => isLineInRanges(c.lineNumber, ranges)); const trimAfter = match.contextAfter?.filter(c => isLineInRanges(c.lineNumber, ranges)); filteredMatches.push({ ...match, contextBefore: trimBefore && trimBefore.length > 0 ? trimBefore : undefined, contextAfter: trimAfter && trimAfter.length > 0 ? trimAfter : undefined, }); } result = { matches: filteredMatches, totalMatches: filteredMatches.length, filesWithMatches: new Set(filteredMatches.map(match => match.path)).size, filesSearched: result.filesSearched, limitReached: result.limitReached, }; } if (archiveDisplayMap.size > 0) { for (const match of result.matches) { const display = archiveDisplayMap.get(resolveSearchResultPath(searchPath, match.path)); if (display) match.path = display; } } const formatPath = (filePath: string): string => archiveDisplaySet.has(filePath) ? filePath : formatResultPath(filePath, isDirectory, searchPath, this.session.cwd); // Single-file scopes can't paginate — there is one file by definition. const canPaginate = isMultiScope; // A caller with a total match cap is not paginating: the cap bounds the // output, and the only consumer that sets one (`pi_grep`) has no `skip` // field to follow a "use skip=N" suggestion with. Windowing it to the // first 20 files would silently return fewer matches than it asked for // while reporting the cap as unreached. // // The window is cap+1 files, not cap: with one match per file, a cap // of N over exactly N files is complete, while over N+1 files it is // clipped — and only reading that extra file distinguishes the two. // The cap below then does the trimming and records that it bit, so // `match_limit_reached` reaches the frame set. const fileWindow = this.#totalMatchLimit !== undefined ? this.#totalMatchLimit + 1 : DEFAULT_FILE_LIMIT; const windowStart = canPaginate ? normalizedSkip : 0; const windowEnd = canPaginate ? normalizedSkip + fileWindow : Number.POSITIVE_INFINITY; // Group matches by file in encounter order. Every file is counted so // per-file overflow is detected BEFORE truncation (the renderer surfaces // that a hot file was trimmed for diversity), but match lists are only // kept — already capped — for files inside the page window. const fileGroups = new Map(); const windowLists: GrepMatch[][] = []; let perFileLimitReached = false; // Files in the page window whose matches past the cap were dropped. const cappedFiles: string[] = []; for (const match of result.matches) { const group = fileGroups.get(match.path); if (!group) { const order = fileGroups.size; const list = order >= windowStart && order < windowEnd ? [match] : undefined; if (list) windowLists.push(list); fileGroups.set(match.path, { count: 1, list }); continue; } group.count++; if (group.count > perFileMatchCap) { perFileLimitReached = true; if (group.count === perFileMatchCap + 1 && group.list) cappedFiles.push(match.path); continue; } group.list?.push(match); } const totalFiles = fileGroups.size; // When native grep stopped at its total cap, files past the cap were // never surfaced — the file total is only a lower bound. const totalFilesLabel = totalCapReached ? `${totalFiles}+` : `${totalFiles}`; const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0; const fileLimitReached = canPaginate && totalFiles > skipFiles + fileWindow; const selectedMatches: GrepMatch[] = []; let totalMatchLimitReached = false; // Round-robin interleaves files for diversity, so the cap is applied // after selection rather than as a per-list bound: trimming // mid-rotation would silently favour whichever files sort first. // Selection stops one past the cap — enough to know the cap bit. const cap = this.#totalMatchLimit; const selectLimit = cap === undefined ? Number.POSITIVE_INFINITY : cap + 1; let anyAdded = windowLists.length > 0; for (let round = 0; anyAdded && selectedMatches.length < selectLimit; round++) { anyAdded = false; for (const list of windowLists) { if (round >= list.length) continue; selectedMatches.push(list[round]); anyAdded = true; if (selectedMatches.length >= selectLimit) break; } } if (cap !== undefined && selectedMatches.length > cap) { selectedMatches.length = cap; totalMatchLimitReached = true; } const nextSkip = skipFiles + windowLists.length; const limitMessage = fileLimitReached ? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFilesLabel}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.` : ""; const { record: recordFile, list: fileList } = createFileRecorder(); const fileMatchCounts = new Map(); const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024)); // Native streams an explicitly named file whole, except in multiline // mode, which holds the file in memory and so reads only its first // NATIVE_GREP_MAX_FILE_BYTES; without this note the caller might miss // that matches beyond the window (or "no matches") reflect partial // coverage, not the whole file. const oversizedNote = await (async (): Promise => { if (!effectiveMultiline) return undefined; const explicitFileTargets: string[] = []; if (exactFilePaths) { explicitFileTargets.push(...exactFilePaths); } else if (!isDirectory && !multiTargets) { explicitFileTargets.push(searchPath); } if (explicitFileTargets.length === 0) return undefined; const oversized: string[] = []; await Promise.all( explicitFileTargets.map(async target => { try { const st = await urlFilesystem.stat(target); if (st.type === "file" && st.size > NATIVE_GREP_MAX_FILE_BYTES) { oversized.push(formatPathRelativeToCwd(target, this.session.cwd)); } } catch { // Stat failures here are surfaced by other code paths. } }), ); if (oversized.length === 0) return undefined; return `Multiline search read only the first ${limitMb}MB of large files (matches past the ${limitMb}MB window are not shown; search without a newline in the pattern, or use \`read\`, for the rest): ${oversized.join(", ")}`; })(); // Directory/multi-target scopes: native counts files it could not map // even a prefix of (rare mmap failures), but cannot name them. const oversizedScanNote = !oversizedNote && skippedOversizedCount > 0 ? `Skipped ${skippedOversizedCount} unreadable large file(s); target them directly with \`read\`` : undefined; const archiveNote = archiveUnreadable.length > 0 ? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}` : undefined; // Suppress entries we already explained via archiveNote — they would otherwise // double up (the unreadable selector also failed the scope's existence check). const archiveUnreadablePaths = new Set(archiveUnreadable.map(s => s.replace(/ \(.*\)$/, ""))); const missingPathsForNote = missingPaths.filter(p => !archiveUnreadablePaths.has(p)); const missingPathsNote = missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined; const warningNote = [missingPathsNote, archiveNote, oversizedNote, oversizedScanNote] .filter((s): s is string => Boolean(s)) .join("\n") || undefined; if (selectedMatches.length === 0) { const details: GrepToolDetails = { scopePath, searchPath, cwd: this.session.cwd, matchCount: 0, fileCount: 0, files: [], truncated: false, missingPaths: missingPaths.length > 0 ? missingPaths : undefined, }; const skipPastEnd = canPaginate && normalizedSkip > 0 && totalFiles > 0 && skipFiles >= totalFiles; const noMatchText = skipPastEnd ? `No more results (${totalFilesLabel} files total; skip=${normalizedSkip} is past the end)` : oversizedNote ? `No matches in searched content (multiline search limited large files to their first ${limitMb}MB)` : "No matches found"; const text = warningNote ? `${noMatchText}\n${warningNote}` : noMatchText; // Zero matches is useless regardless of warnings: by the time // compaction runs, the follow-up call has already corrected course. return toolResult(details).text(text).useless().done(); } const outputLines: string[] = []; let linesTruncated = false; const matchesByFile = new Map(); for (const match of selectedMatches) { const relativePath = formatPath(match.path); recordFile(relativePath); if (!matchesByFile.has(relativePath)) { matchesByFile.set(relativePath, []); } matchesByFile.get(relativePath)!.push(match); } const displayLines: string[] = []; const hashContexts = new Map(); if (baseDisplayMode.hashLines) { const candidates = fileList.filter(relativePath => !archiveDisplaySet.has(relativePath)); // Immutable schemes get no host file; mutable URLs (`local://`) bind to their backing file. const snapshotPaths = await Promise.all( candidates.map(relativePath => resultSnapshotPath(relativePath, this.session.cwd, resolveContext)), ); const editable: Array<{ relativePath: string; snapshotPath: string }> = []; for (let index = 0; index < candidates.length; index++) { const snapshotPath = snapshotPaths[index]; if (snapshotPath === undefined || isImmutableSourcePath(snapshotPath, immutableSourcePaths)) continue; editable.push({ relativePath: candidates[index], snapshotPath }); } // Mint a whole-file content tag so any anchor validates while the // file is unchanged; over-cap / unreadable files get no tag (and // therefore plain, non-editable line output). const tags = await recordSnapshotTags( getEditStore(this.session), editable.map(entry => entry.snapshotPath), ); for (let index = 0; index < editable.length; index++) { const tag = tags[index]; if (tag) hashContexts.set(editable[index].relativePath, { tag, path: editable[index].snapshotPath }); } } const renderMatchesForFile = (relativePath: string): FileMatchSection => { const modelOut: string[] = []; const displayOut: string[] = []; let cutLines: Set | undefined; const fileMatches = matchesByFile.get(relativePath) ?? []; const hashContext = hashContexts.get(relativePath); const useHashLines = hashContext !== undefined; const lineNumberWidth = fileMatches.reduce((width, match) => { let nextWidth = Math.max(width, String(match.lineNumber).length); for (const ctx of match.contextBefore ?? []) { nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length); } for (const ctx of match.contextAfter ?? []) { nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length); } return nextWidth; }, 0); let lastEmittedLine: number | undefined; const gutterPad = " ".repeat(lineNumberWidth + 1); for (const match of fileMatches) { const pushLine = (lineNumber: number, line: string, isMatch: boolean) => { if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) { modelOut.push("..."); displayOut.push(`${gutterPad}│...`); } modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines })); if (!isMatch && line.endsWith("...") && Buffer.byteLength(line) >= DEFAULT_MAX_COLUMN - 6) { (cutLines ??= new Set()).add(lineNumber); } displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth)); lastEmittedLine = lineNumber; }; if (match.contextBefore) { for (const ctx of match.contextBefore) { pushLine(ctx.lineNumber, ctx.line, false); } } // A cut line shows a window around its first match; name where that match sits. const matchLine = match.column === undefined ? match.line : `${match.line} [col ${match.column}]`; if (match.truncated) (cutLines ??= new Set()).add(match.lineNumber); pushLine(match.lineNumber, matchLine, true); if (match.truncated) linesTruncated = true; if (match.contextAfter) { for (const ctx of match.contextAfter) { pushLine(ctx.lineNumber, ctx.line, false); } } fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1); } if (hashContext?.tag) { recordFullySeenRows( getEditStore(this.session), hashContext.path, hashContext.tag, modelOut.join("\n"), cutLines, ); } return { model: modelOut, display: displayOut, tag: hashContext?.tag }; }; const matchOutput = formatFileMatches(fileList, isDirectory || isMultiScope, renderMatchesForFile); outputLines.push(...matchOutput.model); displayLines.push(...matchOutput.display); const trailer = (shownCapped: readonly string[]): string => { const capNotice = shownCapped.length === 0 ? "" : isMultiScope ? `More than ${perFileMatchCap} matches in ${formatCappedFiles(shownCapped)}; grep keeps the first ${perFileMatchCap} per file when searching several. Search one file alone for up to ${SINGLE_FILE_MATCHES}.` : `More than ${perFileMatchCap} matches; grep keeps the first ${perFileMatchCap} in a file. Add a line range (\`path:N-M\`) or narrow the pattern for the rest.`; return [limitMessage, warningNote, capNotice] .filter(Boolean) .map(note => `\n\n${note}`) .join(""); }; // The page, warning and cap notices sit past the byte cap so a long result // cannot cut them off. A multi-file notice names only files the kept text // shows with a full cap's worth of matches: a smaller total limit (`pi_grep`) // can leave a capped file short, and the byte cap can drop its section. A // single file's notice names no file, so it stays when the byte cap cuts it. const rawOutput = outputLines.join("\n"); const cappedCandidates = cappedFiles .map(formatPath) .filter(relativePath => fileMatchCounts.get(relativePath) === perFileMatchCap); let suffix = trailer(cappedCandidates); let truncation: TruncationResult; for (;;) { const reserved = Buffer.byteLength(suffix); truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER, maxBytes: DEFAULT_MAX_BYTES - reserved, }); const keptLines = truncation.truncated ? (truncation.outputLines ?? 0) : outputLines.length; const shownCapped = isMultiScope ? cappedCandidates.filter( relativePath => (matchOutput.modelEnds.get(relativePath) ?? Infinity) <= keptLines, ) : cappedCandidates; suffix = trailer(shownCapped); // Fewer files named can still mean longer names; re-cut until the notice fits its room. if (Buffer.byteLength(suffix) <= reserved) break; } const output = truncation.content + suffix; const displayText = displayLines.join("\n"); let displayTargets: Record | undefined; // Only grouped headers start with `#`; code-frame rows start with their gutter. for (const line of displayLines) { if (line.charCodeAt(0) !== 35 /* # */) continue; const header = URL_HEADER_RE.exec(line); if (!header) continue; const target = header[1]!.trimEnd().replace(HEADER_SUFFIX_RE, ""); const resolved = router.locateSync(target); if (resolved) (displayTargets ??= {})[target] = resolved; } const truncated = Boolean( fileLimitReached || perFileLimitReached || totalMatchLimitReached || result.limitReached || truncation.truncated || linesTruncated, ); const details: GrepToolDetails = { scopePath, searchPath, cwd: this.session.cwd, matchCount: selectedMatches.length, fileCount: fileList.length, files: fileList, fileMatches: fileList.map(path => ({ path, count: fileMatchCounts.get(path) ?? 0, })), truncated, fileLimitReached: fileLimitReached ? fileWindow : undefined, perFileLimitReached: totalMatchLimitReached ? this.#totalMatchLimit : perFileLimitReached ? perFileMatchCap : undefined, displayContent: displayText, displayTargets, missingPaths: missingPaths.length > 0 ? missingPaths : undefined, }; if (truncation.truncated) details.truncation = truncation; if (linesTruncated) details.linesTruncated = true; const resultBuilder = toolResult(details) .text(output) .limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined, columnUnit: "bytes" }); if (truncation.truncated) { resultBuilder.truncation(truncation, { direction: "head" }); } return resultBuilder.done(); } finally { await cleanupArchiveScratch(); } }); } }