@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
266 lines • 15.6 kB
JavaScript
import { stat as fsStat } from "node:fs/promises";
import { createInterface } from "node:readline";
import { spawn } from "child_process";
import path from "path";
import { Type } from "typebox";
import { ensureTool } from "../../utils/tools-manager.js";
import { resolveToCwd } from "./path-utils.js";
import { grepRenderers } from "./renderers/grep.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, GREP_MAX_LINE_LENGTH, truncateHead, truncateLine, } from "./truncate.js";
const grepSchema = Type.Object({
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
glob: Type.Optional(Type.String({
description: "Filter files by glob pattern, relative to path, e.g. '*.ts', 'src/**/*.ts' or '**/*.spec.ts'. A pattern without '/' matches file names at any depth",
})),
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" })),
context: Type.Optional(Type.Number({ description: "Number of lines to show before and after each match (default: 0)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
});
export const grepToolSystemPromptContribution = {
snippet: "Search file contents for patterns (respects .gitignore)",
guidelines: [],
};
const DEFAULT_LIMIT = 100;
const defaultGrepOperations = {
isDirectory: async (p) => (await fsStat(p)).isDirectory(),
};
/** Decode a ripgrep JSON text field (path or line). Non-UTF-8 data arrives base64-encoded in `bytes`. */
function decodeRgText(data) {
if (typeof data?.text === "string")
return data.text;
if (typeof data?.bytes === "string")
return Buffer.from(data.bytes, "base64").toString("utf-8");
return "";
}
export function createGrepToolDefinition(cwd, options) {
const customOps = options?.operations;
return {
name: "grep",
label: "grep",
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
promptSnippet: grepToolSystemPromptContribution.snippet,
parameters: grepSchema,
async execute(_toolCallId, { pattern, path: searchDir, glob, ignoreCase, literal, context, limit, }, signal, _onUpdate, ctx) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let settled = false;
const settle = (fn) => {
if (!settled) {
settled = true;
fn();
}
};
(async () => {
try {
const rgPath = await ensureTool("rg");
if (!rgPath) {
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
return;
}
const searchPath = resolveToCwd(searchDir || ".", ctx?.cwd || cwd);
const ops = customOps ?? defaultGrepOperations;
let isDirectory;
try {
isDirectory = await ops.isDirectory(searchPath);
}
catch {
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
return;
}
// Clamp so String() never yields exponent notation, which rg rejects.
const contextValue = context && context > 0 ? Math.min(Math.floor(context), Number.MAX_SAFE_INTEGER) : 0;
const effectiveLimit = Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT));
const formatPath = (filePath) => {
if (isDirectory) {
const relative = path.relative(searchPath, path.resolve(searchPath, filePath));
if (relative && !relative.startsWith("..")) {
return relative.replace(/\\/g, "/");
}
}
return path.basename(filePath);
};
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on
// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.
const args = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];
if (ignoreCase)
args.push("--ignore-case");
if (literal)
args.push("--fixed-strings");
// rg never matches a leading "./" ("./src/*.ts" finds nothing), so drop it; "!" negates.
if (glob)
args.push("--glob", glob.replace(/^(!?)(?:\.\/)+/, "$1"));
// rg emits the surrounding lines as `context` events, so no matched file is ever read here (#9276).
if (contextValue > 0)
args.push("--context", String(contextValue));
// rg roots --glob at its own cwd, so a directory search runs inside searchPath (#11807).
// It searches "." because rg canonicalizes its cwd: with an absolute path, a symlinked
// searchPath (macOS /tmp) would no longer lie under the glob root.
args.push("--", pattern, isDirectory ? "." : searchPath);
const child = spawn(rgPath, args, {
cwd: isDirectory ? searchPath : undefined,
stdio: ["ignore", "pipe", "pipe"],
});
const rl = createInterface({ input: child.stdout });
let stderr = "";
let matchCount = 0;
let matchLimitReached = false;
let linesTruncated = false;
let aborted = false;
let killedDueToLimit = false;
const outputLines = [];
const cleanup = () => {
rl.close();
signal?.removeEventListener("abort", onAbort);
};
const stopChild = (dueToLimit = false) => {
if (!child.killed) {
killedDueToLimit = dueToLimit;
child.kill();
}
};
const onAbort = () => {
aborted = true;
stopChild();
};
signal?.addEventListener("abort", onAbort, { once: true });
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
// Size of the full output, including lines past the byte cap that are counted but not stored.
let outputBytes = 0;
let outputLineCount = 0;
let lastFile;
// Set at the last allowed match. rg still sends that match's trailing context, kept up to
// trailingContextEnd, and the limit notice waits for proof of one more match.
let limitHit = false;
let trailingContextEnd = 0;
let done = false;
const finish = () => {
done = true;
stopChild(true);
};
const pushLine = (filePath, lineNumber, lineText, isMatch) => {
lastFile = filePath;
const relativePath = formatPath(filePath);
// Strip only the line terminator: a lone CR inside a line is content, as in the read tool.
const sanitized = lineText.replace(/\r?\n?$/, "");
// Truncate long lines so grep output stays compact.
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated)
linesTruncated = true;
const line = isMatch
? `${relativePath}:${lineNumber}: ${truncatedText}`
: `${relativePath}-${lineNumber}- ${truncatedText}`;
// Byte truncation below drops everything past the cap, so keep counting but stop storing (#9276).
// outputBytes counts a newline after every line; the joined output has one fewer.
if (outputBytes - 1 <= DEFAULT_MAX_BYTES)
outputLines.push(line);
outputBytes += Buffer.byteLength(line, "utf-8") + 1;
outputLineCount++;
};
rl.on("line", (line) => {
if (done || !line.trim())
return;
let event;
try {
event = JSON.parse(line);
}
catch {
return;
}
const isMatch = event.type === "match";
if (!isMatch && event.type !== "context")
return;
const filePath = decodeRgText(event.data?.path);
const lineNumber = event.data?.line_number;
if (!filePath || typeof lineNumber !== "number")
return;
const lineText = decodeRgText(event.data?.lines);
if (!limitHit) {
pushLine(filePath, lineNumber, lineText, isMatch);
if (!isMatch)
return;
matchCount++;
if (matchCount >= effectiveLimit) {
limitHit = true;
trailingContextEnd = lineNumber + contextValue;
}
return;
}
const inWindow = filePath === lastFile && lineNumber <= trailingContextEnd;
// A match inside the last match's trailing context is shown as context.
if (inWindow)
pushLine(filePath, lineNumber, lineText, false);
// Only another match proves that the limit cut results off.
if (isMatch)
matchLimitReached = true;
if (matchLimitReached && !(inWindow && lineNumber < trailingContextEnd))
finish();
});
child.on("error", (error) => {
cleanup();
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
});
child.on("close", (code) => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
settle(() => resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }));
return;
}
const rawOutput = outputLines.join("\n");
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
truncation.totalLines = outputLineCount;
truncation.totalBytes = outputBytes - 1;
let output = truncation.content;
const details = {};
// Build actionable notices for truncation and match limits.
const notices = [];
if (matchLimitReached) {
notices.push(`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`);
details.matchLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
details.linesTruncated = true;
}
if (notices.length > 0)
output += `\n\n[${notices.join(". ")}]`;
settle(() => resolve({
content: [{ type: "text", text: output }],
details: Object.keys(details).length > 0 ? details : undefined,
}));
});
}
catch (err) {
settle(() => reject(err));
}
})();
});
},
...grepRenderers,
};
}
export function createGrepTool(cwd, options) {
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
}
//# sourceMappingURL=grep.js.map