lonny-agent 0.2.3 → 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.
Files changed (129) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/llm.d.ts +4 -0
  5. package/dist/agent/llm.d.ts.map +1 -1
  6. package/dist/agent/project.d.ts +26 -0
  7. package/dist/agent/project.d.ts.map +1 -0
  8. package/dist/agent/project.js +303 -0
  9. package/dist/agent/project.js.map +1 -0
  10. package/dist/agent/prompt-builder.d.ts +1 -1
  11. package/dist/agent/prompt-builder.d.ts.map +1 -1
  12. package/dist/agent/prompt-builder.js +22 -13
  13. package/dist/agent/prompt-builder.js.map +1 -1
  14. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  15. package/dist/agent/providers/anthropic.js +20 -2
  16. package/dist/agent/providers/anthropic.js.map +1 -1
  17. package/dist/agent/providers/google.d.ts.map +1 -1
  18. package/dist/agent/providers/google.js +1 -1
  19. package/dist/agent/providers/google.js.map +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts.map +1 -1
  24. package/dist/agent/providers/openai.js +63 -3
  25. package/dist/agent/providers/openai.js.map +1 -1
  26. package/dist/agent/session.d.ts +8 -2
  27. package/dist/agent/session.d.ts.map +1 -1
  28. package/dist/agent/session.js +250 -153
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +7 -1
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/tools/__tests__/bash.test.js +1 -1
  34. package/dist/tools/__tests__/bash.test.js.map +1 -1
  35. package/dist/tools/__tests__/edit.test.js +875 -371
  36. package/dist/tools/__tests__/edit.test.js.map +1 -1
  37. package/dist/tools/__tests__/grep.test.js +32 -0
  38. package/dist/tools/__tests__/grep.test.js.map +1 -1
  39. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  40. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  41. package/dist/tools/__tests__/sed.test.js +228 -0
  42. package/dist/tools/__tests__/sed.test.js.map +1 -0
  43. package/dist/tools/bash.d.ts.map +1 -1
  44. package/dist/tools/bash.js +83 -10
  45. package/dist/tools/bash.js.map +1 -1
  46. package/dist/tools/edit.d.ts +58 -0
  47. package/dist/tools/edit.d.ts.map +1 -1
  48. package/dist/tools/edit.js +366 -149
  49. package/dist/tools/edit.js.map +1 -1
  50. package/dist/tools/grep.d.ts.map +1 -1
  51. package/dist/tools/grep.js +54 -30
  52. package/dist/tools/grep.js.map +1 -1
  53. package/dist/tools/read.d.ts +6 -0
  54. package/dist/tools/read.d.ts.map +1 -1
  55. package/dist/tools/read.js +42 -15
  56. package/dist/tools/read.js.map +1 -1
  57. package/dist/tools/registry.d.ts +0 -6
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +2 -33
  60. package/dist/tools/registry.js.map +1 -1
  61. package/dist/tools/sed.d.ts +4 -0
  62. package/dist/tools/sed.d.ts.map +1 -0
  63. package/dist/tools/sed.js +121 -0
  64. package/dist/tools/sed.js.map +1 -0
  65. package/dist/tools/write_plan.d.ts +1 -1
  66. package/dist/tools/write_plan.d.ts.map +1 -1
  67. package/dist/tools/write_plan.js +14 -4
  68. package/dist/tools/write_plan.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +5 -4
  71. package/dist/tui/index.js.map +1 -1
  72. package/dist/web/index.d.ts.map +1 -1
  73. package/dist/web/index.js +30 -7
  74. package/dist/web/index.js.map +1 -1
  75. package/dist/web/public/app.js +5 -855
  76. package/dist/web/public/confirm.js +70 -0
  77. package/dist/web/public/index.html +121 -120
  78. package/dist/web/public/input.js +107 -0
  79. package/dist/web/public/messages.js +395 -0
  80. package/dist/web/public/sidebar.js +82 -0
  81. package/dist/web/public/state.js +72 -0
  82. package/dist/web/public/style.css +996 -949
  83. package/dist/web/public/utils.js +37 -0
  84. package/dist/web/public/websocket.js +267 -0
  85. package/dist/web/public/ws.js +19 -0
  86. package/dist/web/session-bridge.d.ts.map +1 -1
  87. package/dist/web/session-bridge.js +3 -7
  88. package/dist/web/session-bridge.js.map +1 -1
  89. package/package.json +55 -54
  90. package/scripts/copy-native.mjs +24 -0
  91. package/scripts/copy-web.mjs +15 -0
  92. package/src/agent/index.ts +25 -25
  93. package/src/agent/llm.ts +4 -0
  94. package/src/agent/project.ts +366 -0
  95. package/src/agent/prompt-builder.ts +23 -13
  96. package/src/agent/providers/anthropic.ts +179 -172
  97. package/src/agent/providers/google.ts +210 -211
  98. package/src/agent/providers/ollama.ts +186 -182
  99. package/src/agent/providers/openai.ts +73 -5
  100. package/src/agent/session.ts +275 -153
  101. package/src/config/index.ts +342 -335
  102. package/src/tools/__tests__/bash.test.ts +1 -1
  103. package/src/tools/__tests__/edit.test.ts +981 -385
  104. package/src/tools/__tests__/grep.test.ts +35 -0
  105. package/src/tools/bash.ts +97 -11
  106. package/src/tools/edit.ts +414 -154
  107. package/src/tools/git.ts +76 -76
  108. package/src/tools/grep.ts +57 -30
  109. package/src/tools/read.ts +61 -25
  110. package/src/tools/registry.ts +231 -265
  111. package/src/tools/write_plan.ts +21 -5
  112. package/src/tui/index.ts +786 -785
  113. package/src/web/index.ts +29 -7
  114. package/src/web/public/app.js +5 -855
  115. package/src/web/public/confirm.js +70 -0
  116. package/src/web/public/index.html +121 -120
  117. package/src/web/public/input.js +107 -0
  118. package/src/web/public/messages.js +395 -0
  119. package/src/web/public/sidebar.js +82 -0
  120. package/src/web/public/state.js +72 -0
  121. package/src/web/public/style.css +996 -949
  122. package/src/web/public/utils.js +37 -0
  123. package/src/web/public/websocket.js +267 -0
  124. package/src/web/public/ws.js +19 -0
  125. package/src/web/session-bridge.ts +193 -194
  126. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  127. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  128. package/.lonny/plan-web-cwd-status.md +0 -38
  129. package/src/tools/exec.ts +0 -348
