mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 07:04:25 +00:00
feat(coding-agent): add read-only exploration tools (grep, find, ls) and --tools flag
Add grep, find, and ls tools for safe code exploration without modification risk. These tools are available via the new --tools CLI flag. - grep: Uses ripgrep (auto-downloaded) for fast regex searching. Respects .gitignore, supports glob filtering, context lines, and hidden files. - find: Uses fd (auto-downloaded) for fast file finding. Respects .gitignore, supports glob patterns, and hidden files. - ls: Lists directory contents with proper sorting and directory indicators. - --tools flag: Specify available tools (e.g., --tools read,grep,find,ls for read-only mode) - Dynamic system prompt adapts to selected tools with relevant guidelines Closes #74
This commit is contained in:
parent
a61eca5dee
commit
186169a820
10 changed files with 928 additions and 25 deletions
169
packages/coding-agent/src/tools/find.ts
Normal file
169
packages/coding-agent/src/tools/find.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { ensureTool } from "../tools-manager.js";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const findSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
}),
|
||||
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
export const findTool: AgentTool<typeof findSchema> = {
|
||||
name: "find",
|
||||
label: "find",
|
||||
description:
|
||||
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore.",
|
||||
parameters: findSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Ensure fd is available
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (!fdPath) {
|
||||
reject(new Error("fd is not available and could not be downloaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = path.resolve(expandPath(searchDir || "."));
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Build fd arguments
|
||||
const args: string[] = [
|
||||
"--glob", // Use glob pattern
|
||||
"--color=never", // No ANSI colors
|
||||
"--hidden", // Search hidden files (but still respect .gitignore)
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
|
||||
// Include .gitignore files (root + nested) so fd respects them even outside git repos
|
||||
const gitignoreFiles = new Set<string>();
|
||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||
if (existsSync(rootGitignore)) {
|
||||
gitignoreFiles.add(rootGitignore);
|
||||
}
|
||||
|
||||
try {
|
||||
const nestedGitignores = globSync("**/.gitignore", {
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
absolute: true,
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
});
|
||||
for (const file of nestedGitignores) {
|
||||
gitignoreFiles.add(file);
|
||||
}
|
||||
} catch {
|
||||
// Ignore glob errors
|
||||
}
|
||||
|
||||
for (const gitignorePath of gitignoreFiles) {
|
||||
args.push("--ignore-file", gitignorePath);
|
||||
}
|
||||
|
||||
// Pattern and path
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
// Run fd
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (result.error) {
|
||||
reject(new Error(`Failed to run fd: ${result.error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let output = result.stdout?.trim() || "";
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
||||
// fd returns non-zero for some errors but may still have partial output
|
||||
if (!output) {
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
output = "No files found matching pattern";
|
||||
} else {
|
||||
const lines = output.split("\n");
|
||||
const relativized: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
relativePath = line.slice(searchPath.length + 1); // +1 for the /
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) {
|
||||
relativePath += "/";
|
||||
}
|
||||
|
||||
relativized.push(relativePath);
|
||||
}
|
||||
|
||||
output = relativized.join("\n");
|
||||
|
||||
const count = relativized.length;
|
||||
if (count >= effectiveLimit) {
|
||||
output += `\n\n(truncated, ${effectiveLimit} results shown)`;
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ content: [{ type: "text", text: output }], details: undefined });
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
267
packages/coding-agent/src/tools/grep.ts
Normal file
267
packages/coding-agent/src/tools/grep.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, type Stats, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { ensureTool } from "../tools-manager.js";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
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, e.g. '*.ts' or '**/*.spec.ts'" })),
|
||||
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)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export const grepTool: AgentTool<typeof grepSchema> = {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
description:
|
||||
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore.",
|
||||
parameters: grepSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{
|
||||
pattern,
|
||||
path: searchDir,
|
||||
glob,
|
||||
ignoreCase,
|
||||
literal,
|
||||
context,
|
||||
limit,
|
||||
}: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const rgPath = await ensureTool("rg", true);
|
||||
if (!rgPath) {
|
||||
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = path.resolve(expandPath(searchDir || "."));
|
||||
let searchStat: Stats;
|
||||
try {
|
||||
searchStat = statSync(searchPath);
|
||||
} catch (err) {
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectory = searchStat.isDirectory();
|
||||
const contextValue = context && context > 0 ? context : 0;
|
||||
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
||||
|
||||
const formatPath = (filePath: string): string => {
|
||||
if (isDirectory) {
|
||||
const relative = path.relative(searchPath, filePath);
|
||||
if (relative && !relative.startsWith("..")) {
|
||||
return relative.replace(/\\/g, "/");
|
||||
}
|
||||
}
|
||||
return path.basename(filePath);
|
||||
};
|
||||
|
||||
const fileCache = new Map<string, string[]>();
|
||||
const getFileLines = (filePath: string): string[] => {
|
||||
let lines = fileCache.get(filePath);
|
||||
if (!lines) {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
}
|
||||
fileCache.set(filePath, lines);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
|
||||
|
||||
if (ignoreCase) {
|
||||
args.push("--ignore-case");
|
||||
}
|
||||
|
||||
if (literal) {
|
||||
args.push("--fixed-strings");
|
||||
}
|
||||
|
||||
if (glob) {
|
||||
args.push("--glob", glob);
|
||||
}
|
||||
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let stderr = "";
|
||||
let matchCount = 0;
|
||||
let truncated = false;
|
||||
let aborted = false;
|
||||
let killedDueToLimit = false;
|
||||
const outputLines: string[] = [];
|
||||
|
||||
const cleanup = () => {
|
||||
rl.close();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const stopChild = (dueToLimit: boolean = 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();
|
||||
});
|
||||
|
||||
const formatBlock = (filePath: string, lineNumber: number) => {
|
||||
const relativePath = formatPath(filePath);
|
||||
const lines = getFileLines(filePath);
|
||||
if (!lines.length) {
|
||||
return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
}
|
||||
|
||||
const block: string[] = [];
|
||||
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
|
||||
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
|
||||
|
||||
for (let current = start; current <= end; current++) {
|
||||
const lineText = lines[current - 1] ?? "";
|
||||
const sanitized = lineText.replace(/\r/g, "");
|
||||
const isMatchLine = current === lineNumber;
|
||||
|
||||
if (isMatchLine) {
|
||||
block.push(`${relativePath}:${current}: ${sanitized}`);
|
||||
} else {
|
||||
block.push(`${relativePath}-${current}- ${sanitized}`);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
};
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "match") {
|
||||
matchCount++;
|
||||
const filePath = event.data?.path?.text;
|
||||
const lineNumber = event.data?.line_number;
|
||||
|
||||
if (filePath && typeof lineNumber === "number") {
|
||||
outputLines.push(...formatBlock(filePath, lineNumber));
|
||||
}
|
||||
|
||||
if (matchCount >= effectiveLimit) {
|
||||
truncated = true;
|
||||
stopChild(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let output = outputLines.join("\n");
|
||||
if (truncated) {
|
||||
output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`;
|
||||
}
|
||||
|
||||
settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined }));
|
||||
});
|
||||
} catch (err) {
|
||||
settle(() => reject(err as Error));
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,11 +1,31 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { findTool } from "./find.js";
|
||||
export { grepTool } from "./grep.js";
|
||||
export { lsTool } from "./ls.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { findTool } from "./find.js";
|
||||
import { grepTool } from "./grep.js";
|
||||
import { lsTool } from "./ls.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
// Default tools for full access mode
|
||||
export const codingTools = [readTool, bashTool, editTool, writeTool];
|
||||
|
||||
// All available tools (including read-only exploration tools)
|
||||
export const allTools = {
|
||||
read: readTool,
|
||||
bash: bashTool,
|
||||
edit: editTool,
|
||||
write: writeTool,
|
||||
grep: grepTool,
|
||||
find: findTool,
|
||||
ls: lsTool,
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof allTools;
|
||||
|
|
|
|||
116
packages/coding-agent/src/tools/ls.ts
Normal file
116
packages/coding-agent/src/tools/ls.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import nodePath from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const lsSchema = Type.Object({
|
||||
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
export const lsTool: AgentTool<typeof lsSchema> = {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
description:
|
||||
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles.",
|
||||
parameters: lsSchema,
|
||||
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const dirPath = nodePath.resolve(expandPath(path || "."));
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Check if path exists
|
||||
if (!existsSync(dirPath)) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
const stat = statSync(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reject(new Error(`Not a directory: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dirPath);
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Cannot read directory: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators
|
||||
const results: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
|
||||
try {
|
||||
const entryStat = statSync(fullPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
suffix = "/";
|
||||
}
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(entry + suffix);
|
||||
}
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
let output = results.join("\n");
|
||||
if (truncated) {
|
||||
const remaining = entries.length - effectiveLimit;
|
||||
output += `\n\n(truncated, ${remaining} more entries)`;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
output = "(empty directory)";
|
||||
}
|
||||
|
||||
resolve({ content: [{ type: "text", text: output }], details: undefined });
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue