@probelabs/probe 0.6.0-rc253 → 0.6.0-rc255

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.
Files changed (53) hide show
  1. package/README.md +166 -3
  2. package/bin/binaries/probe-v0.6.0-rc255-aarch64-apple-darwin.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc255-aarch64-unknown-linux-musl.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc255-x86_64-apple-darwin.tar.gz +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc255-x86_64-pc-windows-msvc.zip +0 -0
  6. package/bin/binaries/probe-v0.6.0-rc255-x86_64-unknown-linux-musl.tar.gz +0 -0
  7. package/build/agent/ProbeAgent.d.ts +1 -1
  8. package/build/agent/ProbeAgent.js +51 -16
  9. package/build/agent/acp/tools.js +2 -1
  10. package/build/agent/acp/tools.test.js +2 -1
  11. package/build/agent/dsl/environment.js +19 -0
  12. package/build/agent/index.js +1512 -413
  13. package/build/agent/schemaUtils.js +91 -2
  14. package/build/agent/tools.js +0 -28
  15. package/build/delegate.js +3 -0
  16. package/build/index.js +2 -0
  17. package/build/tools/common.js +6 -5
  18. package/build/tools/edit.js +457 -65
  19. package/build/tools/executePlan.js +3 -1
  20. package/build/tools/fileTracker.js +318 -0
  21. package/build/tools/fuzzyMatch.js +271 -0
  22. package/build/tools/hashline.js +131 -0
  23. package/build/tools/lineEditHeuristics.js +138 -0
  24. package/build/tools/symbolEdit.js +119 -0
  25. package/build/tools/vercel.js +40 -9
  26. package/cjs/agent/ProbeAgent.cjs +1615 -517
  27. package/cjs/index.cjs +1643 -543
  28. package/index.d.ts +189 -1
  29. package/package.json +1 -1
  30. package/src/agent/ProbeAgent.d.ts +1 -1
  31. package/src/agent/ProbeAgent.js +51 -16
  32. package/src/agent/acp/tools.js +2 -1
  33. package/src/agent/acp/tools.test.js +2 -1
  34. package/src/agent/dsl/environment.js +19 -0
  35. package/src/agent/index.js +14 -3
  36. package/src/agent/schemaUtils.js +91 -2
  37. package/src/agent/tools.js +0 -28
  38. package/src/delegate.js +3 -0
  39. package/src/index.js +2 -0
  40. package/src/tools/common.js +6 -5
  41. package/src/tools/edit.js +457 -65
  42. package/src/tools/executePlan.js +3 -1
  43. package/src/tools/fileTracker.js +318 -0
  44. package/src/tools/fuzzyMatch.js +271 -0
  45. package/src/tools/hashline.js +131 -0
  46. package/src/tools/lineEditHeuristics.js +138 -0
  47. package/src/tools/symbolEdit.js +119 -0
  48. package/src/tools/vercel.js +40 -9
  49. package/bin/binaries/probe-v0.6.0-rc253-aarch64-apple-darwin.tar.gz +0 -0
  50. package/bin/binaries/probe-v0.6.0-rc253-aarch64-unknown-linux-musl.tar.gz +0 -0
  51. package/bin/binaries/probe-v0.6.0-rc253-x86_64-apple-darwin.tar.gz +0 -0
  52. package/bin/binaries/probe-v0.6.0-rc253-x86_64-pc-windows-msvc.zip +0 -0
  53. package/bin/binaries/probe-v0.6.0-rc253-x86_64-unknown-linux-musl.tar.gz +0 -0
package/src/tools/edit.js CHANGED
@@ -8,6 +8,10 @@ import { promises as fs } from 'fs';
8
8
  import { dirname, resolve, isAbsolute, sep } from 'path';
9
9
  import { existsSync } from 'fs';
10
10
  import { toRelativePath, safeRealpath } from '../utils/path-validation.js';
11
+ import { findFuzzyMatch } from './fuzzyMatch.js';
12
+ import { findSymbol, findAllSymbols, detectBaseIndent, reindent } from './symbolEdit.js';
13
+ import { parseLineRef, validateLineHash, computeLineHash } from './hashline.js';
14
+ import { cleanNewString } from './lineEditHeuristics.js';
11
15
 
12
16
  /**
13
17
  * Validates that a path is within allowed directories
@@ -52,7 +56,242 @@ function parseFileToolOptions(options = {}) {
52
56
  }
53
57
 
54
58
  /**
55
- * Edit tool generator - Claude Code style string replacement
59
+ * Handle AST-aware symbol editing (replace or insert)
60
+ * @param {Object} params - Parameters
61
+ * @returns {Promise<string>} Result message
62
+ */
63
+ async function handleSymbolEdit({ resolvedPath, file_path, symbol, new_string, position, debug, cwd, fileTracker }) {
64
+ // Validate symbol
65
+ if (typeof symbol !== 'string' || symbol.trim() === '') {
66
+ return 'Error editing file: Invalid symbol - must be a non-empty string. Provide the name of a function, class, method, or other named code definition (e.g. "myFunction" or "MyClass.myMethod"). To edit by text matching instead, use old_string + new_string.';
67
+ }
68
+
69
+ // Validate position if provided
70
+ if (position !== undefined && position !== null && position !== 'before' && position !== 'after') {
71
+ return 'Error editing file: Invalid position - must be "before" or "after". Use position="before" to insert code above the symbol, or position="after" to insert code below it. Omit position entirely to replace the symbol with new_string.';
72
+ }
73
+
74
+ // Find the symbol using AST (always re-reads the file — cheap, ~100ms)
75
+ const allMatches = await findAllSymbols(resolvedPath, symbol, cwd || process.cwd());
76
+ if (allMatches.length === 0) {
77
+ return `Error editing file: Symbol "${symbol}" not found in ${file_path}. Verify the symbol name matches a top-level function, class, method, or other named definition exactly as declared in the source. Use 'search' or 'extract' to inspect the file and find the correct symbol name. Alternatively, use old_string + new_string for text-based editing instead.`;
78
+ }
79
+ if (allMatches.length > 1) {
80
+ const suggestions = allMatches.map(m =>
81
+ ` - ${m.qualifiedName} (${m.nodeType}, line ${m.startLine})`
82
+ ).join('\n');
83
+ return `Error editing ${file_path}: Found ${allMatches.length} symbols named "${symbol}". Use a qualified name to specify which one:\n${suggestions}\n\nExample: <edit><file_path>${file_path}</file_path><symbol>${allMatches[0].qualifiedName}</symbol><new_string>...</new_string></edit>`;
84
+ }
85
+ const symbolInfo = allMatches[0];
86
+
87
+ // Symbol content verification — check if symbol changed since LLM last read it
88
+ if (fileTracker) {
89
+ const check = fileTracker.checkSymbolContent(resolvedPath, symbol, symbolInfo.code);
90
+ if (!check.ok && check.reason === 'stale') {
91
+ return `Error editing ${file_path}: Symbol "${symbol}" has changed since you last read it. Use extract to re-read the current content, then retry.\n\nExample: <extract><targets>${file_path}#${symbol}</targets></extract>`;
92
+ }
93
+ }
94
+
95
+ // Read the file
96
+ const content = await fs.readFile(resolvedPath, 'utf-8');
97
+ const lines = content.split('\n');
98
+
99
+ if (position) {
100
+ // Insert mode: add code before/after the symbol
101
+ const refIndent = detectBaseIndent(symbolInfo.code);
102
+ const reindented = reindent(new_string, refIndent);
103
+ const newLines = reindented.split('\n');
104
+
105
+ if (position === 'after') {
106
+ lines.splice(symbolInfo.endLine, 0, '', ...newLines);
107
+ } else {
108
+ lines.splice(symbolInfo.startLine - 1, 0, ...newLines, '');
109
+ }
110
+
111
+ await fs.writeFile(resolvedPath, lines.join('\n'), 'utf-8');
112
+ if (fileTracker) {
113
+ // Re-read symbol to get updated position and content for chained edits
114
+ const updated = await findSymbol(resolvedPath, symbol, cwd || process.cwd());
115
+ if (updated) {
116
+ fileTracker.trackSymbolAfterWrite(resolvedPath, symbol, updated.code, updated.startLine, updated.endLine);
117
+ }
118
+ fileTracker.markFileSeen(resolvedPath);
119
+ }
120
+
121
+ const insertLine = position === 'after' ? symbolInfo.endLine + 1 : symbolInfo.startLine;
122
+
123
+ if (debug) {
124
+ console.error(`[Edit] Successfully inserted ${newLines.length} lines ${position} "${symbol}" at line ${insertLine} in ${resolvedPath}`);
125
+ }
126
+
127
+ return `Successfully inserted ${newLines.length} lines ${position} symbol "${symbol}" in ${file_path} (at line ${insertLine})`;
128
+ } else {
129
+ // Replace mode: replace entire symbol with new content
130
+ const originalIndent = detectBaseIndent(symbolInfo.code);
131
+ const reindented = reindent(new_string, originalIndent);
132
+ const newLines = reindented.split('\n');
133
+
134
+ lines.splice(symbolInfo.startLine - 1, symbolInfo.endLine - symbolInfo.startLine + 1, ...newLines);
135
+ await fs.writeFile(resolvedPath, lines.join('\n'), 'utf-8');
136
+ if (fileTracker) {
137
+ // Re-read symbol to get updated position and content for chained edits
138
+ const updated = await findSymbol(resolvedPath, symbol, cwd || process.cwd());
139
+ if (updated) {
140
+ fileTracker.trackSymbolAfterWrite(resolvedPath, symbol, updated.code, updated.startLine, updated.endLine);
141
+ }
142
+ fileTracker.markFileSeen(resolvedPath);
143
+ }
144
+
145
+ if (debug) {
146
+ console.error(`[Edit] Successfully replaced symbol "${symbol}" in ${resolvedPath} (lines ${symbolInfo.startLine}-${symbolInfo.endLine})`);
147
+ }
148
+
149
+ return `Successfully replaced symbol "${symbol}" in ${file_path} (was lines ${symbolInfo.startLine}-${symbolInfo.endLine}, now ${newLines.length} lines)`;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Build a response message for line-targeted edits with context lines and hashes.
155
+ * @param {string} file_path - Display path
156
+ * @param {number} startLine - 1-indexed start line (original)
157
+ * @param {number} endLine - 1-indexed end line (original)
158
+ * @param {number} newLineCount - Number of lines in replacement
159
+ * @param {string[]} updatedLines - All file lines after edit
160
+ * @param {number} insertOffset - Where new content starts (0-indexed in updatedLines)
161
+ * @param {string} action - Description of what happened
162
+ * @param {string[]} heuristicMods - Heuristic modifications applied
163
+ * @returns {string} Formatted response
164
+ */
165
+ function buildLineEditResponse(file_path, startLine, endLine, newLineCount, updatedLines, insertOffset, action, heuristicMods) {
166
+ const contextBefore = 1;
167
+ const contextAfter = 1;
168
+
169
+ const contextStart = Math.max(0, insertOffset - contextBefore);
170
+ const contextEnd = Math.min(updatedLines.length, insertOffset + newLineCount + contextAfter);
171
+
172
+ let context = 'Context:\n';
173
+ for (let i = contextStart; i < contextEnd; i++) {
174
+ const lineNum = i + 1;
175
+ const hash = computeLineHash(updatedLines[i]);
176
+ const isNew = i >= insertOffset && i < insertOffset + newLineCount;
177
+ const marker = isNew ? '>' : ' ';
178
+ context += `${marker} ${lineNum}:${hash} | ${updatedLines[i]}\n`;
179
+ }
180
+
181
+ let msg = `Successfully edited ${file_path} (${action})`;
182
+ if (heuristicMods.length > 0) {
183
+ msg += ` [auto-corrected: ${heuristicMods.join(', ')}]`;
184
+ }
185
+ msg += '\n' + context;
186
+ return msg;
187
+ }
188
+
189
+ /**
190
+ * Handle line-targeted editing (replace, insert, delete by line numbers)
191
+ * @param {Object} params - Parameters
192
+ * @returns {Promise<string>} Result message
193
+ */
194
+ async function handleLineEdit({ resolvedPath, file_path, start_line, end_line, new_string, position, debug, fileTracker }) {
195
+ // Parse start_line reference
196
+ const startRef = parseLineRef(start_line);
197
+ if (!startRef) {
198
+ return `Error editing file: Invalid start_line '${start_line}'. Use a line number (e.g. "42") or line:hash (e.g. "42:ab"). Line numbers are 1-indexed.`;
199
+ }
200
+
201
+ // Parse optional end_line reference
202
+ let endRef = null;
203
+ if (end_line !== undefined && end_line !== null) {
204
+ endRef = parseLineRef(end_line);
205
+ if (!endRef) {
206
+ return `Error editing file: Invalid end_line '${end_line}'. Use a line number (e.g. "55") or line:hash (e.g. "55:cd"). Must be >= start_line.`;
207
+ }
208
+ }
209
+
210
+ const startLine = startRef.line;
211
+ const endLine = endRef ? endRef.line : startLine;
212
+
213
+ if (endLine < startLine) {
214
+ return `Error editing file: end_line (${endLine}) must be >= start_line (${startLine}).`;
215
+ }
216
+
217
+ // Validate position if provided
218
+ if (position !== undefined && position !== null && position !== 'before' && position !== 'after') {
219
+ return 'Error editing file: Invalid position - must be "before" or "after". Use position="before" to insert before the line, or position="after" to insert after it.';
220
+ }
221
+
222
+ // Read the file
223
+ const content = await fs.readFile(resolvedPath, 'utf-8');
224
+ const fileLines = content.split('\n');
225
+
226
+ // Validate line numbers in range
227
+ if (startLine > fileLines.length) {
228
+ return `Error editing file: Line ${startLine} is beyond file length (${fileLines.length} lines). Use 'extract' to read the current file content.`;
229
+ }
230
+ if (endLine > fileLines.length) {
231
+ return `Error editing file: Line ${endLine} is beyond file length (${fileLines.length} lines). Use 'extract' to read the current file content.`;
232
+ }
233
+
234
+ // Validate hashes if present
235
+ if (startRef.hash) {
236
+ const validation = validateLineHash(startLine, startRef.hash, fileLines);
237
+ if (!validation.valid) {
238
+ return `Error editing file: Line ${startLine} has changed since last read. Expected hash '${startRef.hash}' but content is now: ${startLine}:${validation.actualHash} | ${validation.actualContent}. Use '${startLine}:${validation.actualHash}' instead.`;
239
+ }
240
+ }
241
+ if (endRef && endRef.hash) {
242
+ const validation = validateLineHash(endLine, endRef.hash, fileLines);
243
+ if (!validation.valid) {
244
+ return `Error editing file: Line ${endLine} has changed since last read. Expected hash '${endRef.hash}' but content is now: ${endLine}:${validation.actualHash} | ${validation.actualContent}. Use '${endLine}:${validation.actualHash}' instead.`;
245
+ }
246
+ }
247
+
248
+ // Run heuristic cleaning
249
+ const { cleaned, modifications } = cleanNewString(new_string, fileLines, startLine, endLine, position);
250
+
251
+ if (debug) {
252
+ if (modifications.length > 0) {
253
+ console.error(`[Edit] Heuristic corrections: ${modifications.join(', ')}`);
254
+ }
255
+ }
256
+
257
+ // Apply the edit
258
+ const newLines = cleaned === '' ? [] : cleaned.split('\n');
259
+
260
+ if (position === 'after') {
261
+ // Insert after the anchor line
262
+ fileLines.splice(startLine, 0, ...newLines);
263
+ await fs.writeFile(resolvedPath, fileLines.join('\n'), 'utf-8');
264
+ if (fileTracker) await fileTracker.trackFileAfterWrite(resolvedPath);
265
+ const action = `${newLines.length} line${newLines.length !== 1 ? 's' : ''} inserted after line ${startLine}`;
266
+ return buildLineEditResponse(file_path, startLine, startLine, newLines.length, fileLines, startLine, action, modifications);
267
+ } else if (position === 'before') {
268
+ // Insert before the anchor line
269
+ fileLines.splice(startLine - 1, 0, ...newLines);
270
+ await fs.writeFile(resolvedPath, fileLines.join('\n'), 'utf-8');
271
+ if (fileTracker) await fileTracker.trackFileAfterWrite(resolvedPath);
272
+ const action = `${newLines.length} line${newLines.length !== 1 ? 's' : ''} inserted before line ${startLine}`;
273
+ return buildLineEditResponse(file_path, startLine, startLine, newLines.length, fileLines, startLine - 1, action, modifications);
274
+ } else {
275
+ // Replace mode: replace lines startLine through endLine (inclusive)
276
+ const replacedCount = endLine - startLine + 1;
277
+ fileLines.splice(startLine - 1, replacedCount, ...newLines);
278
+ await fs.writeFile(resolvedPath, fileLines.join('\n'), 'utf-8');
279
+ if (fileTracker) await fileTracker.trackFileAfterWrite(resolvedPath);
280
+
281
+ let action;
282
+ if (newLines.length === 0) {
283
+ action = `${replacedCount} line${replacedCount !== 1 ? 's' : ''} deleted (lines ${startLine}-${endLine})`;
284
+ } else if (startLine === endLine) {
285
+ action = `line ${startLine} replaced with ${newLines.length} line${newLines.length !== 1 ? 's' : ''}`;
286
+ } else {
287
+ action = `lines ${startLine}-${endLine} replaced with ${newLines.length} line${newLines.length !== 1 ? 's' : ''}`;
288
+ }
289
+ return buildLineEditResponse(file_path, startLine, endLine, newLines.length, fileLines, startLine - 1, action, modifications);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Edit tool generator - supports text replacement and AST-aware symbol editing
56
295
  *
57
296
  * @param {Object} [options] - Configuration options
58
297
  * @param {boolean} [options.debug=false] - Enable debug logging
@@ -65,20 +304,23 @@ export const editTool = (options = {}) => {
65
304
 
66
305
  return tool({
67
306
  name: 'edit',
68
- description: `Edit files using exact string replacement (Claude Code style).
307
+ description: `Edit files using text replacement, AST-aware symbol operations, or line-targeted editing.
69
308
 
70
- This tool performs exact string replacements in files. It requires the old_string to match exactly what's in the file, including all whitespace and indentation.
309
+ Modes:
310
+ 1. Text edit: Provide old_string + new_string to find and replace text (with fuzzy matching fallback)
311
+ 2. Symbol replace: Provide symbol + new_string to replace an entire function/class/method by name
312
+ 3. Symbol insert: Provide symbol + new_string + position to insert code before/after a symbol
313
+ 4. Line-targeted edit: Provide start_line + new_string to edit by line number (from extract/search output)
71
314
 
72
315
  Parameters:
73
316
  - file_path: Path to the file to edit (absolute or relative)
74
- - old_string: Exact text to find and replace (must be unique in the file unless replace_all is true)
75
- - new_string: Text to replace with
76
- - replace_all: (optional) Replace all occurrences instead of requiring uniqueness
77
-
78
- Important:
79
- - The old_string must match EXACTLY including whitespace
80
- - If old_string appears multiple times and replace_all is false, the edit will fail
81
- - Use larger context around the string to ensure uniqueness when needed`,
317
+ - new_string: Replacement text or new code content
318
+ - old_string: (optional) Text to find and replace. If omitted, symbol or start_line must be provided.
319
+ - replace_all: (optional) Replace all occurrences (text mode only)
320
+ - symbol: (optional) Symbol name for AST-aware editing (e.g. "myFunction", "MyClass.myMethod")
321
+ - position: (optional) "before" or "after" — insert code near a symbol or line instead of replacing it
322
+ - start_line: (optional) Line reference (e.g. "42" or "42:ab") for line-targeted editing
323
+ - end_line: (optional) End of line range, inclusive (e.g. "55" or "55:cd")`,
82
324
 
83
325
  inputSchema: {
84
326
  type: 'object',
@@ -89,32 +331,46 @@ Important:
89
331
  },
90
332
  old_string: {
91
333
  type: 'string',
92
- description: 'Exact text to find and replace'
334
+ description: 'Text to find and replace (for text-based editing)'
93
335
  },
94
336
  new_string: {
95
337
  type: 'string',
96
- description: 'Text to replace with'
338
+ description: 'Replacement text or new code content'
97
339
  },
98
340
  replace_all: {
99
341
  type: 'boolean',
100
- description: 'Replace all occurrences (default: false)',
342
+ description: 'Replace all occurrences (default: false, text mode only)',
101
343
  default: false
344
+ },
345
+ symbol: {
346
+ type: 'string',
347
+ description: 'Symbol name for AST-aware editing (e.g. "myFunction", "MyClass.myMethod")'
348
+ },
349
+ position: {
350
+ type: 'string',
351
+ enum: ['before', 'after'],
352
+ description: 'Insert before/after symbol or line (requires symbol or start_line, omit to replace)'
353
+ },
354
+ start_line: {
355
+ type: 'string',
356
+ description: 'Line reference for line-targeted editing (e.g. "42" or "42:ab" with hash)'
357
+ },
358
+ end_line: {
359
+ type: 'string',
360
+ description: 'End of line range, inclusive (e.g. "55" or "55:cd"). Defaults to start_line.'
102
361
  }
103
362
  },
104
- required: ['file_path', 'old_string', 'new_string']
363
+ required: ['file_path', 'new_string']
105
364
  },
106
365
 
107
- execute: async ({ file_path, old_string, new_string, replace_all = false }) => {
366
+ execute: async ({ file_path, old_string, new_string, replace_all = false, symbol, position, start_line, end_line }) => {
108
367
  try {
109
368
  // Validate input parameters
110
369
  if (!file_path || typeof file_path !== 'string' || file_path.trim() === '') {
111
- return `Error editing file: Invalid file_path - must be a non-empty string`;
112
- }
113
- if (old_string === undefined || old_string === null || typeof old_string !== 'string') {
114
- return `Error editing file: Invalid old_string - must be a string`;
370
+ return `Error editing file: Invalid file_path - must be a non-empty string. Provide an absolute path or a path relative to the working directory (e.g. "src/main.js").`;
115
371
  }
116
372
  if (new_string === undefined || new_string === null || typeof new_string !== 'string') {
117
- return `Error editing file: Invalid new_string - must be a string`;
373
+ return `Error editing file: Invalid new_string - must be a string. Provide the replacement content as a string value (empty string "" is valid for deletions).`;
118
374
  }
119
375
 
120
376
  // Resolve the file path
@@ -127,45 +383,86 @@ Important:
127
383
  // Check if path is allowed
128
384
  if (!isPathAllowed(resolvedPath, allowedFolders)) {
129
385
  const relativePath = toRelativePath(resolvedPath, workspaceRoot);
130
- return `Error editing file: Permission denied - ${relativePath} is outside allowed directories`;
386
+ return `Error editing file: Permission denied - ${relativePath} is outside allowed directories. Use a file path within the project workspace.`;
131
387
  }
132
388
 
133
389
  // Check if file exists
134
390
  if (!existsSync(resolvedPath)) {
135
- return `Error editing file: File not found - ${file_path}`;
391
+ return `Error editing file: File not found - ${file_path}. Verify the path is correct and the file exists. Use 'search' to find files by name, or 'create' to make a new file.`;
392
+ }
393
+
394
+ // Check if file has been seen in this session (read-before-write guard)
395
+ if (options.fileTracker && !options.fileTracker.isFileSeen(resolvedPath)) {
396
+ const displayPath = toRelativePath(resolvedPath, workspaceRoot);
397
+ return `Error editing ${displayPath}: This file has not been read yet in this session. Use 'extract' to read the file first, then retry your edit. This ensures you are working with the current file content.\n\nExample: <extract><targets>${displayPath}</targets></extract>`;
398
+ }
399
+
400
+ // Route to appropriate mode (priority: symbol > start_line > old_string)
401
+ if (symbol !== undefined && symbol !== null) {
402
+ // AST-aware symbol mode (includes empty string which handleSymbolEdit validates)
403
+ return await handleSymbolEdit({ resolvedPath, file_path, symbol, new_string, position, debug, cwd, fileTracker: options.fileTracker });
404
+ }
405
+
406
+ if (start_line !== undefined && start_line !== null) {
407
+ // Line-targeted mode
408
+ return await handleLineEdit({ resolvedPath, file_path, start_line, end_line, new_string, position, debug, fileTracker: options.fileTracker });
409
+ }
410
+
411
+ if (old_string === undefined || old_string === null) {
412
+ return 'Error editing file: Must provide either old_string (for text edit), symbol (for AST-aware edit), or start_line (for line-targeted edit). For text editing: set old_string to the exact text to find and new_string to its replacement. For symbol editing: set symbol to a function/class/method name (e.g. "myFunction"). For line-targeted editing: set start_line to a line number from extract/search output (e.g. "42" or "42:ab").';
136
413
  }
137
414
 
415
+ // Validate old_string for text mode
416
+ if (typeof old_string !== 'string') {
417
+ return `Error editing file: Invalid old_string - must be a string. Provide the exact text to find in the file, or use the symbol parameter instead for AST-aware editing by name.`;
418
+ }
419
+
420
+ // ─── Text-based edit mode ───
421
+
138
422
  // Read the file
139
423
  const content = await fs.readFile(resolvedPath, 'utf-8');
140
424
 
141
- // Check if old_string exists in the file
425
+ // Try exact match first, fall back to fuzzy matching
426
+ let matchTarget = old_string;
427
+ let matchStrategy = 'exact';
428
+
142
429
  if (!content.includes(old_string)) {
143
- return `Error editing file: String not found - the specified old_string was not found in ${file_path}`;
430
+ // Exact match failed try progressive fuzzy matching
431
+ const fuzzy = findFuzzyMatch(content, old_string);
432
+ if (!fuzzy) {
433
+ return `Error editing file: String not found - the specified old_string was not found in ${file_path}. The text may have changed or differ from what you expected. Try: (1) Use 'search' or 'extract' to read the current file content and copy the exact text. (2) Use the symbol parameter to edit by function/class name instead. (3) Verify the file_path is correct.`;
434
+ }
435
+ matchTarget = fuzzy.matchedText;
436
+ matchStrategy = fuzzy.strategy;
437
+ if (debug) {
438
+ console.error(`[Edit] Exact match failed, used ${matchStrategy} matching`);
439
+ }
144
440
  }
145
441
 
146
- // Count occurrences
147
- const occurrences = content.split(old_string).length - 1;
442
+ // Count occurrences of the matched text
443
+ const occurrences = content.split(matchTarget).length - 1;
148
444
 
149
445
  // Check uniqueness if not replacing all
150
446
  if (!replace_all && occurrences > 1) {
151
- return `Error editing file: Multiple occurrences found - the old_string appears ${occurrences} times. Use replace_all: true to replace all occurrences, or provide more context to make the string unique.`;
447
+ return `Error editing file: Multiple occurrences found - the old_string appears ${occurrences} times in ${file_path}. To fix: (1) Set replace_all=true to replace all occurrences, or (2) Include more surrounding lines in old_string to make the match unique (add the full line or adjacent lines for context).`;
152
448
  }
153
449
 
154
450
  // Perform the replacement
155
451
  let newContent;
156
452
  if (replace_all) {
157
- newContent = content.replaceAll(old_string, new_string);
453
+ newContent = content.replaceAll(matchTarget, new_string);
158
454
  } else {
159
- newContent = content.replace(old_string, new_string);
455
+ newContent = content.replace(matchTarget, new_string);
160
456
  }
161
457
 
162
458
  // Check if replacement was made
163
459
  if (newContent === content) {
164
- return `Error editing file: No changes made - old_string and new_string might be the same`;
460
+ return `Error editing file: No changes made - the replacement result is identical to the original. Verify that old_string and new_string are actually different. If fuzzy matching was used, the matched text may already equal new_string.`;
165
461
  }
166
462
 
167
463
  // Write the file back
168
464
  await fs.writeFile(resolvedPath, newContent, 'utf-8');
465
+ if (options.fileTracker) await options.fileTracker.trackFileAfterWrite(resolvedPath);
169
466
 
170
467
  const replacedCount = replace_all ? occurrences : 1;
171
468
 
@@ -174,7 +471,8 @@ Important:
174
471
  }
175
472
 
176
473
  // Return success message as a string (matching other tools pattern)
177
- return `Successfully edited ${file_path} (${replacedCount} replacement${replacedCount !== 1 ? 's' : ''})`;
474
+ const strategyNote = matchStrategy !== 'exact' ? `, matched via ${matchStrategy}` : '';
475
+ return `Successfully edited ${file_path} (${replacedCount} replacement${replacedCount !== 1 ? 's' : ''}${strategyNote})`;
178
476
 
179
477
  } catch (error) {
180
478
  console.error('[Edit] Error:', error);
@@ -236,10 +534,10 @@ Important:
236
534
  try {
237
535
  // Validate input parameters
238
536
  if (!file_path || typeof file_path !== 'string' || file_path.trim() === '') {
239
- return `Error creating file: Invalid file_path - must be a non-empty string`;
537
+ return `Error creating file: Invalid file_path - must be a non-empty string. Provide an absolute path or a path relative to the working directory (e.g. "src/newFile.js").`;
240
538
  }
241
539
  if (content === undefined || content === null || typeof content !== 'string') {
242
- return `Error creating file: Invalid content - must be a string`;
540
+ return `Error creating file: Invalid content - must be a string. Provide the file content as a string value (empty string "" is valid for an empty file).`;
243
541
  }
244
542
 
245
543
  // Resolve the file path
@@ -252,7 +550,7 @@ Important:
252
550
  // Check if path is allowed
253
551
  if (!isPathAllowed(resolvedPath, allowedFolders)) {
254
552
  const relativePath = toRelativePath(resolvedPath, workspaceRoot);
255
- return `Error creating file: Permission denied - ${relativePath} is outside allowed directories`;
553
+ return `Error creating file: Permission denied - ${relativePath} is outside allowed directories. Use a file path within the project workspace.`;
256
554
  }
257
555
 
258
556
  // Check if file exists
@@ -260,14 +558,18 @@ Important:
260
558
  return `Error creating file: File already exists - ${file_path}. Use overwrite: true to replace it.`;
261
559
  }
262
560
 
561
+ // Check if file existed before write
562
+ const existed = existsSync(resolvedPath);
563
+
263
564
  // Ensure parent directory exists
264
565
  const dir = dirname(resolvedPath);
265
566
  await fs.mkdir(dir, { recursive: true });
266
567
 
267
568
  // Write the file
268
569
  await fs.writeFile(resolvedPath, content, 'utf-8');
570
+ if (options.fileTracker) await options.fileTracker.trackFileAfterWrite(resolvedPath);
269
571
 
270
- const action = existsSync(resolvedPath) && overwrite ? 'overwrote' : 'created';
572
+ const action = existed && overwrite ? 'overwrote' : 'created';
271
573
  const bytes = Buffer.byteLength(content, 'utf-8');
272
574
 
273
575
  if (debug) {
@@ -295,18 +597,35 @@ export const editSchema = {
295
597
  },
296
598
  old_string: {
297
599
  type: 'string',
298
- description: 'Exact text to find and replace'
600
+ description: 'Text to find and replace (for text-based editing)'
299
601
  },
300
602
  new_string: {
301
603
  type: 'string',
302
- description: 'Text to replace with'
604
+ description: 'Replacement text or new code content'
303
605
  },
304
606
  replace_all: {
305
607
  type: 'boolean',
306
- description: 'Replace all occurrences (default: false)'
608
+ description: 'Replace all occurrences (default: false, text mode only)'
609
+ },
610
+ symbol: {
611
+ type: 'string',
612
+ description: 'Symbol name for AST-aware editing (e.g. "myFunction", "MyClass.myMethod")'
613
+ },
614
+ position: {
615
+ type: 'string',
616
+ enum: ['before', 'after'],
617
+ description: 'Insert before/after symbol or line (requires symbol or start_line, omit to replace)'
618
+ },
619
+ start_line: {
620
+ type: 'string',
621
+ description: 'Line reference for line-targeted editing (e.g. "42" or "42:ab" with hash)'
622
+ },
623
+ end_line: {
624
+ type: 'string',
625
+ description: 'End of line range, inclusive (e.g. "55" or "55:cd"). Defaults to start_line.'
307
626
  }
308
627
  },
309
- required: ['file_path', 'old_string', 'new_string']
628
+ required: ['file_path', 'new_string']
310
629
  };
311
630
 
312
631
  export const createSchema = {
@@ -329,7 +648,7 @@ export const createSchema = {
329
648
  };
330
649
 
331
650
  // Tool descriptions for XML definitions
332
- export const editDescription = 'Edit files using exact string replacement. Requires exact match including whitespace.';
651
+ export const editDescription = 'Edit files using text replacement, AST-aware symbol operations, or line-targeted editing. Supports fuzzy matching for text edits and optional hash-based integrity verification for line edits.';
333
652
  export const createDescription = 'Create new files with specified content. Will create parent directories if needed.';
334
653
 
335
654
  // XML tool definitions
@@ -337,44 +656,117 @@ export const editToolDefinition = `
337
656
  ## edit
338
657
  Description: ${editDescription}
339
658
 
340
- When to use:
341
- - For precise, surgical edits to existing files
342
- - When you need to change specific lines or blocks of code
343
- - For renaming functions, variables, or updating configuration values
344
- - When the exact text to replace is known and unique (or use replace_all for multiple occurrences)
659
+ Four editing modes — choose based on the scope of your change:
345
660
 
346
- When NOT to use:
347
- - For creating new files (use 'create' tool instead)
348
- - When you cannot determine the exact text to replace
349
- - When changes span multiple locations that would be better handled together
661
+ 1. **Text edit** (old_string + new_string): For small, precise changes — fix a condition, rename a variable, update a value. Provide old_string copied verbatim from the file and new_string with the replacement. Fuzzy matching handles minor whitespace/indentation differences automatically, but always try to copy the exact text.
662
+
663
+ 2. **Symbol replace** (symbol + new_string): For replacing an entire function, class, or method by name. No need to quote the old code — just provide the symbol name and the full new implementation. Indentation is automatically adjusted to match the original. Prefer this mode when rewriting whole definitions.
664
+
665
+ 3. **Symbol insert** (symbol + new_string + position): For adding new code before or after an existing symbol. Set position to "before" or "after".
666
+
667
+ 4. **Line-targeted edit** (start_line + new_string): For precise edits using line numbers from extract/search output. Use start_line with a line number (e.g. "42") or line:hash (e.g. "42:ab") for integrity verification. Add end_line for multi-line ranges. Use position="before" or "after" to insert instead of replace.
350
668
 
351
669
  Parameters:
352
670
  - file_path: (required) Path to the file to edit
353
- - old_string: (required) Exact text to find and replace (must match including whitespace, newlines, and indentation)
354
- - new_string: (required) Text to replace with
355
- - replace_all: (optional, default: false) Replace all occurrences if the string appears multiple times
356
-
357
- Important notes:
358
- - The old_string MUST match EXACTLY, including all whitespace, indentation, and line breaks
359
- - If old_string appears multiple times and replace_all is false, the tool will fail
360
- - Always verify the exact formatting of the text you want to replace
671
+ - new_string: (required) Replacement text or new code content
672
+ - old_string: (optional) Text to find and replace — copy verbatim from the file, do not paraphrase or reformat
673
+ - replace_all: (optional, default: false) Replace all occurrences of old_string (text mode only)
674
+ - symbol: (optional) Name of a code symbol (e.g. "myFunction", "MyClass.myMethod") — must match a function, class, or method definition
675
+ - position: (optional) "before" or "after" — insert new_string near the symbol or line instead of replacing it
676
+ - start_line: (optional) Line reference for line-targeted editing (e.g. "42" or "42:ab")
677
+ - end_line: (optional) End of line range, inclusive (e.g. "55" or "55:cd"). Defaults to start_line.
678
+
679
+ Mode selection rules (priority order):
680
+ - If symbol is provided, symbol mode is used (old_string and start_line are ignored)
681
+ - If start_line is provided (without symbol), line-targeted mode is used
682
+ - If old_string is provided (without symbol or start_line), text mode is used
683
+ - If none are provided, the tool returns an error with guidance
684
+
685
+ When to use each mode:
686
+ - Small edits (a line or a few lines): use text mode with old_string
687
+ - Replacing entire functions/classes/methods: use symbol mode — no exact text matching needed
688
+ - Editing specific lines from extract/search output: use line-targeted mode with start_line
689
+ - Editing inside large functions without rewriting them entirely: first use extract with the symbol target (e.g. "file.js#myFunction") to see the function with line numbers, then use start_line/end_line to edit specific lines within it
690
+
691
+ Error handling:
692
+ - If an edit fails, read the error message carefully — it contains specific instructions for how to fix the call and retry
693
+ - Common fixes: use 'search'/'extract' to get exact file content, add more context to old_string, switch between text and symbol modes
694
+ - Line-targeted hash mismatch: the file changed since last read; the error provides updated line:hash references
361
695
 
362
696
  Examples:
697
+
698
+ Text edit (find and replace):
363
699
  <edit>
364
700
  <file_path>src/main.js</file_path>
365
- <old_string>function oldName() {
366
- return 42;
367
- }</old_string>
368
- <new_string>function newName() {
369
- return 42;
370
- }</new_string>
701
+ <old_string>return false;</old_string>
702
+ <new_string>return true;</new_string>
371
703
  </edit>
372
704
 
705
+ Text edit with replace_all:
373
706
  <edit>
374
707
  <file_path>config.json</file_path>
375
708
  <old_string>"debug": false</old_string>
376
709
  <new_string>"debug": true</new_string>
377
710
  <replace_all>true</replace_all>
711
+ </edit>
712
+
713
+ Symbol replace (rewrite entire function by name):
714
+ <edit>
715
+ <file_path>src/utils.js</file_path>
716
+ <symbol>calculateTotal</symbol>
717
+ <new_string>function calculateTotal(items) {
718
+ return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
719
+ }</new_string>
720
+ </edit>
721
+
722
+ Symbol insert (add new function after existing one):
723
+ <edit>
724
+ <file_path>src/utils.js</file_path>
725
+ <symbol>calculateTotal</symbol>
726
+ <position>after</position>
727
+ <new_string>function calculateTax(total, rate) {
728
+ return total * rate;
729
+ }</new_string>
730
+ </edit>
731
+
732
+ Line-targeted edit (replace a line):
733
+ <edit>
734
+ <file_path>src/main.js</file_path>
735
+ <start_line>42</start_line>
736
+ <new_string> return processItems(order.items);</new_string>
737
+ </edit>
738
+
739
+ Line-targeted edit (replace a range of lines):
740
+ <edit>
741
+ <file_path>src/main.js</file_path>
742
+ <start_line>42</start_line>
743
+ <end_line>55</end_line>
744
+ <new_string> // simplified implementation
745
+ return processItems(order.items);</new_string>
746
+ </edit>
747
+
748
+ Line-targeted edit with hash verification:
749
+ <edit>
750
+ <file_path>src/main.js</file_path>
751
+ <start_line>42:ab</start_line>
752
+ <end_line>55:cd</end_line>
753
+ <new_string> return processItems(order.items);</new_string>
754
+ </edit>
755
+
756
+ Line-targeted insert (add code after a line):
757
+ <edit>
758
+ <file_path>src/main.js</file_path>
759
+ <start_line>42</start_line>
760
+ <position>after</position>
761
+ <new_string> const validated = validate(input);</new_string>
762
+ </edit>
763
+
764
+ Line-targeted delete (remove lines):
765
+ <edit>
766
+ <file_path>src/main.js</file_path>
767
+ <start_line>42</start_line>
768
+ <end_line>45</end_line>
769
+ <new_string></new_string>
378
770
  </edit>`;
379
771
 
380
772
  export const createToolDefinition = `
@@ -415,4 +807,4 @@ Examples:
415
807
 
416
808
  This is a new project.</content>
417
809
  <overwrite>true</overwrite>
418
- </create>`;
810
+ </create>`;