@@ -1,21 +1,163 @@
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
- // ── ANSI colors for diff output ─────────────────────────────────────────
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
- function generateDiff(oldStr, newStr) {
10
+ // ── Colors for HTML output ────────────────────────────────────────────────
11
+ const HTML_RED = '#ff5050';
12
+ const HTML_GREEN = '#00c864';
13
+ const HTML_DIM = '#888888';
14
+ /** Build diagnostic JSON for error messages */
15
+ function buildDiag(edit) {
16
+ return JSON.stringify({
17
+ file_path: edit.file_path,
18
+ old_string: edit.old_string,
19
+ new_string: edit.new_string,
20
+ });
21
+ }
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) {
9
31
  const oldLines = oldStr === '' ? [] : oldStr.split('\n');
10
- const newLines = newStr.split('\n');
11
- const lines = [];
12
- for (const line of oldLines) {
13
- lines.push(` ${DIFF_RED}- ${line}${DIFF_RESET}`);
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
+ }));
44
+ }
45
+ /** Terminal renderer — unified-diff format with color */
46
+ export function renderDiffTerminal(lines, startLineNum) {
47
+ if (lines.length === 0)
48
+ return '';
49
+ const output = [];
50
+ let oldLn = startLineNum ?? 1;
51
+ let newLn = startLineNum ?? 1;
52
+ for (const line of lines) {
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
+ }
14
66
  }
