@tomsun28/pizza 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,319 +1,134 @@
1
1
  /**
2
- * Core logic for pizza-mode: [CLI] tag parsing, tool execution helpers,
3
- * and conversation history rewriting.
2
+ * Core logic for pizza-mode.
3
+ *
4
+ * Protocol:
5
+ * [CLI id=N] <shell command>[/CLI]
6
+ * — execute an arbitrary shell command; the full unix toolbelt is available
7
+ * (ls, cat, rg, fd, find, grep, sed, awk, jq, git, gh, curl, node, python, …).
8
+ *
9
+ * [WRITE id=N path=<path>]
10
+ * <full file contents>
11
+ * [/WRITE]
12
+ * — overwrite a file with the exact body between the tags.
13
+ *
14
+ * [EDIT id=N path=<path>]
15
+ * <<<<<<< SEARCH
16
+ * <exact existing text>
17
+ * =======
18
+ * <replacement text>
19
+ * >>>>>>> REPLACE
20
+ * [/EDIT]
21
+ * — targeted replacement; SEARCH text must match exactly and appear exactly once.
22
+ *
23
+ * Results are always returned as [RESULT id=N kind=<bash|write|edit> …]…[/RESULT].
4
24
  */
5
- import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
6
- import { resolve, isAbsolute, dirname, join, relative } from "node:path";
25
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
26
+ import { resolve, isAbsolute, dirname } from "node:path";
7
27
  import { spawn } from "node:child_process";
8
28
  // ─── Constants ────────────────────────────────────────────────────────────────
9
- export const MAX_CALLS_PER_TURN = 5;
29
+ export const MAX_CALLS_PER_TURN = 10;
10
30
  export const MAX_OUTPUT_BYTES = 128 * 1024;
11
31
  export const MAX_OUTPUT_LINES = 1000;
12
- export const MAX_FILE_BYTES = 256 * 1024;
13
- export const MAX_FILE_LINES = 2000;
14
- export const MAX_GREP_RESULTS = 200;
15
- export const MAX_GREP_FILE_SIZE = 1024 * 1024;
16
- // ─── [CLI] tag parsing ────────────────────────────────────────────────────────
32
+ // ─── Attribute parsing ────────────────────────────────────────────────────────
17
33
  /**
18
- * Parse [CLI] toolName args[/CLI] blocks from model text output.
19
- * Returns all calls found and the text before the first call.
34
+ * Parse `key=value` attribute strings, supporting quoted values.
35
+ * Example input: ` id=1 path="src/foo.ts"` { id: "1", path: "src/foo.ts" }
20
36
  */
21
- export function parseCLICalls(text) {
22
- const regex = /\[CLI(?:\s+id=(\d+))?\]([\s\S]*?)\[\/CLI\]/g;
23
- const calls = [];
24
- let firstMatchStart = -1;
25
- let autoId = 1;
26
- let match;
27
- while ((match = regex.exec(text)) !== null) {
28
- if (firstMatchStart === -1)
29
- firstMatchStart = match.index;
30
- const explicitId = match[1] ? parseInt(match[1], 10) : undefined;
31
- const inner = match[2].trim();
32
- const spaceIdx = inner.search(/\s/);
33
- const toolName = spaceIdx === -1 ? inner : inner.slice(0, spaceIdx);
34
- const rawArgs = spaceIdx === -1 ? "" : inner.slice(spaceIdx + 1).trim();
35
- if (toolName) {
36
- calls.push({ id: explicitId ?? autoId, toolName, rawArgs });
37
- autoId++;
38
- }
37
+ function parseAttrs(raw) {
38
+ const attrs = {};
39
+ const re = /(\w+)=(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^\s\]]+))/g;
40
+ let m;
41
+ while ((m = re.exec(raw)) !== null) {
42
+ const key = m[1];
43
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
44
+ attrs[key] = value;
39
45
  }
40
- const textBeforeCalls = firstMatchStart >= 0 ? text.substring(0, firstMatchStart).trimEnd() : text;
41
- return { calls, textBeforeCalls };
46
+ return attrs;
42
47
  }
43
- // ─── Shell-style arg parser ───────────────────────────────────────────────────
48
+ // ─── Block parsing ────────────────────────────────────────────────────────────
44
49
  /**
45
- * Parse a shell-style args string into a Record.
46
- * Handles: --key "value", --key value, --flag, positional args.
50
+ * Parse all [CLI], [WRITE], [EDIT] blocks from assistant text, preserving order.
51
+ * Auto-assigns ids when not explicit. Ids are independent per kind so that
52
+ * result rendering can reference them back without collision.
47
53
  */
48
- export function parseCliArgs(raw) {
49
- const args = {};
50
- const positional = [];
51
- const tokens = [];
52
- let i = 0;
53
- while (i < raw.length) {
54
- while (i < raw.length && /\s/.test(raw[i]))
55
- i++;
56
- if (i >= raw.length)
54
+ export function parseBlocks(text) {
55
+ const blocks = [];
56
+ const counters = { bash: 1, write: 1, edit: 1 };
57
+ // Use a single scanner that finds whichever tag opens next, then consumes
58
+ // to its matching close tag. This preserves source order across kinds.
59
+ const openRe = /\[(CLI|WRITE|EDIT)((?:\s+[^\]]*)?)\]/g;
60
+ let m;
61
+ while ((m = openRe.exec(text)) !== null) {
62
+ const tag = m[1];
63
+ const attrs = parseAttrs(m[2] ?? "");
64
+ const closeTag = `[/${tag}]`;
65
+ const closeIdx = text.indexOf(closeTag, openRe.lastIndex);
66
+ if (closeIdx === -1)
57
67
  break;
58
- if (raw[i] === '"' || raw[i] === "'") {
59
- const quote = raw[i++];
60
- let val = "";
61
- while (i < raw.length && raw[i] !== quote) {
62
- if (raw[i] === "\\" && i + 1 < raw.length) {
63
- i++;
64
- val += raw[i++];
65
- }
66
- else {
67
- val += raw[i++];
68
- }
69
- }
70
- i++;
71
- tokens.push(val);
72
- }
73
- else {
74
- let val = "";
75
- while (i < raw.length && !/\s/.test(raw[i])) {
76
- val += raw[i++];
77
- }
78
- tokens.push(val);
68
+ const body = text.slice(openRe.lastIndex, closeIdx);
69
+ openRe.lastIndex = closeIdx + closeTag.length;
70
+ const explicitId = attrs.id ? parseInt(attrs.id, 10) : undefined;
71
+ if (tag === "CLI") {
72
+ const command = body.trim();
73
+ if (!command)
74
+ continue;
75
+ blocks.push({
76
+ kind: "bash",
77
+ id: explicitId ?? counters.bash++,
78
+ command,
79
+ });
79
80
  }
80
- }
81
- let ti = 0;
82
- while (ti < tokens.length) {
83
- const tok = tokens[ti];
84
- if (tok.startsWith("--")) {
85
- const key = tok.slice(2);
86
- if (ti + 1 < tokens.length && !tokens[ti + 1].startsWith("--")) {
87
- args[key] = tokens[ti + 1];
88
- ti += 2;
89
- }
90
- else {
91
- args[key] = true;
92
- ti++;
81
+ else if (tag === "WRITE") {
82
+ if (!attrs.path) {
83
+ blocks.push({
84
+ kind: "write",
85
+ id: explicitId ?? counters.write++,
86
+ path: "",
87
+ content: body,
88
+ });
89
+ continue;
93
90
  }
91
+ blocks.push({
92
+ kind: "write",
93
+ id: explicitId ?? counters.write++,
94
+ path: attrs.path,
95
+ content: stripLeadingNewline(body),
96
+ });
94
97
  }
95
- else {
96
- positional.push(tok);
97
- ti++;
98
- }
99
- }
100
- if (positional.length > 0) {
101
- if (!("path" in args) && !("command" in args) && !("pattern" in args)) {
102
- args._positional = positional.join(" ");
98
+ else if (tag === "EDIT") {
99
+ const parsed = parseEditBody(body);
100
+ blocks.push({
101
+ kind: "edit",
102
+ id: explicitId ?? counters.edit++,
103
+ path: attrs.path ?? "",
104
+ search: parsed?.search ?? "",
105
+ replace: parsed?.replace ?? "",
106
+ });
103
107
  }
104
108
  }
105
- return args;
109
+ return blocks;
106
110
  }
107
- /**
108
- * Map parsed args to tool-specific argument schema.
109
- */
110
- export function mapArgsToTool(toolName, rawArgs) {
111
- const args = parseCliArgs(rawArgs);
112
- const pos = args._positional;
113
- delete args._positional;
114
- switch (toolName) {
115
- case "ls":
116
- if (pos && !args.path)
117
- args.path = pos;
118
- break;
119
- case "read":
120
- if (pos && !args.path)
121
- args.path = pos;
122
- break;
123
- case "write":
124
- // --path and --content required
125
- break;
126
- case "edit":
127
- if (args.old) {
128
- args.oldText = args.old;
129
- delete args.old;
130
- }
131
- if (args.new) {
132
- args.newText = args.new;
133
- delete args.new;
134
- }
135
- if (pos && !args.path)
136
- args.path = pos;
137
- break;
138
- case "grep":
139
- if (pos && !args.pattern)
140
- args.pattern = pos;
141
- break;
142
- case "bash":
143
- if (rawArgs && !args.command)
144
- args.command = rawArgs;
145
- break;
146
- default:
147
- if (pos)
148
- args.input = pos;
149
- }
150
- return args;
111
+ function stripLeadingNewline(s) {
112
+ return s.startsWith("\n") ? s.slice(1) : s;
151
113
  }
152
114
  /**
153
- * Convert tool call arguments back to CLI flag string for history reconstruction.
115
+ * Extract SEARCH / REPLACE halves from the body of an [EDIT] block.
116
+ * Accepts Aider-style markers:
117
+ * <<<<<<< SEARCH
118
+ * ...
119
+ * =======
120
+ * ...
121
+ * >>>>>>> REPLACE
154
122
  */
155
- export function argsToCliString(toolName, arguments_) {
156
- if (toolName === "bash") {
157
- return arguments_.command ?? "";
158
- }
159
- if (toolName === "ls" || toolName === "read") {
160
- const path = arguments_.path;
161
- const rest = Object.entries(arguments_)
162
- .filter(([k]) => k !== "path")
163
- .map(([k, v]) => `--${k} "${String(v).replace(/"/g, '\\"')}"`)
164
- .join(" ");
165
- return path ? (rest ? `${path} ${rest}` : path) : rest;
166
- }
167
- if (toolName === "edit") {
168
- const mapped = { ...arguments_ };
169
- if (mapped.oldText !== undefined) {
170
- mapped.old = mapped.oldText;
171
- delete mapped.oldText;
172
- }
173
- if (mapped.newText !== undefined) {
174
- mapped.new = mapped.newText;
175
- delete mapped.newText;
176
- }
177
- return Object.entries(mapped)
178
- .map(([k, v]) => {
179
- if (v === true)
180
- return `--${k}`;
181
- const s = String(v);
182
- return s.includes(" ") || s.includes('"') || s.includes("\n")
183
- ? `--${k} "${s.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`
184
- : `--${k} ${s}`;
185
- })
186
- .join(" ");
187
- }
188
- return Object.entries(arguments_)
189
- .map(([k, v]) => {
190
- if (v === true)
191
- return `--${k}`;
192
- const s = String(v);
193
- return s.includes(" ") || s.includes('"') || s.includes("\n")
194
- ? `--${k} "${s.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`
195
- : `--${k} ${s}`;
196
- })
197
- .join(" ");
123
+ function parseEditBody(body) {
124
+ const re = /<{5,}\s*SEARCH\s*\r?\n([\s\S]*?)\r?\n={5,}\s*\r?\n([\s\S]*?)\r?\n>{5,}\s*REPLACE/;
125
+ const m = re.exec(body);
126
+ if (!m)
127
+ return null;
128
+ return { search: m[1], replace: m[2] };
198
129
  }
199
130
  // ─── Tool implementations ─────────────────────────────────────────────────────
200
- export async function execLs(cwd, args) {
201
- const dirPath = args.path
202
- ? isAbsolute(args.path) ? args.path : resolve(cwd, args.path)
203
- : cwd;
204
- const entries = await readdir(dirPath);
205
- const lines = [];
206
- for (const entry of entries.sort()) {
207
- try {
208
- const fullPath = join(dirPath, entry);
209
- const s = await stat(fullPath);
210
- const type = s.isDirectory() ? "dir" : "file";
211
- const size = s.isDirectory() ? "-" : formatSize(s.size);
212
- lines.push(`${type}\t${size}\t${entry}`);
213
- }
214
- catch {
215
- lines.push(`?\t?\t${entry}`);
216
- }
217
- }
218
- return {
219
- output: lines.length === 0 ? "(empty directory)" : lines.join("\n"),
220
- isError: false,
221
- };
222
- }
223
- export async function execRead(cwd, args) {
224
- const absolutePath = args.path
225
- ? (isAbsolute(args.path) ? args.path : resolve(cwd, args.path))
226
- : resolve(cwd, ".");
227
- const buffer = await readFile(absolutePath);
228
- const text = buffer.toString("utf-8");
229
- const allLines = text.split("\n");
230
- const totalLines = allLines.length;
231
- const offset = args.offset ? Number(args.offset) : undefined;
232
- const limit = args.limit ? Number(args.limit) : undefined;
233
- const startLine = offset ? Math.max(0, offset - 1) : 0;
234
- if (startLine >= totalLines) {
235
- throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines)`);
236
- }
237
- let selected = limit !== undefined
238
- ? allLines.slice(startLine, startLine + limit)
239
- : allLines.slice(startLine);
240
- if (selected.length > MAX_FILE_LINES) {
241
- selected = selected.slice(0, MAX_FILE_LINES);
242
- }
243
- let output = selected.join("\n");
244
- if (Buffer.byteLength(output, "utf-8") > MAX_FILE_BYTES) {
245
- const buf = Buffer.from(output, "utf-8");
246
- output = buf.subarray(0, MAX_FILE_BYTES).toString("utf-8");
247
- }
248
- const shownLines = output.split("\n").length;
249
- const remaining = totalLines - startLine - shownLines;
250
- if (remaining > 0) {
251
- const nextOffset = startLine + shownLines + 1;
252
- output += `\n\n[Showing ${shownLines} of ${totalLines} lines. Use --offset ${nextOffset} to continue.]`;
253
- }
254
- return { output, isError: false };
255
- }
256
- export async function execWrite(cwd, args) {
257
- if (!args.path)
258
- throw new Error("write requires --path");
259
- if (args.content === undefined)
260
- throw new Error("write requires --content");
261
- const absolutePath = isAbsolute(args.path) ? args.path : resolve(cwd, args.path);
262
- await mkdir(dirname(absolutePath), { recursive: true });
263
- await writeFile(absolutePath, args.content, "utf-8");
264
- return {
265
- output: `Wrote ${String(args.content).length} bytes to ${args.path}`,
266
- isError: false,
267
- };
268
- }
269
- export async function execEdit(cwd, args) {
270
- if (!args.path)
271
- throw new Error("edit requires --path");
272
- if (args.oldText === undefined)
273
- throw new Error("edit requires --old");
274
- if (args.newText === undefined)
275
- throw new Error("edit requires --new");
276
- const absolutePath = isAbsolute(args.path) ? args.path : resolve(cwd, args.path);
277
- const content = await readFile(absolutePath, "utf-8");
278
- const index = content.indexOf(args.oldText);
279
- if (index === -1) {
280
- throw new Error(`Could not find the exact text in ${args.path}. The --old text must match exactly including whitespace.`);
281
- }
282
- const second = content.indexOf(args.oldText, index + 1);
283
- if (second !== -1) {
284
- throw new Error(`Found multiple occurrences of the text in ${args.path}. Provide more context to make it unique.`);
285
- }
286
- const newContent = content.substring(0, index) + args.newText + content.substring(index + args.oldText.length);
287
- await writeFile(absolutePath, newContent, "utf-8");
288
- return { output: `Edited ${args.path}`, isError: false };
289
- }
290
- export async function execGrep(cwd, args) {
291
- if (!args.pattern)
292
- throw new Error("grep requires a pattern");
293
- const searchDir = args.path
294
- ? (isAbsolute(args.path) ? args.path : resolve(cwd, args.path))
295
- : cwd;
296
- let regex;
297
- try {
298
- regex = new RegExp(args.pattern, "g");
299
- }
300
- catch (e) {
301
- throw new Error(`Invalid regex pattern: ${e.message}`);
302
- }
303
- const extFilter = args.include ? parseExtFilter(args.include) : null;
304
- const results = [];
305
- await grepDirectory(searchDir, regex, extFilter, results, cwd);
306
- if (results.length === 0) {
307
- return { output: "No matches found.", isError: false };
308
- }
309
- let output = results.join("\n");
310
- if (results.length >= MAX_GREP_RESULTS) {
311
- output += `\n\n[Truncated at ${MAX_GREP_RESULTS} results]`;
312
- }
313
- return { output, isError: false };
314
- }
315
- export async function execBash(cwd, args, signal) {
316
- const command = args.command;
131
+ export async function execBash(cwd, command, signal) {
317
132
  if (!command)
318
133
  throw new Error("bash requires a command");
319
134
  return new Promise((resolve_, reject) => {
@@ -329,12 +144,10 @@ export async function execBash(cwd, args, signal) {
329
144
  setTimeout(() => child.kill("SIGKILL"), 2000);
330
145
  };
331
146
  if (signal) {
332
- if (signal.aborted) {
147
+ if (signal.aborted)
333
148
  onAbort();
334
- }
335
- else {
149
+ else
336
150
  signal.addEventListener("abort", onAbort, { once: true });
337
- }
338
151
  }
339
152
  child.stdout?.on("data", (d) => chunks.push(d));
340
153
  child.stderr?.on("data", (d) => chunks.push(d));
@@ -353,104 +166,95 @@ export async function execBash(cwd, args, signal) {
353
166
  });
354
167
  });
355
168
  }
169
+ export async function execWrite(cwd, path, content) {
170
+ if (!path)
171
+ throw new Error("[WRITE] requires path= attribute");
172
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
173
+ await mkdir(dirname(absolutePath), { recursive: true });
174
+ await writeFile(absolutePath, content, "utf-8");
175
+ return {
176
+ output: `Wrote ${Buffer.byteLength(content, "utf-8")} bytes to ${path}`,
177
+ isError: false,
178
+ };
179
+ }
180
+ export async function execEdit(cwd, path, search, replace) {
181
+ if (!path)
182
+ throw new Error("[EDIT] requires path= attribute");
183
+ if (!search) {
184
+ throw new Error("[EDIT] requires a SEARCH/REPLACE body. Use <<<<<<< SEARCH … ======= … >>>>>>> REPLACE");
185
+ }
186
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
187
+ const content = await readFile(absolutePath, "utf-8");
188
+ const index = content.indexOf(search);
189
+ if (index === -1) {
190
+ throw new Error(`Could not find the SEARCH text in ${path}. It must match exactly, including whitespace.`);
191
+ }
192
+ const second = content.indexOf(search, index + 1);
193
+ if (second !== -1) {
194
+ throw new Error(`SEARCH text occurs multiple times in ${path}. Provide more surrounding context to make it unique.`);
195
+ }
196
+ const newContent = content.substring(0, index) + replace + content.substring(index + search.length);
197
+ await writeFile(absolutePath, newContent, "utf-8");
198
+ return { output: `Edited ${path}`, isError: false };
199
+ }
356
200
  // ─── Dispatch ─────────────────────────────────────────────────────────────────
357
- export async function dispatchTool(toolName, rawArgs, cwd, signal) {
358
- const args = mapArgsToTool(toolName, rawArgs);
359
- switch (toolName) {
360
- case "ls": return execLs(cwd, args);
361
- case "read": return execRead(cwd, args);
362
- case "write": return execWrite(cwd, args);
363
- case "edit": return execEdit(cwd, args);
364
- case "grep": return execGrep(cwd, args);
365
- case "bash": return execBash(cwd, args, signal);
366
- default: {
367
- // Fallback: treat unknown commands as bash
368
- const bashCmd = `${toolName}${rawArgs ? " " + rawArgs : ""}`;
369
- return execBash(cwd, { command: bashCmd }, signal);
370
- }
201
+ export async function dispatchBlock(block, cwd, signal) {
202
+ switch (block.kind) {
203
+ case "bash":
204
+ return execBash(cwd, block.command, signal);
205
+ case "write":
206
+ return execWrite(cwd, block.path, block.content);
207
+ case "edit":
208
+ return execEdit(cwd, block.path, block.search, block.replace);
371
209
  }
372
210
  }
373
211
  // ─── Result formatting ────────────────────────────────────────────────────────
374
- export function buildResultBlock(output, isError, id, total, cliCommand) {
375
- const idAttr = id !== undefined ? ` id=${id}` : "";
376
- const cliAttr = cliCommand ? ` cli="${cliCommand.replace(/"/g, '\\"')}"` : "";
212
+ export function buildResultBlock(output, isError, id, kind, label) {
213
+ const labelAttr = label ? ` label="${label.replace(/"/g, '\\"')}"` : "";
377
214
  const errAttr = isError ? " error=true" : "";
378
- return `[RESULT${idAttr}${cliAttr}${errAttr}]\n${output}\n[/RESULT]`;
215
+ return `[RESULT id=${id} kind=${kind}${labelAttr}${errAttr}]\n${output}\n[/RESULT]`;
216
+ }
217
+ export function labelForBlock(block) {
218
+ switch (block.kind) {
219
+ case "bash":
220
+ return truncateLabel(block.command);
221
+ case "write":
222
+ return `write ${block.path}`;
223
+ case "edit":
224
+ return `edit ${block.path}`;
225
+ }
226
+ }
227
+ function truncateLabel(s) {
228
+ const oneLine = s.replace(/\s+/g, " ").trim();
229
+ return oneLine.length > 120 ? oneLine.slice(0, 117) + "…" : oneLine;
379
230
  }
380
231
  // ─── History rewriting ────────────────────────────────────────────────────────
381
232
  /**
382
- * Reconstruct the [CLI] text that the model originally emitted for a tool call.
233
+ * Reconstruct assistant-visible text for a native tool_use call of one of our
234
+ * built-in operations (used only if an older session recorded them as native
235
+ * tool calls). For bash we render [CLI]; for write/edit we render fenced blocks.
383
236
  */
384
- export function reconstructCliText(toolName, arguments_) {
385
- const cliArgs = argsToCliString(toolName, arguments_);
386
- const inner = cliArgs ? `${toolName} ${cliArgs}` : toolName;
387
- return `[CLI] ${inner}[/CLI]`;
388
- }
389
- // ─── Helpers ──────────────────────────────────────────────────────────────────
390
- function formatSize(bytes) {
391
- if (bytes < 1024)
392
- return `${bytes}B`;
393
- if (bytes < 1024 * 1024)
394
- return `${(bytes / 1024).toFixed(1)}KB`;
395
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
396
- }
397
- function parseExtFilter(include) {
398
- if (include.startsWith("*."))
399
- return include.substring(1);
400
- return include;
401
- }
402
- const SKIP_DIRS = new Set([
403
- "node_modules", ".git", "dist", "build", ".next", "__pycache__",
404
- ".venv", "venv", ".cache", "coverage",
405
- ]);
406
- async function grepDirectory(dir, regex, extFilter, results, cwd) {
407
- if (results.length >= MAX_GREP_RESULTS)
408
- return;
409
- let entries;
410
- try {
411
- entries = await readdir(dir);
237
+ export function reconstructBlockText(toolName, args) {
238
+ if (toolName === "bash" && typeof args.command === "string") {
239
+ return `[CLI] ${args.command}[/CLI]`;
412
240
  }
413
- catch {
414
- return;
241
+ if (toolName === "write" && typeof args.path === "string") {
242
+ const content = typeof args.content === "string" ? args.content : "";
243
+ return `[WRITE path="${escapeAttr(args.path)}"]\n${content}\n[/WRITE]`;
415
244
  }
416
- for (const entry of entries) {
417
- if (results.length >= MAX_GREP_RESULTS)
418
- return;
419
- const fullPath = join(dir, entry);
420
- let s;
421
- try {
422
- s = await stat(fullPath);
423
- }
424
- catch {
425
- continue;
426
- }
427
- if (s.isDirectory()) {
428
- if (!SKIP_DIRS.has(entry)) {
429
- await grepDirectory(fullPath, regex, extFilter, results, cwd);
430
- }
431
- }
432
- else if (s.isFile()) {
433
- if (s.size > MAX_GREP_FILE_SIZE)
434
- continue;
435
- if (extFilter && !entry.endsWith(extFilter))
436
- continue;
437
- try {
438
- const content = await readFile(fullPath, "utf-8");
439
- const lines = content.split("\n");
440
- const relPath = relative(cwd, fullPath);
441
- for (let i = 0; i < lines.length; i++) {
442
- regex.lastIndex = 0;
443
- if (regex.test(lines[i])) {
444
- results.push(`${relPath}:${i + 1}: ${lines[i].trimEnd()}`);
445
- if (results.length >= MAX_GREP_RESULTS)
446
- return;
447
- }
448
- }
449
- }
450
- catch { /* skip binary/unreadable */ }
451
- }
245
+ if (toolName === "edit" && typeof args.path === "string") {
246
+ const oldText = args.oldText ?? args.old ?? "";
247
+ const newText = args.newText ?? args.new ?? "";
248
+ return (`[EDIT path="${escapeAttr(args.path)}"]\n` +
249
+ `<<<<<<< SEARCH\n${oldText}\n=======\n${newText}\n>>>>>>> REPLACE\n` +
250
+ `[/EDIT]`);
452
251
  }
252
+ return null;
253
+ }
254
+ function escapeAttr(s) {
255
+ return s.replace(/"/g, '\\"');
453
256
  }
257
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
454
258
  function truncateOutput(text) {
455
259
  const lines = text.split("\n");
456
260
  if (lines.length > MAX_OUTPUT_LINES) {