- file_ops.MAX_FIND_RESULTS: 1000 -> 200 to match existing tool cap and preserve LLM behavior - tools.find_files now delegates to file_ops.findFiles (parallels how grep already delegates); drops ~50 LOC of duplicated path resolution and rg subprocess - Drop unused basename import in file_ops Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
8.8 KiB
TypeScript
283 lines
8.8 KiB
TypeScript
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { resolve, basename, relative } from 'node:path';
|
|
import { z } from 'zod';
|
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
|
|
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
const DEFAULT_VIEW_LINES = 200;
|
|
const MAX_GREP_RESULTS = 200;
|
|
const DEFAULT_GREP_RESULTS = 100;
|
|
const MAX_FIND_RESULTS = 200;
|
|
const DEFAULT_FIND_RESULTS = 100;
|
|
const MAX_DIR_ENTRIES = 500;
|
|
|
|
export interface ToolJsonSchema {
|
|
type: 'function';
|
|
function: {
|
|
name: string;
|
|
description: string;
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
export interface ToolDef<TInput> {
|
|
name: string;
|
|
description: string;
|
|
inputSchema: z.ZodType<TInput>;
|
|
jsonSchema: ToolJsonSchema;
|
|
execute(input: TInput, projectRoot: string): Promise<unknown>;
|
|
}
|
|
|
|
const ViewFileInput = z.object({
|
|
path: z.string().min(1),
|
|
start_line: z.number().int().positive().optional(),
|
|
end_line: z.number().int().positive().optional(),
|
|
});
|
|
type ViewFileInputT = z.infer<typeof ViewFileInput>;
|
|
|
|
export const viewFile: ToolDef<ViewFileInputT> = {
|
|
name: 'view_file',
|
|
description:
|
|
"Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.",
|
|
inputSchema: ViewFileInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'view_file',
|
|
description:
|
|
"Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.",
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: { type: 'string', description: 'absolute or project-relative path' },
|
|
start_line: { type: 'integer', description: 'first line (1-indexed)' },
|
|
end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' },
|
|
},
|
|
required: ['path'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, projectRoot) {
|
|
const real = await pathGuard(projectRoot, input.path);
|
|
const s = await stat(real);
|
|
if (!s.isFile()) {
|
|
throw new PathScopeError(`not a file: ${input.path}`);
|
|
}
|
|
if (s.size > MAX_FILE_BYTES) {
|
|
throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
|
|
}
|
|
const raw = await readFile(real, 'utf8');
|
|
const lines = raw.split('\n');
|
|
const total = lines.length;
|
|
let start = input.start_line ?? 1;
|
|
let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1);
|
|
if (input.start_line == null && input.end_line == null) {
|
|
end = Math.min(total, DEFAULT_VIEW_LINES);
|
|
}
|
|
if (start < 1) start = 1;
|
|
if (end > total) end = total;
|
|
if (end < start) end = start;
|
|
const slice = lines.slice(start - 1, end);
|
|
const content = slice.join('\n');
|
|
const truncated = total > end || start > 1;
|
|
return {
|
|
path: relative(projectRoot, real) || basename(real),
|
|
content,
|
|
total_lines: total,
|
|
returned_lines: [start, end],
|
|
truncated,
|
|
};
|
|
},
|
|
};
|
|
|
|
const ListDirInput = z.object({
|
|
path: z.string().min(1),
|
|
show_hidden: z.boolean().optional(),
|
|
});
|
|
type ListDirInputT = z.infer<typeof ListDirInput>;
|
|
|
|
export const listDir: ToolDef<ListDirInputT> = {
|
|
name: 'list_dir',
|
|
description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.',
|
|
inputSchema: ListDirInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'list_dir',
|
|
description:
|
|
'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: { type: 'string' },
|
|
show_hidden: { type: 'boolean' },
|
|
},
|
|
required: ['path'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, projectRoot) {
|
|
const real = await pathGuard(projectRoot, input.path);
|
|
const s = await stat(real);
|
|
if (!s.isDirectory()) {
|
|
throw new PathScopeError(`not a directory: ${input.path}`);
|
|
}
|
|
const entries = await readdir(real, { withFileTypes: true });
|
|
const filtered = input.show_hidden
|
|
? entries
|
|
: entries.filter((e) => !e.name.startsWith('.'));
|
|
const total = filtered.length;
|
|
const slice = filtered.slice(0, MAX_DIR_ENTRIES);
|
|
const out = await Promise.all(
|
|
slice.map(async (e) => {
|
|
const child = resolve(real, e.name);
|
|
let size: number | undefined;
|
|
if (e.isFile()) {
|
|
try {
|
|
const cs = await stat(child);
|
|
size = cs.size;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return {
|
|
name: e.name,
|
|
type: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
|
...(size != null ? { size } : {}),
|
|
};
|
|
})
|
|
);
|
|
return {
|
|
path: relative(projectRoot, real) || '.',
|
|
entries: out,
|
|
total,
|
|
truncated: total > MAX_DIR_ENTRIES,
|
|
};
|
|
},
|
|
};
|
|
|
|
const GrepInput = z.object({
|
|
pattern: z.string().min(1),
|
|
path: z.string().optional(),
|
|
case_sensitive: z.boolean().optional(),
|
|
max_results: z.number().int().positive().optional(),
|
|
hidden: z.boolean().optional(),
|
|
});
|
|
type GrepInputT = z.infer<typeof GrepInput>;
|
|
|
|
export const grep: ToolDef<GrepInputT> = {
|
|
name: 'grep',
|
|
description:
|
|
'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).',
|
|
inputSchema: GrepInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'grep',
|
|
description:
|
|
'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
pattern: { type: 'string' },
|
|
path: { type: 'string' },
|
|
case_sensitive: { type: 'boolean' },
|
|
max_results: { type: 'integer' },
|
|
hidden: { type: 'boolean' },
|
|
},
|
|
required: ['pattern'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, projectRoot) {
|
|
const limit = Math.min(
|
|
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
|
MAX_GREP_RESULTS
|
|
);
|
|
// Delegate to file_ops.grep; reshape match objects to preserve LLM output format
|
|
// (file_ops uses {path, line, text}; tool output uses {path, line, content})
|
|
const result = await fileOpsGrep(projectRoot, input.pattern, {
|
|
path: input.path,
|
|
max_matches: limit,
|
|
case_sensitive: input.case_sensitive,
|
|
hidden: input.hidden,
|
|
});
|
|
return {
|
|
matches: result.matches.map((m) => ({
|
|
path: m.path,
|
|
line: m.line,
|
|
content: m.text,
|
|
})),
|
|
total: result.matches.length,
|
|
truncated: result.truncated,
|
|
};
|
|
},
|
|
};
|
|
|
|
const FindFilesInput = z.object({
|
|
pattern: z.string().min(1),
|
|
path: z.string().optional(),
|
|
max_results: z.number().int().positive().optional(),
|
|
});
|
|
type FindFilesInputT = z.infer<typeof FindFilesInput>;
|
|
|
|
export const findFiles: ToolDef<FindFilesInputT> = {
|
|
name: 'find_files',
|
|
description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).',
|
|
inputSchema: FindFilesInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'find_files',
|
|
description:
|
|
'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
pattern: { type: 'string' },
|
|
path: { type: 'string' },
|
|
max_results: { type: 'integer' },
|
|
},
|
|
required: ['pattern'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, projectRoot) {
|
|
const limit = Math.min(
|
|
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
|
MAX_FIND_RESULTS
|
|
);
|
|
// Delegate to file_ops.findFiles; reshape { files, total, truncated } to
|
|
// preserve the LLM-visible output format { paths, total, truncated }
|
|
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
|
path: input.path,
|
|
max_results: limit,
|
|
});
|
|
return {
|
|
paths: result.files,
|
|
total: result.total,
|
|
truncated: result.truncated,
|
|
};
|
|
},
|
|
};
|
|
|
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|
viewFile as ToolDef<unknown>,
|
|
listDir as ToolDef<unknown>,
|
|
grep as ToolDef<unknown>,
|
|
findFiles as ToolDef<unknown>,
|
|
];
|
|
|
|
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
|
ALL_TOOLS.map((t) => [t.name, t])
|
|
);
|
|
|
|
export function toolJsonSchemas(): ToolJsonSchema[] {
|
|
return ALL_TOOLS.map((t) => t.jsonSchema);
|
|
}
|