15
- for (const line of newLines) {
16
- lines.push(` ${DIFF_GREEN}+ ${line}${DIFF_RESET}`);
67
+ return output.join('\n');
68
+ }
69
+ /** HTML renderer for web output */
70
+ export function renderDiffHtml(lines) {
71
+ if (lines.length === 0)
72
+ return '';
73
+ const output = [];
74
+ for (const line of lines) {
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
+ }
17
84
  }
18
- return lines.join('\n');
85
+ return output.join('\n');
86
+ }
87
+ /** Escape HTML special characters */
88
+ export function escapeHtml(str) {
89
+ return str
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;')
93
+ .replace(/"/g, '&quot;')
94
+ .replace(/'/g, '&#39;');
95
+ }
96
+ /**
97
+ * Generate diff output using jest-diff.
98
+ * Returns terminal-colored unified-diff format with line numbers.
99
+ */
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');
19
161
  }
20
162
  /**
21
163
  * Normalize a line for whitespace-tolerant comparison:
@@ -27,7 +169,8 @@ function generateDiff(oldStr, newStr) {
27
169
  * - Extra internal spaces → `"foo bar"` → `"foo bar"`
28
170
  * - Blank lines with spaces → `" "` → `""`
29
171
  */
30
- function normalizeLine(s) {
172
+ /** Export for testing */
173
+ export function normalizeLine(s) {
31
174
  return s.trim().replace(/[ \t]{2,}/g, ' ');
32
175
  }
33
176
  /**
@@ -38,7 +181,8 @@ function normalizeLine(s) {
38
181
  * so the caller can do content.slice(match.index, match.index + match.length)
39
182
  * to extract the actual matched text (with its original whitespace).
40
183
  */
41
- function findAllLinesTolerant(content, oldString) {
184
+ /** Export for testing */
185
+ export function findAllLinesTolerant(content, oldString) {
42
186
  if (oldString === '')
43
187
  return [];
44
188
  const contentLines = content.split('\n');
@@ -75,134 +219,220 @@ function findAllLinesTolerant(content, oldString) {
75
219
  }
76
220
  return matches;
77
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
+ }
78
326
  export function createEditTool(applier, cwd) {
79
327
  return {
80
328
  definition: {
81
329
  name: 'edit',
82
- description: `Replace exact text in files. Each edit object is a COMPLETE find-and-replace pair.
83
- Parameter: {"edits": [{"file_path": "...", "old_string": "...", "new_string": "..."}]}
330
+ description: `Replace exact text in files using markdown code block format.
84
331
 
85
332
  HOW TO USE:
86
333
  1. Read the file first with \`read\`
87
334
  2. Copy the EXACT text to replace — include 2-3 lines of context before/after
88
- 3. Call: edit({ edits: [{ file_path, old_string, new_string }] })
335
+ 3. Use markdown code block format below
89
336
 
90
- CRITICAL RULES:
91
- - Each edit MUST have BOTH old_string AND new_string (a complete find-replace pair)
92
- - old_string must match EXACTLY (whitespace, indentation, line breaks)
93
- - old_string must be UNIQUE — include enough surrounding context
94
- - Do NOT include the "<lineNumber>: " prefix from read output
95
- - \`edits\` is always an array. Never pass file_path/old_string/new_string as top-level keys.
96
- - To batch multiple independent edits, add multiple objects to the array
337
+ FORMAT:
338
+ \`\`\`edit
339
+ file: <file_path>
340
+ old: |
341
+ <exact text to find>
342
+ new: |
343
+ <replacement text>
344
+ \`\`\`
97
345
 
98
346
  EXAMPLES:
99
- Single edit: edit({ edits: [{ file_path: "src/config.ts", old_string: "mode: 'code'", new_string: "mode: 'plan'" }] })
347
+ Single edit:
348
+ \`\`\`edit
349
+ file: src/config.ts
350
+ old: |
351
+ mode: 'code'
352
+ new: |
353
+ mode: 'plan'
354
+ \`\`\`
100
355
 
101
- Batch edits: edit({ edits: [
102
- { file_path: "src/a.ts", old_string: "foo", new_string: "bar" },
103
- { file_path: "src/b.ts", old_string: "x", new_string: "y" }
104
- ] })
356
+ Create new file:
357
+ \`\`\`edit
358
+ file: src/new.ts
359
+ old: |
360
+ new: |
361
+ const x = 1
362
+ \`\`\`
105
363
 
106
- Create file: edit({ edits: [{ file_path: "src/new.ts", old_string: "", new_string: "const x = 1" }] })`,
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`,
107
369
  parameters: {
108
- edits: {
109
- type: 'array',
110
- 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.',
111
373
  required: true,
112
- items: {
113
- type: 'object',
114
- properties: {
115
- file_path: { type: 'string', description: 'Path to the file' },
116
- old_string: {
117
- type: 'string',
118
- description: 'Text to FIND in the file. REQUIRED. Pair with new_string to form a complete find-replace. Pass "" to create a new file.',
119
- },
120
- new_string: {
121
- type: 'string',
122
- description: 'Replacement text. REQUIRED. Pair with old_string to form a complete find-replace. Pass "" to delete content.',
123
- },
124
- },
125
- },
126
374
  },
127
375
  },
128
376
  },
129
377
  async execute(input) {
130
- // ── Auto-correction: detect common misuse patterns ────────────────
131
- // ── Debug: log raw input for diagnosing failures ─────────────
132
- const rawInput = JSON.parse(JSON.stringify(input));
133
- // Pattern 0: input is an array (edits passed directly instead of wrapped)
134
- if (Array.isArray(input)) {
135
- input = { edits: input };
136
- }
137
- // Pattern 1: input has file_path, old_string, new_string at top level (missing edits array)
138
- if (!Array.isArray(input.edits)) {
139
- const keys = Object.keys(input);
140
- // Check if the keys look like a single edit object (file_path + old_string + new_string)
141
- const hasFilePath = typeof input.file_path === 'string';
142
- const hasOldString = typeof input.old_string === 'string';
143
- const hasNewString = typeof input.new_string === 'string';
144
- if (hasFilePath && hasOldString && hasNewString) {
145
- // Auto-correct: wrap into edits array
146
- input = {
147
- edits: [
148
- {
149
- file_path: input.file_path,
150
- old_string: input.old_string,
151
- new_string: input.new_string,
152
- },
153
- ],
154
- };
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
+ }
155
394
  }
156
- else if (hasFilePath && hasOldString) {
157
- // Only file_path + old_string (missing new_string) still try
158
- input = {
159
- edits: [
160
- {
161
- file_path: input.file_path,
162
- old_string: input.old_string,
163
- new_string: input.new_string || '',
164
- },
165
- ],
166
- };
395
+ catch {
396
+ // Not JSONuse as-is
167
397
  }
168
- else if (keys.length === 1 && hasFilePath) {
169
- // Only file_path maybe they meant create file with empty content?
170
- input = { edits: [{ file_path: input.file_path, old_string: '', new_string: '' }] };
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
+ };
171
405
  }
172
- else if (keys.length === 2 && hasFilePath && typeof input.new_string === 'string') {
173
- // file_path + new_string but no old_string — treat as new file creation
174
- input = {
175
- edits: [{ file_path: input.file_path, old_string: '', new_string: input.new_string }],
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)}`,
176
419
  };
177
420
  }
178
- else {
179
- // Can't auto-correct give helpful error with examples
180
- const example = hasFilePath
181
- ? `edit({ edits: [{ file_path: "${input.file_path}", old_string: "...", new_string: "..." }] })`
182
- : `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)) {
183
423
  return {
184
424
  success: false,
185
425
  output: '',
186
- error: `edit requires "edits" array. Received: ${JSON.stringify(rawInput)}. Usage: ${example}`,
426
+ error: `edit requires "edits" array. Use markdown format: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\`\nRaw input: ${summarizeRawInput(rawInput)}`,
187
427
  };
188
428
  }
189
- }
190
- const edits = input.edits;
191
- if (edits.length === 0) {
192
- // Build a diagnostic: show what the AI received vs expected
193
- const receivedKeys = Object.keys(rawInput);
194
- const hint = receivedKeys.length === 1 && receivedKeys[0] === 'edits'
195
- ? 'The edits array exists but is empty — include at least one edit object with file_path, old_string, and new_string.'
196
- : receivedKeys.includes('file_path')
197
- ? 'Top-level file_path/old_string/new_string detected — wrap them in an edits array: { edits: [{ file_path, old_string, new_string }] }'
198
- : 'No usable edit data found in the input. Provide an edits array with at least one edit.';
199
429
  return {
200
430
  success: false,
201
431
  output: '',
202
- error: `edit FAILED no edits to apply. Raw input: ${JSON.stringify(rawInput)}. ${hint}`,
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)}`,
203
433
  };
204
434
  }
205
- // Validate each edit object — catch missing old_string/new_string early
435
+ // Validate each edit object
206
436
  const editErrors = [];
207
437
  for (let i = 0; i < edits.length; i++) {
208
438
  const e = edits[i];
@@ -213,16 +443,21 @@ EXAMPLES:
213
443
  missing.push('old_string');
214
444
  if (typeof e.new_string !== 'string')
215
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
+ }
216
449
  if (missing.length > 0) {
217
- editErrors.push(` edit #${i + 1}: missing ${missing.join(', ')} (has: ${Object.keys(e).join(', ')})`);
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})` : ''}`);
218
454
  }
