lonny-agent 0.2.4 → 0.2.6
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 +326 -326
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +13 -9
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +43 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +4 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +45 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +1 -1
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +875 -371
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +32 -0
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +83 -10
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +50 -9
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +328 -158
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +54 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/registry.d.ts +0 -6
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -29
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +26 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +395 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +996 -949
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +3 -7
- package/dist/web/session-bridge.js.map +1 -1
- package/package.json +55 -54
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/index.ts +25 -25
- package/src/agent/llm.ts +4 -0
- package/src/agent/prompt-builder.ts +13 -9
- package/src/agent/providers/anthropic.ts +179 -179
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -186
- package/src/agent/providers/openai.ts +61 -5
- package/src/agent/session.ts +59 -3
- package/src/config/index.ts +342 -335
- package/src/tools/__tests__/bash.test.ts +1 -1
- package/src/tools/__tests__/edit.test.ts +981 -385
- package/src/tools/__tests__/grep.test.ts +35 -0
- package/src/tools/bash.ts +97 -11
- package/src/tools/edit.ts +362 -169
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/registry.ts +2 -31
- package/src/tui/index.ts +786 -786
- package/src/web/index.ts +25 -3
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +395 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +996 -949
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +193 -194
- package/.claude/settings.local.json +0 -14
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- package/src/tools/exec.ts +0 -348
package/dist/tools/edit.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { DIFF_DELETE, DIFF_INSERT, diffLinesRaw } from 'jest-diff';
|
|
3
4
|
import { fmtErr } from './errors.js';
|
|
4
5
|
// ── ANSI colors for terminal output ───────────────────────────────────────
|
|
5
6
|
const DIFF_RED = '\x1b[38;2;255;80;80m';
|
|
6
7
|
const DIFF_GREEN = '\x1b[38;2;0;200;100m';
|
|
8
|
+
const DIFF_DIM = '\x1b[38;2;100;100;100m';
|
|
7
9
|
const DIFF_RESET = '\x1b[0m';
|
|
8
|
-
// ──
|
|
10
|
+
// ── Colors for HTML output ────────────────────────────────────────────────
|
|
9
11
|
const HTML_RED = '#ff5050';
|
|
10
12
|
const HTML_GREEN = '#00c864';
|
|
13
|
+
const HTML_DIM = '#888888';
|
|
11
14
|
/** Build diagnostic JSON for error messages */
|
|
12
15
|
function buildDiag(edit) {
|
|
13
16
|
return JSON.stringify({
|
|
@@ -16,31 +19,50 @@ function buildDiag(edit) {
|
|
|
16
19
|
new_string: edit.new_string,
|
|
17
20
|
});
|
|
18
21
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
22
|
+
/** Summarize raw input for error messages to avoid dumping huge strings into the LLM context. */
|
|
23
|
+
function summarizeRawInput(rawInput) {
|
|
24
|
+
const s = JSON.stringify(rawInput);
|
|
25
|
+
if (s.length <= 500)
|
|
26
|
+
return s;
|
|
27
|
+
return `${s.slice(0, 500)}... [truncated, total ${s.length} chars]`;
|
|
28
|
+
}
|
|
29
|
+
/** Compute diff lines using jest-diff for proper line-level diffs */
|
|
30
|
+
export function computeDiff(oldStr, newStr) {
|
|
21
31
|
const oldLines = oldStr === '' ? [] : oldStr.split('\n');
|
|
22
|
-
const newLines = newStr.split('\n');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const newLines = newStr === '' ? [] : newStr.split('\n');
|
|
33
|
+
if (oldLines.length === 0 && newLines.length === 0)
|
|
34
|
+
return [];
|
|
35
|
+
const rawDiff = diffLinesRaw(oldLines, newLines);
|
|
36
|
+
return rawDiff.map(d => ({
|
|
37
|
+
type: d[0] === DIFF_DELETE
|
|
38
|
+
? 'delete'
|
|
39
|
+
: d[0] === DIFF_INSERT
|
|
40
|
+
? 'insert'
|
|
41
|
+
: 'equal',
|
|
42
|
+
content: d[1],
|
|
43
|
+
}));
|
|
31
44
|
}
|
|
32
|
-
/** Terminal renderer */
|
|
33
|
-
export function renderDiffTerminal(lines) {
|
|
45
|
+
/** Terminal renderer — unified-diff format with color */
|
|
46
|
+
export function renderDiffTerminal(lines, startLineNum) {
|
|
34
47
|
if (lines.length === 0)
|
|
35
48
|
return '';
|
|
36
|
-
const maxLineNum = Math.max(...lines.map(l => l.lineNum));
|
|
37
|
-
const lineNumWidth = String(maxLineNum).length;
|
|
38
|
-
const fmtLineNum = (n) => String(n).padStart(lineNumWidth, ' ');
|
|
39
49
|
const output = [];
|
|
50
|
+
let oldLn = startLineNum ?? 1;
|
|
51
|
+
let newLn = startLineNum ?? 1;
|
|
40
52
|
for (const line of lines) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
if (line.type === 'delete') {
|
|
54
|
+
output.push(` ${DIFF_RED}- ${oldLn} ${line.content}${DIFF_RESET}`);
|
|
55
|
+
oldLn++;
|
|
56
|
+
}
|
|
57
|
+
else if (line.type === 'insert') {
|
|
58
|
+
output.push(` ${DIFF_GREEN}+ ${newLn} ${line.content}${DIFF_RESET}`);
|
|
59
|
+
newLn++;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
output.push(` ${DIFF_DIM} ${oldLn} ${line.content}${DIFF_RESET}`);
|
|
63
|
+
oldLn++;
|
|
64
|
+
newLn++;
|
|
65
|
+
}
|
|
44
66
|
}
|
|
45
67
|
return output.join('\n');
|
|
46
68
|
}
|
|
@@ -48,32 +70,94 @@ export function renderDiffTerminal(lines) {
|
|
|
48
70
|
export function renderDiffHtml(lines) {
|
|
49
71
|
if (lines.length === 0)
|
|
50
72
|
return '';
|
|
51
|
-
const maxLineNum = Math.max(...lines.map(l => l.lineNum));
|
|
52
|
-
const lineNumWidth = String(maxLineNum).length;
|
|
53
|
-
const fmtLineNum = (n) => String(n).padStart(lineNumWidth, ' ');
|
|
54
73
|
const output = [];
|
|
55
74
|
for (const line of lines) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
if (line.type === 'delete') {
|
|
76
|
+
output.push(` <span style="color: ${HTML_RED};">- ${escapeHtml(line.content)}</span>`);
|
|
77
|
+
}
|
|
78
|
+
else if (line.type === 'insert') {
|
|
79
|
+
output.push(` <span style="color: ${HTML_GREEN};">+ ${escapeHtml(line.content)}</span>`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
output.push(` <span style="color: ${HTML_DIM};"> ${escapeHtml(line.content)}</span>`);
|
|
83
|
+
}
|
|
59
84
|
}
|
|
60
85
|
return output.join('\n');
|
|
61
86
|
}
|
|
62
87
|
/** Escape HTML special characters */
|
|
63
|
-
function escapeHtml(str) {
|
|
88
|
+
export function escapeHtml(str) {
|
|
64
89
|
return str
|
|
65
90
|
.replace(/&/g, '&')
|
|
66
91
|
.replace(/</g, '<')
|
|
67
92
|
.replace(/>/g, '>')
|
|
68
|
-
.replace(/"/g, '"')
|
|
93
|
+
.replace(/"/g, '"')
|
|
94
|
+
.replace(/'/g, ''');
|
|
69
95
|
}
|
|
70
96
|
/**
|
|
71
|
-
* Generate diff output
|
|
72
|
-
*
|
|
97
|
+
* Generate diff output using jest-diff.
|
|
98
|
+
* Returns terminal-colored unified-diff format with line numbers.
|
|
73
99
|
*/
|
|
74
|
-
export function generateDiff(oldStr, newStr,
|
|
75
|
-
const lines = computeDiff(oldStr, newStr
|
|
76
|
-
return renderDiffTerminal(lines);
|
|
100
|
+
export function generateDiff(oldStr, newStr, startLineNum) {
|
|
101
|
+
const lines = computeDiff(oldStr, newStr);
|
|
102
|
+
return renderDiffTerminal(lines, startLineNum);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Generate diff with surrounding context lines (1 before, 1 after)
|
|
106
|
+
* and line numbers. Context lines are extracted from the full file content.
|
|
107
|
+
*/
|
|
108
|
+
export function generateDiffWithContext(fullContent, oldStr, newStr, matchIndex, matchLength) {
|
|
109
|
+
// Calculate 1-based line number of the first matched line
|
|
110
|
+
const firstLineNum = fullContent.slice(0, matchIndex).split('\n').length;
|
|
111
|
+
const contentLines = fullContent.split('\n');
|
|
112
|
+
// Find 0-based line index where matchIndex falls
|
|
113
|
+
let charPos = 0;
|
|
114
|
+
let matchStartLineIdx = 0;
|
|
115
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
116
|
+
if (charPos <= matchIndex && matchIndex < charPos + contentLines[i].length + 1) {
|
|
117
|
+
matchStartLineIdx = i;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
charPos += contentLines[i].length + 1; // +1 for \n
|
|
121
|
+
}
|
|
122
|
+
// How many lines does oldStr span in the original content?
|
|
123
|
+
const oldLines = oldStr === '' ? [] : oldStr.split('\n');
|
|
124
|
+
const oldLineCount = oldLines.length;
|
|
125
|
+
const matchEndLineIdx = matchStartLineIdx + oldLineCount - 1;
|
|
126
|
+
// Context before (1 line) and after (1 line)
|
|
127
|
+
const beforeLineIdx = matchStartLineIdx > 0 ? matchStartLineIdx - 1 : -1;
|
|
128
|
+
const afterLineIdx = matchEndLineIdx < contentLines.length - 1 ? matchEndLineIdx + 1 : -1;
|
|
129
|
+
// Compute diff between oldStr and newStr
|
|
130
|
+
const diffLines = computeDiff(oldStr, newStr);
|
|
131
|
+
const output = [];
|
|
132
|
+
// Context before
|
|
133
|
+
if (beforeLineIdx !== -1) {
|
|
134
|
+
const lineNum = beforeLineIdx + 1;
|
|
135
|
+
output.push(` ${DIFF_DIM} ${lineNum} ${contentLines[beforeLineIdx]}${DIFF_RESET}`);
|
|
136
|
+
}
|
|
137
|
+
// Diff lines with line numbers
|
|
138
|
+
let oldLn = firstLineNum;
|
|
139
|
+
let newLn = firstLineNum;
|
|
140
|
+
for (const line of diffLines) {
|
|
141
|
+
if (line.type === 'delete') {
|
|
142
|
+
output.push(` ${DIFF_RED}- ${oldLn} ${line.content}${DIFF_RESET}`);
|
|
143
|
+
oldLn++;
|
|
144
|
+
}
|
|
145
|
+
else if (line.type === 'insert') {
|
|
146
|
+
output.push(` ${DIFF_GREEN}+ ${newLn} ${line.content}${DIFF_RESET}`);
|
|
147
|
+
newLn++;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
output.push(` ${DIFF_DIM} ${oldLn} ${line.content}${DIFF_RESET}`);
|
|
151
|
+
oldLn++;
|
|
152
|
+
newLn++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Context after
|
|
156
|
+
if (afterLineIdx !== -1) {
|
|
157
|
+
const lineNum = afterLineIdx + 1;
|
|
158
|
+
output.push(` ${DIFF_DIM} ${lineNum} ${contentLines[afterLineIdx]}${DIFF_RESET}`);
|
|
159
|
+
}
|
|
160
|
+
return output.join('\n');
|
|
77
161
|
}
|
|
78
162
|
/**
|
|
79
163
|
* Normalize a line for whitespace-tolerant comparison:
|
|
@@ -85,7 +169,8 @@ export function generateDiff(oldStr, newStr, startLine = 0) {
|
|
|
85
169
|
* - Extra internal spaces → `"foo bar"` → `"foo bar"`
|
|
86
170
|
* - Blank lines with spaces → `" "` → `""`
|
|
87
171
|
*/
|
|
88
|
-
|
|
172
|
+
/** Export for testing */
|
|
173
|
+
export function normalizeLine(s) {
|
|
89
174
|
return s.trim().replace(/[ \t]{2,}/g, ' ');
|
|
90
175
|
}
|
|
91
176
|
/**
|
|
@@ -96,7 +181,8 @@ function normalizeLine(s) {
|
|
|
96
181
|
* so the caller can do content.slice(match.index, match.index + match.length)
|
|
97
182
|
* to extract the actual matched text (with its original whitespace).
|
|
98
183
|
*/
|
|
99
|
-
|
|
184
|
+
/** Export for testing */
|
|
185
|
+
export function findAllLinesTolerant(content, oldString) {
|
|
100
186
|
if (oldString === '')
|
|
101
187
|
return [];
|
|
102
188
|
const contentLines = content.split('\n');
|
|
@@ -133,142 +219,220 @@ function findAllLinesTolerant(content, oldString) {
|
|
|
133
219
|
}
|
|
134
220
|
return matches;
|
|
135
221
|
}
|
|
222
|
+
/** Export for testing */
|
|
223
|
+
export function parseMarkdownEdit(content) {
|
|
224
|
+
const edits = [];
|
|
225
|
+
function parseEditBlock(raw) {
|
|
226
|
+
// Remove stray ``` lines (model may close the block early before new:)
|
|
227
|
+
const cleaned = raw.replace(/^```\s*$/gm, '');
|
|
228
|
+
const fileMatch = cleaned.match(/^file:\s*(.+)$/m);
|
|
229
|
+
if (!fileMatch)
|
|
230
|
+
return null;
|
|
231
|
+
const filePath = fileMatch[1].trim();
|
|
232
|
+
let oldString = '';
|
|
233
|
+
let newString = '';
|
|
234
|
+
const oldMatch = cleaned.match(/^old:(?:\s*\|\d*\s*\n)?([\s\S]*?)^new:/m);
|
|
235
|
+
const newMatch = cleaned.match(/^new:(?:\s*\|\d*\s*\n)?([\s\S]*)$/m);
|
|
236
|
+
if (oldMatch) {
|
|
237
|
+
oldString = oldMatch[1].replace(/^\n+/, '').replace(/\n+$/, '');
|
|
238
|
+
}
|
|
239
|
+
if (newMatch) {
|
|
240
|
+
newString = newMatch[1].replace(/^\n+/, '').replace(/\n+$/, '');
|
|
241
|
+
}
|
|
242
|
+
return { file_path: filePath, old_string: oldString || '', new_string: newString || '' };
|
|
243
|
+
}
|
|
244
|
+
// Strategy 1: Non-greedy block regex (handles multi-edit, correct formatting)
|
|
245
|
+
const blockRegex = /```edit\s*([\s\S]*?)```/gi;
|
|
246
|
+
for (const regexMatch of content.matchAll(blockRegex)) {
|
|
247
|
+
const edit = parseEditBlock(regexMatch[1]);
|
|
248
|
+
if (edit)
|
|
249
|
+
edits.push(edit);
|
|
250
|
+
}
|
|
251
|
+
// Strategy 2: If no edit with new_string found (model likely closed ``` before new:),
|
|
252
|
+
// retry with greedy matching to capture everything up to the last ```
|
|
253
|
+
if (!edits.some(e => e.new_string)) {
|
|
254
|
+
edits.length = 0;
|
|
255
|
+
const greedyRegex = /```edit\s*([\s\S]*)```/g;
|
|
256
|
+
for (const regexMatch of content.matchAll(greedyRegex)) {
|
|
257
|
+
const edit = parseEditBlock(regexMatch[1]);
|
|
258
|
+
if (edit)
|
|
259
|
+
edits.push(edit);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Strategy 3: No block markers at all — try parsing raw content directly
|
|
263
|
+
if (edits.length === 0) {
|
|
264
|
+
const edit = parseEditBlock(content);
|
|
265
|
+
if (edit)
|
|
266
|
+
edits.push(edit);
|
|
267
|
+
}
|
|
268
|
+
return edits;
|
|
269
|
+
}
|
|
270
|
+
/** Extract edits from legacy JSON format (backward compatibility) */
|
|
271
|
+
function extractEditsFromJSON(input) {
|
|
272
|
+
// Pattern 0: input is an array (edits passed directly instead of wrapped)
|
|
273
|
+
if (Array.isArray(input)) {
|
|
274
|
+
// Preserve array for validation
|
|
275
|
+
return input;
|
|
276
|
+
}
|
|
277
|
+
// Pattern 1: input has file_path, old_string, new_string at top level (missing edits array)
|
|
278
|
+
if (!Array.isArray(input.edits)) {
|
|
279
|
+
const keys = Object.keys(input);
|
|
280
|
+
// Check if the keys look like a single edit object (file_path + old_string + new_string)
|
|
281
|
+
const hasFilePath = typeof input.file_path === 'string';
|
|
282
|
+
const hasOldString = typeof input.old_string === 'string';
|
|
283
|
+
const hasNewString = typeof input.new_string === 'string';
|
|
284
|
+
if (hasFilePath && hasOldString && hasNewString) {
|
|
285
|
+
return [
|
|
286
|
+
{
|
|
287
|
+
file_path: input.file_path,
|
|
288
|
+
old_string: input.old_string,
|
|
289
|
+
new_string: input.new_string,
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
else if (hasFilePath && hasOldString) {
|
|
294
|
+
// Only file_path + old_string (missing new_string) — use a sentinel
|
|
295
|
+
// value so validation catches this as an error instead of silently deleting content.
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
file_path: input.file_path,
|
|
299
|
+
old_string: input.old_string,
|
|
300
|
+
new_string: input.new_string || '__MISSING_NEW_STRING__',
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
else if (keys.length === 2 && hasFilePath && typeof input.new_string === 'string') {
|
|
305
|
+
// file_path + new_string but no old_string — treat as new file creation
|
|
306
|
+
return [
|
|
307
|
+
{
|
|
308
|
+
file_path: input.file_path,
|
|
309
|
+
old_string: '',
|
|
310
|
+
new_string: input.new_string,
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
else if (keys.length === 1 && hasFilePath) {
|
|
315
|
+
// Only file_path — maybe they meant create file with empty content?
|
|
316
|
+
return [{ file_path: input.file_path, old_string: '', new_string: '' }];
|
|
317
|
+
}
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
// If edits is an array (even empty), preserve for validation
|
|
321
|
+
if (Array.isArray(input.edits)) {
|
|
322
|
+
return input.edits;
|
|
323
|
+
}
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
136
326
|
export function createEditTool(applier, cwd) {
|
|
137
327
|
return {
|
|
138
328
|
definition: {
|
|
139
329
|
name: 'edit',
|
|
140
|
-
description: `Replace exact text in files
|
|
141
|
-
Parameter: {"edits": [{"file_path": "...", "old_string": "...", "new_string": "..."}]}
|
|
330
|
+
description: `Replace exact text in files using markdown code block format.
|
|
142
331
|
|
|
143
332
|
HOW TO USE:
|
|
144
333
|
1. Read the file first with \`read\`
|
|
145
334
|
2. Copy the EXACT text to replace — include 2-3 lines of context before/after
|
|
146
|
-
3.
|
|
335
|
+
3. Use markdown code block format below
|
|
147
336
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
337
|
+
FORMAT:
|
|
338
|
+
\`\`\`edit
|
|
339
|
+
file: <file_path>
|
|
340
|
+
old: |
|
|
341
|
+
<exact text to find>
|
|
342
|
+
new: |
|
|
343
|
+
<replacement text>
|
|
344
|
+
\`\`\`
|
|
155
345
|
|
|
156
346
|
EXAMPLES:
|
|
157
|
-
|
|
347
|
+
Single edit:
|
|
348
|
+
\`\`\`edit
|
|
349
|
+
file: src/config.ts
|
|
350
|
+
old: |
|
|
351
|
+
mode: 'code'
|
|
352
|
+
new: |
|
|
353
|
+
mode: 'plan'
|
|
354
|
+
\`\`\`
|
|
158
355
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
356
|
+
Create new file:
|
|
357
|
+
\`\`\`edit
|
|
358
|
+
file: src/new.ts
|
|
359
|
+
old: |
|
|
360
|
+
new: |
|
|
361
|
+
const x = 1
|
|
362
|
+
\`\`\`
|
|
163
363
|
|
|
164
|
-
|
|
364
|
+
CRITICAL RULES:
|
|
365
|
+
- old and new are separated by "old:" and "new:" labels
|
|
366
|
+
- Use | after label for multi-line content
|
|
367
|
+
- old_string must match EXACTLY (whitespace, indentation, line breaks)
|
|
368
|
+
- Do NOT include the "<lineNumber>: " prefix from read output`,
|
|
165
369
|
parameters: {
|
|
166
|
-
|
|
167
|
-
type: '
|
|
168
|
-
|
|
169
|
-
description: 'REQUIRED. Array of complete find-replace pairs. Each edit MUST have BOTH old_string AND new_string. Example: [{ file_path: "src/file.ts", old_string: "old", new_string: "new" }]',
|
|
370
|
+
content: {
|
|
371
|
+
type: 'string',
|
|
372
|
+
description: 'Markdown code block with edit instructions. See description for format.',
|
|
170
373
|
required: true,
|
|
171
|
-
items: {
|
|
172
|
-
type: 'object',
|
|
173
|
-
properties: {
|
|
174
|
-
file_path: { type: 'string', description: 'Path to the file' },
|
|
175
|
-
old_string: {
|
|
176
|
-
type: 'string',
|
|
177
|
-
description: 'Text to FIND in the file. REQUIRED. Pair with new_string to form a complete find-replace. Pass "" to create a new file.',
|
|
178
|
-
},
|
|
179
|
-
new_string: {
|
|
180
|
-
type: 'string',
|
|
181
|
-
description: 'Replacement text. REQUIRED. Pair with old_string to form a complete find-replace. Pass "" to delete content.',
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
374
|
},
|
|
186
375
|
},
|
|
187
376
|
},
|
|
188
377
|
async execute(input) {
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const keys = Object.keys(input);
|
|
206
|
-
// Check if the keys look like a single edit object (file_path + old_string + new_string)
|
|
207
|
-
const hasFilePath = typeof input.file_path === 'string';
|
|
208
|
-
const hasOldString = typeof input.old_string === 'string';
|
|
209
|
-
const hasNewString = typeof input.new_string === 'string';
|
|
210
|
-
if (hasFilePath && hasOldString && hasNewString) {
|
|
211
|
-
// Auto-correct: wrap into edits array
|
|
212
|
-
input = {
|
|
213
|
-
edits: [
|
|
214
|
-
{
|
|
215
|
-
file_path: input.file_path,
|
|
216
|
-
old_string: input.old_string,
|
|
217
|
-
new_string: input.new_string,
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
};
|
|
378
|
+
// Debug: keep rawInput for error messages (capture early for all error paths)
|
|
379
|
+
const rawInput = input;
|
|
380
|
+
// ── Parse markdown format ─────────────────────────────────────────
|
|
381
|
+
let edits = [];
|
|
382
|
+
// If input has 'content' field (new markdown format)
|
|
383
|
+
if (typeof input.content === 'string') {
|
|
384
|
+
// Compat: handle double-JSON-wrapped content
|
|
385
|
+
let contentStr = input.content;
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(contentStr);
|
|
388
|
+
if (typeof parsed === 'string') {
|
|
389
|
+
contentStr = parsed;
|
|
390
|
+
}
|
|
391
|
+
else if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') {
|
|
392
|
+
contentStr = parsed.content;
|
|
393
|
+
}
|
|
221
394
|
}
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
input = {
|
|
225
|
-
edits: [
|
|
226
|
-
{
|
|
227
|
-
file_path: input.file_path,
|
|
228
|
-
old_string: input.old_string,
|
|
229
|
-
new_string: input.new_string || '',
|
|
230
|
-
},
|
|
231
|
-
],
|
|
232
|
-
};
|
|
395
|
+
catch {
|
|
396
|
+
// Not JSON — use as-is
|
|
233
397
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
398
|
+
edits = parseMarkdownEdit(contentStr);
|
|
399
|
+
if (edits.length === 0) {
|
|
400
|
+
return {
|
|
401
|
+
success: false,
|
|
402
|
+
output: '',
|
|
403
|
+
error: `Failed to parse edit format. Raw input: ${summarizeRawInput(rawInput)}\nUse: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\``,
|
|
404
|
+
};
|
|
237
405
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// Legacy JSON format (backward compatibility)
|
|
409
|
+
edits = extractEditsFromJSON(input);
|
|
410
|
+
}
|
|
411
|
+
if (edits.length === 0) {
|
|
412
|
+
// Check if input specifically had empty edits array (for better error message)
|
|
413
|
+
const inputEdits = input.edits;
|
|
414
|
+
if ('edits' in input && Array.isArray(inputEdits) && inputEdits.length === 0) {
|
|
415
|
+
return {
|
|
416
|
+
success: false,
|
|
417
|
+
output: '',
|
|
418
|
+
error: `edit FAILED — no edits to apply. The edits array exists but is empty. Raw input: ${summarizeRawInput(rawInput)}`,
|
|
242
419
|
};
|
|
243
420
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const example = hasFilePath
|
|
247
|
-
? `edit({ edits: [{ file_path: "${input.file_path}", old_string: "...", new_string: "..." }] })`
|
|
248
|
-
: `edit({ edits: [{ file_path: "src/file.ts", old_string: "old", new_string: "new" }] })`;
|
|
421
|
+
// Check if input has edits key but it's not an array
|
|
422
|
+
if ('edits' in input && !Array.isArray(input.edits)) {
|
|
249
423
|
return {
|
|
250
424
|
success: false,
|
|
251
425
|
output: '',
|
|
252
|
-
error: `edit requires "edits" array.
|
|
426
|
+
error: `edit requires "edits" array. Use markdown format: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\`\nRaw input: ${summarizeRawInput(rawInput)}`,
|
|
253
427
|
};
|
|
254
428
|
}
|
|
255
|
-
}
|
|
256
|
-
const edits = input.edits;
|
|
257
|
-
if (edits.length === 0) {
|
|
258
|
-
// Build a diagnostic: show what the AI received vs expected
|
|
259
|
-
const receivedKeys = Object.keys(rawInput);
|
|
260
|
-
const hint = receivedKeys.length === 1 && receivedKeys[0] === 'edits'
|
|
261
|
-
? 'The edits array exists but is empty — include at least one edit object with file_path, old_string, and new_string.'
|
|
262
|
-
: receivedKeys.includes('file_path')
|
|
263
|
-
? 'Top-level file_path/old_string/new_string detected — wrap them in an edits array: { edits: [{ file_path, old_string, new_string }] }'
|
|
264
|
-
: 'No usable edit data found in the input. Provide an edits array with at least one edit.';
|
|
265
429
|
return {
|
|
266
430
|
success: false,
|
|
267
431
|
output: '',
|
|
268
|
-
error: `edit
|
|
432
|
+
error: `No valid edits found (empty or invalid format). The edit array must contain objects with file_path, old_string, and new_string. Use markdown code block format: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\`\nRaw input: ${summarizeRawInput(rawInput)}`,
|
|
269
433
|
};
|
|
270
434
|
}
|
|
271
|
-
// Validate each edit object
|
|
435
|
+
// Validate each edit object
|
|
272
436
|
const editErrors = [];
|
|
273
437
|
for (let i = 0; i < edits.length; i++) {
|
|
274
438
|
const e = edits[i];
|
|
@@ -279,16 +443,21 @@ EXAMPLES:
|
|
|
279
443
|
missing.push('old_string');
|
|
280
444
|
if (typeof e.new_string !== 'string')
|
|
281
445
|
missing.push('new_string');
|
|
446
|
+
if (e.new_string === '__MISSING_NEW_STRING__') {
|
|
447
|
+
missing.push('new_string (required — LLM must provide a non-empty replacement)');
|
|
448
|
+
}
|
|
282
449
|
if (missing.length > 0) {
|
|
283
|
-
|
|
450
|
+
const present = Object.keys(e)
|
|
451
|
+
.filter(k => typeof e[k] === 'string')
|
|
452
|
+
.join(', ');
|
|
453
|
+
editErrors.push(` edit #${i + 1}: missing ${missing.join(', ')}${present ? ` (has: ${present})` : ''}`);
|
|
284
454
|
}
|
|
285
455
|
}
|
|
286
456
|
if (editErrors.length > 0) {
|
|
287
457
|
return {
|
|
288
458
|
success: false,
|
|
289
459
|
output: '',
|
|
290
|
-
error: `edit FAILED — ${editErrors.length} of ${edits.length} edit(s) have missing fields.\n${editErrors.join('\n')}\n\nReceived: ${
|
|
291
|
-
Do NOT split old_string (text to find) and new_string (replacement) across different edit objects.`,
|
|
460
|
+
error: `edit FAILED — ${editErrors.length} of ${edits.length} edit(s) have missing fields.\n${editErrors.join('\n')}\n\nReceived: ${summarizeRawInput(rawInput)}\n\nEach edit object must be a COMPLETE find-replace pair with BOTH old_string AND new_string.`,
|
|
292
461
|
};
|
|
293
462
|
}
|
|
294
463
|
const fileGroups = new Map();
|
|
@@ -322,26 +491,24 @@ Do NOT split old_string (text to find) and new_string (replacement) across diffe
|
|
|
322
491
|
e.new_string = e.new_string.replace(/\r\n/g, '\n');
|
|
323
492
|
if (e.old_string === '') {
|
|
324
493
|
if (content !== null) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
results.push(` FAIL ${relPath}:
|
|
328
|
-
Edit: ${diag}
|
|
329
|
-
${suggestion}`);
|
|
494
|
+
// File already exists in-memory (from prior edit in this batch)
|
|
495
|
+
// or on disk — treat as error to prevent silent overwrites.
|
|
496
|
+
results.push(` FAIL ${relPath}: Cannot create — file already exists (duplicate create in batch or file on disk)`);
|
|
330
497
|
if (!anyFailed) {
|
|
331
498
|
anyFailed = true;
|
|
332
|
-
firstError = `
|
|
499
|
+
firstError = `Cannot create file "${relPath}": file already exists`;
|
|
333
500
|
}
|
|
334
501
|
break;
|
|
335
502
|
}
|
|
336
503
|
const newLines = e.new_string.split('\n').length;
|
|
337
504
|
content = e.new_string;
|
|
338
|
-
results.push(` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string)}`);
|
|
505
|
+
results.push(` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string, 1)}`);
|
|
339
506
|
continue;
|
|
340
507
|
}
|
|
341
508
|
if (content === null) {
|
|
342
509
|
const diag = buildDiag(e);
|
|
343
510
|
const suggestion = ` → Check the file path, or create it first with old_string: "" (empty string).`;
|
|
344
|
-
results.push(` FAIL ${relPath}: File not found
|
|
511
|
+
results.push(` FAIL ${relPath}: File not found
|
|
345
512
|
Edit: ${diag}
|
|
346
513
|
${suggestion}`);
|
|
347
514
|
if (!anyFailed) {
|
|
@@ -359,7 +526,7 @@ ${suggestion}`);
|
|
|
359
526
|
if (exactIdx !== lastIdx) {
|
|
360
527
|
const diag = buildDiag(e);
|
|
361
528
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`;
|
|
362
|
-
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
|
|
529
|
+
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
|
|
363
530
|
Edit: ${diag}
|
|
364
531
|
${suggestion}`);
|
|
365
532
|
if (!anyFailed) {
|
|
@@ -397,8 +564,8 @@ ${suggestion}`);
|
|
|
397
564
|
const diag = buildDiag(e);
|
|
398
565
|
const readHint = readWarning ? `\n ${readWarning}` : '';
|
|
399
566
|
const suggestion = ` → Read the file again with read({ paths: ["${e.file_path}"] }) to get current content, then retry with exact matching text. Include 2-3 lines of surrounding context for uniqueness.${readHint}`;
|
|
400
|
-
results.push(` FAIL ${relPath}: old_string not found
|
|
401
|
-
Edit: ${diag}
|
|
567
|
+
results.push(` FAIL ${relPath}: old_string not found${hint}
|
|
568
|
+
Edit: ${diag}
|
|
402
569
|
${suggestion}`);
|
|
403
570
|
if (!anyFailed) {
|
|
404
571
|
anyFailed = true;
|
|
@@ -409,7 +576,7 @@ ${suggestion}`);
|
|
|
409
576
|
if (tolerant.length > 1) {
|
|
410
577
|
const diag = buildDiag(e);
|
|
411
578
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`;
|
|
412
|
-
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)
|
|
579
|
+
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)\n Edit: ${diag}\n${suggestion}`);
|
|
413
580
|
if (!anyFailed) {
|
|
414
581
|
anyFailed = true;
|
|
415
582
|
firstError = `old_string appears MULTIPLE times in ${relPath} (whitespace-normalized)`;
|
|
@@ -422,10 +589,13 @@ ${suggestion}`);
|
|
|
422
589
|
strategy: 'tolerant',
|
|
423
590
|
};
|
|
424
591
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
592
|
+
// For tolerant matching, use the actual file text at the match position
|
|
593
|
+
// so the diff reflects what was really in the file (with original whitespace).
|
|
594
|
+
const matchedOld = matchInfo.strategy === 'tolerant'
|
|
595
|
+
? content.slice(matchInfo.index, matchInfo.index + matchInfo.length)
|
|
596
|
+
: e.old_string;
|
|
597
|
+
const diff = generateDiffWithContext(content, matchedOld, e.new_string, matchInfo.index, matchInfo.length);
|
|
598
|
+
results.push(` Edited ${relPath}:\n${diff}`);
|
|
429
599
|
content =
|
|
430
600
|
content.slice(0, matchInfo.index) +
|
|
431
601
|
e.new_string +
|
|
@@ -462,7 +632,7 @@ ${suggestion}`);
|
|
|
462
632
|
return {
|
|
463
633
|
success: false,
|
|
464
634
|
output: '',
|
|
465
|
-
error: `Edit FAILED — all changes rolled back
|
|
635
|
+
error: `Edit FAILED — all changes rolled back.\n${failLines}\n\n${firstError}`,
|
|
466
636
|
};
|
|
467
637
|
}
|
|
468
638
|
for (const [resolved, content] of modifiedFiles) {
|
|
@@ -479,7 +649,7 @@ ${suggestion}`);
|
|
|
479
649
|
return {
|
|
480
650
|
success: false,
|
|
481
651
|
output: '',
|
|
482
|
-
error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${
|
|
652
|
+
error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${summarizeRawInput(rawInput)}`,
|
|
483
653
|
};
|
|
484
654
|
}
|
|
485
655
|
applier.markRead(resolved);
|