Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { resolveInRepo, isKicadFile } from '../util/paths.js';
export async function toolReadFile(
repoRoot: string,
p: string,
startLine?: number,
endLine?: number,
): Promise<string> {
const abs = resolveInRepo(repoRoot, p);
const text = await readFile(abs, 'utf8');
if (startLine === undefined) return text;
const lines = text.split('\n');
const from = Math.max(1, startLine);
const to = Math.min(lines.length, endLine ?? lines.length);
return lines
.slice(from - 1, to)
.map((l, i) => `${from + i}: ${l}`)
.join('\n');
}
/** New files only; refuses to overwrite anything and to create KiCad files (SPEC §4.2). */
export async function toolWriteFile(repoRoot: string, p: string, content: string): Promise<string> {
const abs = resolveInRepo(repoRoot, p);
if (isKicadFile(abs)) {
throw new Error(`write_file refuses KiCad files (${p}); use edit_file with anchors instead`);
}
if (existsSync(abs)) {
throw new Error(`write_file refuses to overwrite existing file ${p}; use edit_file`);
}
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, content, 'utf8');
return `wrote ${p}`;
}
/**
* Exact-match anchored replace; fails with an actionable error unless unique.
* `replaceAll` replaces every occurrence (the rename case, AC-3.1) while still
* being a surgical text edit on the s-expression source.
*/
export async function toolEditFile(
repoRoot: string,
p: string,
oldString: string,
newString: string,
replaceAll = false,
): Promise<string> {
const abs = resolveInRepo(repoRoot, p);
const text = await readFile(abs, 'utf8');
const first = text.indexOf(oldString);
if (first === -1) {
throw new Error(`edit_file: anchor not found in ${p}; re-read the file and use an exact excerpt`);
}
const count = text.split(oldString).length - 1;
if (replaceAll) {
await writeFile(abs, text.split(oldString).join(newString), 'utf8');
return `edited ${p} (${count} occurrence(s) replaced)`;
}
if (count > 1) {
throw new Error(
`edit_file: anchor matched ${count} times in ${p}; widen the anchor with surrounding lines until it is unique, or pass replace_all: true to replace every occurrence`,
);
}
await writeFile(abs, text.slice(0, first) + newString + text.slice(first + oldString.length), 'utf8');
return `edited ${p}`;
}
export interface SearchMatch {
file: string;
line: number;
text: string;
}
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.copperhead']);
/**
* Longest match line returned to the model, in characters.
*
* The file-size bound in `toolSearch` (5 MB) bounds the file, not the line, and
* on a one-object-per-line document those are very different limits. A
* `transcript.jsonl` line is an entire `{ts, type, data}` record with tool
* output inline, so a few matches inside one file can be hundreds of thousands
* of tokens. A live run died on exactly that — `Prompt is too long · the
* request is ~1003121 tokens (limit 1000000) but this conversation is only
* ~403383 tokens`, the balance being search results — and it cost a stage
* attempt. The agent had done nothing wrong: it searched the repo it was told
* to work in, and a `run-logs/` directory happened to be sitting in it.
*
* A match is a pointer, not a payload — enough to see the hit and decide
* whether to read the file. Truncation is marked so a clipped line is
* distinguishable from a short one.
*/
const MAX_MATCH_CHARS = 400;
/** Clip one match line to `MAX_MATCH_CHARS`, marking it when clipped. */
function clipMatch(line: string): string {
const t = line.trim();
return t.length <= MAX_MATCH_CHARS ? t : `${t.slice(0, MAX_MATCH_CHARS)}… [+${t.length - MAX_MATCH_CHARS} chars]`;
}
export function globToRegex(glob: string): RegExp {
let out = '';
for (let i = 0; i < glob.length; i++) {
if (glob.startsWith('**/', i)) {
out += '(?:.*/)?'; // zero or more directories
i += 2;
} else if (glob.startsWith('**', i)) {
out += '.*';
i += 1;
} else if (glob[i] === '*') {
out += '[^/]*';
} else if (glob[i] === '?') {
out += '.';
} else {
out += glob[i]!.replace(/[.+^${}()|[\]\\]/, '\\$&');
}
}
return new RegExp(`^${out}$`);
}
/** ripgrep-style regex search implemented natively (no rg dependency). */
export async function toolSearch(
repoRoot: string,
pattern: string,
glob?: string,
maxMatches = 200,
): Promise<SearchMatch[]> {
const re = new RegExp(pattern);
const globRe = glob ? globToRegex(glob) : null;
const matches: SearchMatch[] = [];
async function walk(dir: string): Promise<void> {
if (matches.length >= maxMatches) return;
for (const entry of await readdir(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const abs = path.join(dir, entry);
const rel = path.relative(repoRoot, abs);
const st = await stat(abs);
if (st.isDirectory()) {
await walk(abs);
} else if (st.size < 5_000_000 && (!globRe || globRe.test(rel))) {
let text: string;
try {
text = await readFile(abs, 'utf8');
} catch {
continue;
}
if (text.includes('\u0000')) continue; // binary
const lines = text.split('\n');
for (let i = 0; i < lines.length && matches.length < maxMatches; i++) {
if (re.test(lines[i]!)) matches.push({ file: rel, line: i + 1, text: clipMatch(lines[i]!) });
}
}
if (matches.length >= maxMatches) return;
}
}
await walk(repoRoot);
return matches;
}
b4d619a61This file is pinned to the selected release. Download the original to open it in your design tool.