219
455
  }
220
456
  if (editErrors.length > 0) {
221
457
  return {
222
458
  success: false,
223
459
  output: '',
224
- error: `edit FAILED — ${editErrors.length} of ${edits.length} edit(s) have missing fields.\n${editErrors.join('\n')}\n\nReceived: ${JSON.stringify(rawInput)}\n\nEach edit object must be a COMPLETE find-replace pair with BOTH old_string AND new_string.
225
- 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.`,
226
461
  };
227
462
  }
228
463
  const fileGroups = new Map();
@@ -256,34 +491,24 @@ Do NOT split old_string (text to find) and new_string (replacement) across diffe
256
491
  e.new_string = e.new_string.replace(/\r\n/g, '\n');
257
492
  if (e.old_string === '') {
258
493
  if (content !== null) {
259
- const diag = JSON.stringify({
260
- file_path: e.file_path,
261
- old_string: '',
262
- new_string: e.new_string,
263
- });
264
- const suggestion = ` → Use edit with old_string to replace existing content, or choose a different path.`;
265
- results.push(` FAIL ${relPath}: File already exists — Raw input: ${JSON.stringify(rawInput)}
266
- Edit: ${diag}
267
- ${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)`);
268
497
  if (!anyFailed) {
269
498
  anyFailed = true;
270
- firstError = `File already exists: ${relPath}`;
499
+ firstError = `Cannot create file "${relPath}": file already exists`;
271
500
  }
272
501
  break;
273
502
  }
274
503
  const newLines = e.new_string.split('\n').length;
275
504
  content = e.new_string;
276
- results.push(` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string)}`);
505
+ results.push(` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string, 1)}`);
277
506
  continue;
278
507
  }
