This commit is contained in:
2026-05-14 19:24:50 +00:00
parent af0628867f
commit a7f218e182
63 changed files with 10539 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, basename, relative } from 'node:path';
import { spawn } from 'node:child_process';
import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.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>;
interface RipgrepMatch {
type: string;
data?: {
path?: { text?: string };
line_number?: number;
lines?: { text?: string };
};
}
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 target = await pathGuard(projectRoot, input.path ?? projectRoot);
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
);
const args = [
'--json',
'--max-count',
String(limit),
'--max-columns',
'300',
];
if (!input.case_sensitive) args.push('--ignore-case');
if (input.hidden) args.push('--hidden');
args.push('--', input.pattern, target);
return await new Promise((resolveP, rejectP) => {
const child = spawn('rg', args, { cwd: projectRoot });
const matches: Array<{ path: string; line: number; content: string }> = [];
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
if (matches.length >= limit) continue;
try {
const parsed = JSON.parse(line) as RipgrepMatch;
if (parsed.type !== 'match' || !parsed.data) continue;
const path = parsed.data.path?.text ?? '';
const lineNumber = parsed.data.line_number ?? 0;
const content = parsed.data.lines?.text ?? '';
matches.push({
path: relative(projectRoot, path) || path,
line: lineNumber,
content: content.replace(/\n$/, ''),
});
} catch {
/* ignore non-json */
}
}
if (matches.length >= limit) {
child.kill();
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
// rg exits 1 when no matches, 2 on real error
if (code === 2 && matches.length === 0) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
resolveP({
matches,
total: matches.length,
truncated: matches.length >= limit,
});
});
});
},
};
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 target = await pathGuard(projectRoot, input.path ?? projectRoot);
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
);
return await new Promise((resolveP, rejectP) => {
const args = ['--files', '--glob', input.pattern, target];
const child = spawn('rg', args, { cwd: projectRoot });
const paths: string[] = [];
let total = 0;
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
total++;
if (paths.length < limit) {
paths.push(relative(projectRoot, line) || line);
}
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
if (code === 2) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
if (buf.length > 0) {
total++;
if (paths.length < limit) {
paths.push(relative(projectRoot, buf) || buf);
}
}
resolveP({
paths,
total,
truncated: total > paths.length,
});
});
});
},
};
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);
}