@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.
- package/README.md +166 -3
- package/bin/binaries/probe-v0.6.0-rc255-aarch64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc255-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc255-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc255-x86_64-pc-windows-msvc.zip +0 -0
- package/bin/binaries/probe-v0.6.0-rc255-x86_64-unknown-linux-musl.tar.gz +0 -0
- package/build/agent/ProbeAgent.d.ts +1 -1
- package/build/agent/ProbeAgent.js +51 -16
- package/build/agent/acp/tools.js +2 -1
- package/build/agent/acp/tools.test.js +2 -1
- package/build/agent/dsl/environment.js +19 -0
- package/build/agent/index.js +1512 -413
- package/build/agent/schemaUtils.js +91 -2
- package/build/agent/tools.js +0 -28
- package/build/delegate.js +3 -0
- package/build/index.js +2 -0
- package/build/tools/common.js +6 -5
- package/build/tools/edit.js +457 -65
- package/build/tools/executePlan.js +3 -1
- package/build/tools/fileTracker.js +318 -0
- package/build/tools/fuzzyMatch.js +271 -0
- package/build/tools/hashline.js +131 -0
- package/build/tools/lineEditHeuristics.js +138 -0
- package/build/tools/symbolEdit.js +119 -0
- package/build/tools/vercel.js +40 -9
- package/cjs/agent/ProbeAgent.cjs +1615 -517
- package/cjs/index.cjs +1643 -543
- package/index.d.ts +189 -1
- package/package.json +1 -1
- package/src/agent/ProbeAgent.d.ts +1 -1
- package/src/agent/ProbeAgent.js +51 -16
- package/src/agent/acp/tools.js +2 -1
- package/src/agent/acp/tools.test.js +2 -1
- package/src/agent/dsl/environment.js +19 -0
- package/src/agent/index.js +14 -3
- package/src/agent/schemaUtils.js +91 -2
- package/src/agent/tools.js +0 -28
- package/src/delegate.js +3 -0
- package/src/index.js +2 -0
- package/src/tools/common.js +6 -5
- package/src/tools/edit.js +457 -65
- package/src/tools/executePlan.js +3 -1
- package/src/tools/fileTracker.js +318 -0
- package/src/tools/fuzzyMatch.js +271 -0
- package/src/tools/hashline.js +131 -0
- package/src/tools/lineEditHeuristics.js +138 -0
- package/src/tools/symbolEdit.js +119 -0
- package/src/tools/vercel.js +40 -9
- package/bin/binaries/probe-v0.6.0-rc253-aarch64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc253-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc253-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc253-x86_64-pc-windows-msvc.zip +0 -0
- package/bin/binaries/probe-v0.6.0-rc253-x86_64-unknown-linux-musl.tar.gz +0 -0
package/build/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
|
-
*
|
|
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
|
|
307
|
+
description: `Edit files using text replacement, AST-aware symbol operations, or line-targeted editing.
|
|
69
308
|
|
|
70
|
-
|
|
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
|
-
-
|
|
75
|
-
-
|
|
76
|
-
- replace_all: (optional) Replace all occurrences
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
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: '
|
|
334
|
+
description: 'Text to find and replace (for text-based editing)'
|
|
93
335
|
},
|
|
94
336
|
new_string: {
|
|
95
337
|
type: 'string',
|
|
96
|
-
description: '
|
|
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', '
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
453
|
+
newContent = content.replaceAll(matchTarget, new_string);
|
|
158
454
|
} else {
|
|
159
|
-
newContent = content.replace(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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: '
|
|
600
|
+
description: 'Text to find and replace (for text-based editing)'
|
|
299
601
|
},
|
|
300
602
|
new_string: {
|
|
301
603
|
type: 'string',
|
|
302
|
-
description: '
|
|
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', '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
-
|
|
354
|
-
-
|
|
355
|
-
- replace_all: (optional, default: false) Replace all occurrences
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
-
|
|
359
|
-
-
|
|
360
|
-
|
|
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>
|
|
366
|
-
|
|
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>`;
|