279
508
  if (content === null) {
280
- const diag = JSON.stringify({
281
- file_path: e.file_path,
282
- old_string: e.old_string,
283
- new_string: e.new_string,
284
- });
509
+ const diag = buildDiag(e);
285
510
  const suggestion = ` → Check the file path, or create it first with old_string: "" (empty string).`;
286
- results.push(` FAIL ${relPath}: File not found — Raw input: ${JSON.stringify(rawInput)}
511
+ results.push(` FAIL ${relPath}: File not found
287
512
  Edit: ${diag}
288
513
  ${suggestion}`);
289
514
  if (!anyFailed) {
@@ -299,13 +524,9 @@ ${suggestion}`);
299
524
  // Exact match found — check for duplicates
300
525
  const lastIdx = content.lastIndexOf(e.old_string);
301
526
  if (exactIdx !== lastIdx) {
302
- const diag = JSON.stringify({
303
- file_path: e.file_path,
304
- old_string: e.old_string,
305
- new_string: e.new_string,
306
- });
527
+ const diag = buildDiag(e);
307
528
  const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`;
308
- results.push(` FAIL ${relPath}: old_string appears MULTIPLE times — Raw input: ${JSON.stringify(rawInput)}
529
+ results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
309
530
  Edit: ${diag}
310
531
  ${suggestion}`);
311
532
  if (!anyFailed) {
@@ -340,15 +561,11 @@ ${suggestion}`);
340
561
  const lines = contentLines.slice(0, Math.min(contentLines.length, 5));
341
562
  hint = `\n File content (first ${lines.length} lines):\n """\n${lines.join('\n')}\n """`;
342
563
  }
343
- const diag = JSON.stringify({
344
- file_path: e.file_path,
345
- old_string: e.old_string,
346
- new_string: e.new_string,
347
- });
564
+ const diag = buildDiag(e);
348
565
  const readHint = readWarning ? `\n ${readWarning}` : '';
349
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}`;
350
- results.push(` FAIL ${relPath}: old_string not found — Raw input: ${JSON.stringify(rawInput)}
351
- Edit: ${diag}${hint}
567
+ results.push(` FAIL ${relPath}: old_string not found${hint}
568
+ Edit: ${diag}
352
569
  ${suggestion}`);
353
570
  if (!anyFailed) {
354
571
  anyFailed = true;
@@ -357,13 +574,9 @@ ${suggestion}`);
357
574
  break;
358
575
  }
359
576
  if (tolerant.length > 1) {
360
- const diag = JSON.stringify({
361
- file_path: e.file_path,
362
- old_string: e.old_string,
363
- new_string: e.new_string,
364
- });
577
+ const diag = buildDiag(e);
365
578
  const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`;
366
- results.push(` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized) — Raw input: ${JSON.stringify(rawInput)}\n Edit: ${diag}\n${suggestion}`);
579
+ results.push(` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)\n Edit: ${diag}\n${suggestion}`);
367
580
  if (!anyFailed) {
368
581
  anyFailed = true;
369
582
  firstError = `old_string appears MULTIPLE times in ${relPath} (whitespace-normalized)`;
@@ -376,9 +589,13 @@ ${suggestion}`);
376
589
  strategy: 'tolerant',
377
590
  };
378
591
  }
379
- const strategyLabel = matchInfo.strategy === 'tolerant' ? ' (whitespace-normalized)' : '';
380
- const diff = generateDiff(e.old_string, e.new_string);
381
- results.push(` Edited ${relPath}${strategyLabel}:\n${diff}`);
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}`);
382
599
  content =
383
600
  content.slice(0, matchInfo.index) +
384
601
  e.new_string +
@@ -415,7 +632,7 @@ ${suggestion}`);
415
632
  return {
416
633
  success: false,
417
634
  output: '',
418
- error: `Edit FAILED — all changes rolled back. Raw input: ${JSON.stringify(rawInput)}\n${failLines}\n\n${firstError}`,
635
+ error: `Edit FAILED — all changes rolled back.\n${failLines}\n\n${firstError}`,
419
636
  };
420
637
  }
421
638
  for (const [resolved, content] of modifiedFiles) {
@@ -432,7 +649,7 @@ ${suggestion}`);
432
649
  return {
433
650
  success: false,
434
651
  output: '',
435
- error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${JSON.stringify(rawInput)}`,
652
+ error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${summarizeRawInput(rawInput)}`,
436
653
  };
437
654
  }
438
655
  applier.markRead(resolved);