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
package/src/tools/edit.ts CHANGED
@@ -1,25 +1,194 @@
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 type { FileReadTracker } from '../diff/apply.js'
4
5
  import { fmtErr } from './errors.js'
5
6
  import type { Tool, ToolResult } from './types.js'
6
7
 
7
- // ── ANSI colors for diff output ─────────────────────────────────────────
8
+ // ── Diff types ────────────────────────────────────────────────────────────
9
+ export type DiffLineType = 'delete' | 'insert' | 'equal'
10
+
11
+ export interface DiffLine {
12
+ type: DiffLineType
13
+ content: string
14
+ }
15
+
16
+ // ── ANSI colors for terminal output ───────────────────────────────────────
8
17
  const DIFF_RED = '\x1b[38;2;255;80;80m'
9
18
  const DIFF_GREEN = '\x1b[38;2;0;200;100m'
19
+ const DIFF_DIM = '\x1b[38;2;100;100;100m'
10
20
  const DIFF_RESET = '\x1b[0m'
11
21
 
12
- function generateDiff(oldStr: string, newStr: string): string {
22
+ // ── Colors for HTML output ────────────────────────────────────────────────
23
+ const HTML_RED = '#ff5050'
24
+ const HTML_GREEN = '#00c864'
25
+ const HTML_DIM = '#888888'
26
+
27
+ /** Build diagnostic JSON for error messages */
28
+ function buildDiag(edit: SingleEdit): string {
29
+ return JSON.stringify({
30
+ file_path: edit.file_path,
31
+ old_string: edit.old_string,
32
+ new_string: edit.new_string,
33
+ })
34
+ }
35
+
36
+ /** Summarize raw input for error messages to avoid dumping huge strings into the LLM context. */
37
+ function summarizeRawInput(rawInput: unknown): string {
38
+ const s = JSON.stringify(rawInput)
39
+ if (s.length <= 500) return s
40
+ return `${s.slice(0, 500)}... [truncated, total ${s.length} chars]`
41
+ }
42
+
43
+ /** Compute diff lines using jest-diff for proper line-level diffs */
44
+ export function computeDiff(oldStr: string, newStr: string): DiffLine[] {
13
45
  const oldLines = oldStr === '' ? [] : oldStr.split('\n')
14
- const newLines = newStr.split('\n')
15
- const lines: string[] = []
16
- for (const line of oldLines) {
17
- lines.push(` ${DIFF_RED}- ${line}${DIFF_RESET}`)
46
+ const newLines = newStr === '' ? [] : newStr.split('\n')
47
+
48
+ if (oldLines.length === 0 && newLines.length === 0) return []
49
+
50
+ const rawDiff = diffLinesRaw(oldLines, newLines)
51
+ return rawDiff.map(d => ({
52
+ type:
53
+ d[0] === DIFF_DELETE
54
+ ? ('delete' as const)
55
+ : d[0] === DIFF_INSERT
56
+ ? ('insert' as const)
57
+ : ('equal' as const),
58
+ content: d[1],
59
+ }))
60
+ }
61
+
62
+ /** Terminal renderer — unified-diff format with color */
63
+ export function renderDiffTerminal(lines: DiffLine[], startLineNum?: number): string {
64
+ if (lines.length === 0) return ''
65
+
66
+ const output: string[] = []
67
+ let oldLn = startLineNum ?? 1
68
+ let newLn = startLineNum ?? 1
69
+ for (const line of lines) {
70
+ if (line.type === 'delete') {
71
+ output.push(` ${DIFF_RED}- ${oldLn} ${line.content}${DIFF_RESET}`)
72
+ oldLn++
73
+ } else if (line.type === 'insert') {
74
+ output.push(` ${DIFF_GREEN}+ ${newLn} ${line.content}${DIFF_RESET}`)
75
+ newLn++
76
+ } else {
77
+ output.push(` ${DIFF_DIM} ${oldLn} ${line.content}${DIFF_RESET}`)
78
+ oldLn++
79
+ newLn++
80
+ }
18
81
  }
19
- for (const line of newLines) {
20
- lines.push(` ${DIFF_GREEN}+ ${line}${DIFF_RESET}`)
82
+ return output.join('\n')
83
+ }
84
+
85
+ /** HTML renderer for web output */
86
+ export function renderDiffHtml(lines: DiffLine[]): string {
87
+ if (lines.length === 0) return ''
88
+
89
+ const output: string[] = []
90
+ for (const line of lines) {
91
+ if (line.type === 'delete') {
92
+ output.push(` <span style="color: ${HTML_RED};">- ${escapeHtml(line.content)}</span>`)
93
+ } else if (line.type === 'insert') {
94
+ output.push(` <span style="color: ${HTML_GREEN};">+ ${escapeHtml(line.content)}</span>`)
95
+ } else {
96
+ output.push(` <span style="color: ${HTML_DIM};"> ${escapeHtml(line.content)}</span>`)
97
+ }
21
98
  }
22
- return lines.join('\n')
99
+ return output.join('\n')
100
+ }
101
+
102
+ /** Escape HTML special characters */
103
+ export function escapeHtml(str: string): string {
104
+ return str
105
+ .replace(/&/g, '&amp;')
106
+ .replace(/</g, '&lt;')
107
+ .replace(/>/g, '&gt;')
108
+ .replace(/"/g, '&quot;')
109
+ .replace(/'/g, '&#39;')
110
+ }
111
+
112
+ /**
113
+ * Generate diff output using jest-diff.
114
+ * Returns terminal-colored unified-diff format with line numbers.
115
+ */
116
+ export function generateDiff(oldStr: string, newStr: string, startLineNum?: number): string {
117
+ const lines = computeDiff(oldStr, newStr)
118
+ return renderDiffTerminal(lines, startLineNum)
119
+ }
120
+
121
+ /**
122
+ * Generate diff with surrounding context lines (1 before, 1 after)
123
+ * and line numbers. Context lines are extracted from the full file content.
124
+ */
125
+ export function generateDiffWithContext(
126
+ fullContent: string,
127
+ oldStr: string,
128
+ newStr: string,
129
+ matchIndex: number,
130
+ matchLength: number,
131
+ ): string {
132
+ // Calculate 1-based line number of the first matched line
133
+ const firstLineNum = fullContent.slice(0, matchIndex).split('\n').length
134
+
135
+ const contentLines = fullContent.split('\n')
136
+
137
+ // Find 0-based line index where matchIndex falls
138
+ let charPos = 0
139
+ let matchStartLineIdx = 0
140
+ for (let i = 0; i < contentLines.length; i++) {
141
+ if (charPos <= matchIndex && matchIndex < charPos + contentLines[i].length + 1) {
142
+ matchStartLineIdx = i
143
+ break
144
+ }
145
+ charPos += contentLines[i].length + 1 // +1 for \n
146
+ }
147
+
148
+ // How many lines does oldStr span in the original content?
149
+ const oldLines = oldStr === '' ? [] : oldStr.split('\n')
150
+ const oldLineCount = oldLines.length
151
+ const matchEndLineIdx = matchStartLineIdx + oldLineCount - 1
152
+
153
+ // Context before (1 line) and after (1 line)
154
+ const beforeLineIdx = matchStartLineIdx > 0 ? matchStartLineIdx - 1 : -1
155
+ const afterLineIdx = matchEndLineIdx < contentLines.length - 1 ? matchEndLineIdx + 1 : -1
156
+
157
+ // Compute diff between oldStr and newStr
158
+ const diffLines = computeDiff(oldStr, newStr)
159
+
160
+ const output: string[] = []
161
+
162
+ // Context before
163
+ if (beforeLineIdx !== -1) {
164
+ const lineNum = beforeLineIdx + 1
165
+ output.push(` ${DIFF_DIM} ${lineNum} ${contentLines[beforeLineIdx]}${DIFF_RESET}`)
166
+ }
167
+
168
+ // Diff lines with line numbers
169
+ let oldLn = firstLineNum
170
+ let newLn = firstLineNum
171
+ for (const line of diffLines) {
172
+ if (line.type === 'delete') {
173
+ output.push(` ${DIFF_RED}- ${oldLn} ${line.content}${DIFF_RESET}`)
174
+ oldLn++
175
+ } else if (line.type === 'insert') {
176
+ output.push(` ${DIFF_GREEN}+ ${newLn} ${line.content}${DIFF_RESET}`)
177
+ newLn++
178
+ } else {
179
+ output.push(` ${DIFF_DIM} ${oldLn} ${line.content}${DIFF_RESET}`)
180
+ oldLn++
181
+ newLn++
182
+ }
183
+ }
184
+
185
+ // Context after
186
+ if (afterLineIdx !== -1) {
187
+ const lineNum = afterLineIdx + 1
188
+ output.push(` ${DIFF_DIM} ${lineNum} ${contentLines[afterLineIdx]}${DIFF_RESET}`)
189
+ }
190
+
191
+ return output.join('\n')
23
192
  }
24
193
 
25
194
  /**
@@ -32,7 +201,8 @@ function generateDiff(oldStr: string, newStr: string): string {
32
201
  * - Extra internal spaces → `"foo bar"` → `"foo bar"`
33
202
  * - Blank lines with spaces → `" "` → `""`
34
203
  */
35
- function normalizeLine(s: string): string {
204
+ /** Export for testing */
205
+ export function normalizeLine(s: string): string {
36
206
  return s.trim().replace(/[ \t]{2,}/g, ' ')
37
207
  }
38
208
 
@@ -49,7 +219,8 @@ interface MatchPos {
49
219
  * so the caller can do content.slice(match.index, match.index + match.length)
50
220
  * to extract the actual matched text (with its original whitespace).
51
221
  */
52
- function findAllLinesTolerant(content: string, oldString: string): MatchPos[] {
222
+ /** Export for testing */
223
+ export function findAllLinesTolerant(content: string, oldString: string): MatchPos[] {
53
224
  if (oldString === '') return []
54
225
 
55
226
  const contentLines = content.split('\n')
@@ -96,140 +267,233 @@ interface SingleEdit {
96
267
  new_string: string
97
268
  }
98
269
 
270
+ type Edit = SingleEdit
271
+
272
+ /** Export for testing */
273
+ export function parseMarkdownEdit(content: string): Edit[] {
274
+ const edits: Edit[] = []
275
+
276
+ function parseEditBlock(raw: string): Edit | null {
277
+ // Remove stray ``` lines (model may close the block early before new:)
278
+ const cleaned = raw.replace(/^```\s*$/gm, '')
279
+
280
+ const fileMatch = cleaned.match(/^file:\s*(.+)$/m)
281
+ if (!fileMatch) return null
282
+ const filePath = fileMatch[1]!.trim()
283
+
284
+ let oldString = ''
285
+ let newString = ''
286
+
287
+ const oldMatch = cleaned.match(/^old:(?:\s*\|\d*\s*\n)?([\s\S]*?)^new:/m)
288
+ const newMatch = cleaned.match(/^new:(?:\s*\|\d*\s*\n)?([\s\S]*)$/m)
289
+
290
+ if (oldMatch) {
291
+ oldString = oldMatch[1]!.replace(/^\n+/, '').replace(/\n+$/, '')
292
+ }
293
+ if (newMatch) {
294
+ newString = newMatch[1]!.replace(/^\n+/, '').replace(/\n+$/, '')
295
+ }
296
+
297
+ return { file_path: filePath, old_string: oldString || '', new_string: newString || '' }
298
+ }
299
+
300
+ // Strategy 1: Non-greedy block regex (handles multi-edit, correct formatting)
301
+ const blockRegex = /```edit\s*([\s\S]*?)```/gi
302
+ for (const regexMatch of content.matchAll(blockRegex)) {
303
+ const edit = parseEditBlock(regexMatch[1]!)
304
+ if (edit) edits.push(edit)
305
+ }
306
+
307
+ // Strategy 2: If no edit with new_string found (model likely closed ``` before new:),
308
+ // retry with greedy matching to capture everything up to the last ```
309
+ if (!edits.some(e => e.new_string)) {
310
+ edits.length = 0
311
+ const greedyRegex = /```edit\s*([\s\S]*)```/g
312
+ for (const regexMatch of content.matchAll(greedyRegex)) {
313
+ const edit = parseEditBlock(regexMatch[1]!)
314
+ if (edit) edits.push(edit)
315
+ }
316
+ }
317
+
318
+ // Strategy 3: No block markers at all — try parsing raw content directly
319
+ if (edits.length === 0) {
320
+ const edit = parseEditBlock(content)
321
+ if (edit) edits.push(edit)
322
+ }
323
+
324
+ return edits
325
+ }
326
+
327
+ /** Extract edits from legacy JSON format (backward compatibility) */
328
+ function extractEditsFromJSON(input: Record<string, unknown>): Edit[] {
329
+ // Pattern 0: input is an array (edits passed directly instead of wrapped)
330
+ if (Array.isArray(input)) {
331
+ // Preserve array for validation
332
+ return input as Edit[]
333
+ }
334
+
335
+ // Pattern 1: input has file_path, old_string, new_string at top level (missing edits array)
336
+ if (!Array.isArray(input.edits)) {
337
+ const keys = Object.keys(input)
338
+
339
+ // Check if the keys look like a single edit object (file_path + old_string + new_string)
340
+ const hasFilePath = typeof input.file_path === 'string'
341
+ const hasOldString = typeof input.old_string === 'string'
342
+ const hasNewString = typeof input.new_string === 'string'
343
+
344
+ if (hasFilePath && hasOldString && hasNewString) {
345
+ return [
346
+ {
347
+ file_path: input.file_path as string,
348
+ old_string: input.old_string as string,
349
+ new_string: input.new_string as string,
350
+ },
351
+ ]
352
+ } else if (hasFilePath && hasOldString) {
353
+ // Only file_path + old_string (missing new_string) — use a sentinel
354
+ // value so validation catches this as an error instead of silently deleting content.
355
+ return [
356
+ {
357
+ file_path: input.file_path as string,
358
+ old_string: input.old_string as string,
359
+ new_string: (input.new_string as string) || '__MISSING_NEW_STRING__',
360
+ },
361
+ ]
362
+ } else if (keys.length === 2 && hasFilePath && typeof input.new_string === 'string') {
363
+ // file_path + new_string but no old_string — treat as new file creation
364
+ return [
365
+ {
366
+ file_path: input.file_path as string,
367
+ old_string: '',
368
+ new_string: input.new_string as string,
369
+ },
370
+ ]
371
+ } else if (keys.length === 1 && hasFilePath) {
372
+ // Only file_path — maybe they meant create file with empty content?
373
+ return [{ file_path: input.file_path as string, old_string: '', new_string: '' }]
374
+ }
375
+ return []
376
+ }
377
+
378
+ // If edits is an array (even empty), preserve for validation
379
+ if (Array.isArray(input.edits)) {
380
+ return input.edits as Edit[]
381
+ }
382
+
383
+ return []
384
+ }
385
+
99
386
  export function createEditTool(applier: FileReadTracker, cwd: string): Tool {
100
387
  return {
101
388
  definition: {
102
389
  name: 'edit',
103
- description: `Replace exact text in files. Each edit object is a COMPLETE find-and-replace pair.
104
- Parameter: {"edits": [{"file_path": "...", "old_string": "...", "new_string": "..."}]}
390
+ description: `Replace exact text in files using markdown code block format.
105
391
 
106
392
  HOW TO USE:
107
393
  1. Read the file first with \`read\`
108
394
  2. Copy the EXACT text to replace — include 2-3 lines of context before/after
109
- 3. Call: edit({ edits: [{ file_path, old_string, new_string }] })
395
+ 3. Use markdown code block format below
110
396
 
111
- CRITICAL RULES:
112
- - Each edit MUST have BOTH old_string AND new_string (a complete find-replace pair)
113
- - old_string must match EXACTLY (whitespace, indentation, line breaks)
114
- - old_string must be UNIQUE — include enough surrounding context
115
- - Do NOT include the "<lineNumber>: " prefix from read output
116
- - \`edits\` is always an array. Never pass file_path/old_string/new_string as top-level keys.
117
- - To batch multiple independent edits, add multiple objects to the array
397
+ FORMAT:
398
+ \`\`\`edit
399
+ file: <file_path>
400
+ old: |
401
+ <exact text to find>
402
+ new: |
403
+ <replacement text>
404
+ \`\`\`
118
405
 
119
406
  EXAMPLES:
120
- Single edit: edit({ edits: [{ file_path: "src/config.ts", old_string: "mode: 'code'", new_string: "mode: 'plan'" }] })
121
-
122
- Batch edits: edit({ edits: [
123
- { file_path: "src/a.ts", old_string: "foo", new_string: "bar" },
124
- { file_path: "src/b.ts", old_string: "x", new_string: "y" }
125
- ] })
407
+ Single edit:
408
+ \`\`\`edit
409
+ file: src/config.ts
410
+ old: |
411
+ mode: 'code'
412
+ new: |
413
+ mode: 'plan'
414
+ \`\`\`
415
+
416
+ Create new file:
417
+ \`\`\`edit
418
+ file: src/new.ts
419
+ old: |
420
+ new: |
421
+ const x = 1
422
+ \`\`\`
126
423
 
127
- Create file: edit({ edits: [{ file_path: "src/new.ts", old_string: "", new_string: "const x = 1" }] })`,
424
+ CRITICAL RULES:
425
+ - old and new are separated by "old:" and "new:" labels
426
+ - Use | after label for multi-line content
427
+ - old_string must match EXACTLY (whitespace, indentation, line breaks)
428
+ - Do NOT include the "<lineNumber>: " prefix from read output`,
128
429
  parameters: {
129
- edits: {
130
- type: 'array',
131
- description:
132
- '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" }]',
430
+ content: {
431
+ type: 'string',
432
+ description: 'Markdown code block with edit instructions. See description for format.',
133
433
  required: true,
134
- items: {
135
- type: 'object',
136
- properties: {
137
- file_path: { type: 'string', description: 'Path to the file' },
138
- old_string: {
139
- type: 'string',
140
- description:
141
- 'Text to FIND in the file. REQUIRED. Pair with new_string to form a complete find-replace. Pass "" to create a new file.',
142
- },
143
- new_string: {
144
- type: 'string',
145
- description:
146
- 'Replacement text. REQUIRED. Pair with old_string to form a complete find-replace. Pass "" to delete content.',
147
- },
148
- },
149
- },
150
434
  },
151
435
  },
152
436
  },
153
437
  async execute(input): Promise<ToolResult> {
154
- // ── Auto-correction: detect common misuse patterns ────────────────
155
- // ── Debug: log raw input for diagnosing failures ─────────────
156
- const rawInput = JSON.parse(JSON.stringify(input))
438
+ // Debug: keep rawInput for error messages (capture early for all error paths)
439
+ const rawInput = input
157
440
 
158
- // Pattern 0: input is an array (edits passed directly instead of wrapped)
159
- if (Array.isArray(input)) {
160
- input = { edits: input }
161
- }
441
+ // ── Parse markdown format ─────────────────────────────────────────
442
+ let edits: Edit[] = []
162
443
 
163
- // Pattern 1: input has file_path, old_string, new_string at top level (missing edits array)
164
- if (!Array.isArray(input.edits)) {
165
- const keys = Object.keys(input)
166
-
167
- // Check if the keys look like a single edit object (file_path + old_string + new_string)
168
- const hasFilePath = typeof input.file_path === 'string'
169
- const hasOldString = typeof input.old_string === 'string'
170
- const hasNewString = typeof input.new_string === 'string'
171
-
172
- if (hasFilePath && hasOldString && hasNewString) {
173
- // Auto-correct: wrap into edits array
174
- input = {
175
- edits: [
176
- {
177
- file_path: input.file_path,
178
- old_string: input.old_string,
179
- new_string: input.new_string,
180
- },
181
- ],
182
- }
183
- } else if (hasFilePath && hasOldString) {
184
- // Only file_path + old_string (missing new_string) — still try
185
- input = {
186
- edits: [
187
- {
188
- file_path: input.file_path,
189
- old_string: input.old_string,
190
- new_string: input.new_string || '',
191
- },
192
- ],
193
- }
194
- } else if (keys.length === 1 && hasFilePath) {
195
- // Only file_path — maybe they meant create file with empty content?
196
- input = { edits: [{ file_path: input.file_path, old_string: '', new_string: '' }] }
197
- } else if (keys.length === 2 && hasFilePath && typeof input.new_string === 'string') {
198
- // file_path + new_string but no old_string — treat as new file creation
199
- input = {
200
- edits: [{ file_path: input.file_path, old_string: '', new_string: input.new_string }],
444
+ // If input has 'content' field (new markdown format)
445
+ if (typeof input.content === 'string') {
446
+ // Compat: handle double-JSON-wrapped content
447
+ let contentStr = input.content
448
+ try {
449
+ const parsed = JSON.parse(contentStr)
450
+ if (typeof parsed === 'string') {
451
+ contentStr = parsed
452
+ } else if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') {
453
+ contentStr = parsed.content
201
454
  }
202
- } else {
203
- // Can't auto-correctgive helpful error with examples
204
- const example = hasFilePath
205
- ? `edit({ edits: [{ file_path: "${input.file_path}", old_string: "...", new_string: "..." }] })`
206
- : `edit({ edits: [{ file_path: "src/file.ts", old_string: "old", new_string: "new" }] })`
455
+ } catch {
456
+ // Not JSONuse as-is
457
+ }
458
+ edits = parseMarkdownEdit(contentStr)
459
+ if (edits.length === 0) {
207
460
  return {
208
461
  success: false,
209
462
  output: '',
210
- error: `edit requires "edits" array. Received: ${JSON.stringify(rawInput)}. Usage: ${example}`,
463
+ error: `Failed to parse edit format. Raw input: ${summarizeRawInput(rawInput)}\nUse: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\``,
211
464
  }
212
465
  }
466
+ } else {
467
+ // Legacy JSON format (backward compatibility)
468
+ edits = extractEditsFromJSON(input as Record<string, unknown>)
213
469
  }
214
470
 
215
- const edits = input.edits as SingleEdit[]
216
471
  if (edits.length === 0) {
217
- // Build a diagnostic: show what the AI received vs expected
218
- const receivedKeys = Object.keys(rawInput)
219
- const hint =
220
- receivedKeys.length === 1 && receivedKeys[0] === 'edits'
221
- ? 'The edits array exists but is empty — include at least one edit object with file_path, old_string, and new_string.'
222
- : receivedKeys.includes('file_path')
223
- ? 'Top-level file_path/old_string/new_string detected wrap them in an edits array: { edits: [{ file_path, old_string, new_string }] }'
224
- : 'No usable edit data found in the input. Provide an edits array with at least one edit.'
472
+ // Check if input specifically had empty edits array (for better error message)
473
+ const inputEdits = (input as Record<string, unknown>).edits
474
+ if ('edits' in input && Array.isArray(inputEdits) && inputEdits.length === 0) {
475
+ return {
476
+ success: false,
477
+ output: '',
478
+ error: `edit FAILEDno edits to apply. The edits array exists but is empty. Raw input: ${summarizeRawInput(rawInput)}`,
479
+ }
480
+ }
481
+ // Check if input has edits key but it's not an array
482
+ if ('edits' in input && !Array.isArray((input as Record<string, unknown>).edits)) {
483
+ return {
484
+ success: false,
485
+ output: '',
486
+ error: `edit requires "edits" array. Use markdown format: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\`\nRaw input: ${summarizeRawInput(rawInput)}`,
487
+ }
488
+ }
225
489
  return {
226
490
  success: false,
227
491
  output: '',
228
- error: `edit FAILED no edits to apply. Raw input: ${JSON.stringify(rawInput)}. ${hint}`,
492
+ 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)}`,
229
493
  }
230
494
  }
231
495
 
232
- // Validate each edit object — catch missing old_string/new_string early
496
+ // Validate each edit object
233
497
  const editErrors: string[] = []
234
498
  for (let i = 0; i < edits.length; i++) {
235
499
  const e = edits[i]
@@ -237,9 +501,15 @@ EXAMPLES:
237
501
  if (typeof e.file_path !== 'string' || !e.file_path) missing.push('file_path')
238
502
  if (typeof e.old_string !== 'string') missing.push('old_string')
239
503
  if (typeof e.new_string !== 'string') missing.push('new_string')
504
+ if (e.new_string === '__MISSING_NEW_STRING__') {
505
+ missing.push('new_string (required — LLM must provide a non-empty replacement)')
506
+ }
240
507
  if (missing.length > 0) {
508
+ const present = Object.keys(e)
509
+ .filter(k => typeof e[k as keyof Edit] === 'string')
510
+ .join(', ')
241
511
  editErrors.push(
242
- ` edit #${i + 1}: missing ${missing.join(', ')} (has: ${Object.keys(e).join(', ')})`,
512
+ ` edit #${i + 1}: missing ${missing.join(', ')}${present ? ` (has: ${present})` : ''}`,
243
513
  )
244
514
  }
245
515
  }
@@ -247,8 +517,7 @@ EXAMPLES:
247
517
  return {
248
518
  success: false,
249
519
  output: '',
250
- 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.
251
- Do NOT split old_string (text to find) and new_string (replacement) across different edit objects.`,
520
+ 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.`,
252
521
  }
253
522
  }
254
523
 
@@ -288,37 +557,29 @@ Do NOT split old_string (text to find) and new_string (replacement) across diffe
288
557
 
289
558
  if (e.old_string === '') {
290
559
  if (content !== null) {
291
- const diag = JSON.stringify({
292
- file_path: e.file_path,
293
- old_string: '',
294
- new_string: e.new_string,
295
- })
296
- const suggestion = ` → Use edit with old_string to replace existing content, or choose a different path.`
297
- results.push(` FAIL ${relPath}: File already exists — Raw input: ${JSON.stringify(rawInput)}
298
- Edit: ${diag}
299
- ${suggestion}`)
560
+ // File already exists in-memory (from prior edit in this batch)
561
+ // or on disk — treat as error to prevent silent overwrites.
562
+ results.push(
563
+ ` FAIL ${relPath}: Cannot create — file already exists (duplicate create in batch or file on disk)`,
564
+ )
300
565
  if (!anyFailed) {
301
566
  anyFailed = true
302
- firstError = `File already exists: ${relPath}`
567
+ firstError = `Cannot create file "${relPath}": file already exists`
303
568
  }
304
569
  break
305
570
  }
306
571
  const newLines = e.new_string.split('\n').length
307
572
  content = e.new_string
308
573
  results.push(
309
- ` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string)}`,
574
+ ` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string, 1)}`,
310
575
  )
311
576
  continue
312
577
  }
313
578
 
314
579
  if (content === null) {
315
- const diag = JSON.stringify({
316
- file_path: e.file_path,
317
- old_string: e.old_string,
318
- new_string: e.new_string,
319
- })
580
+ const diag = buildDiag(e)
320
581
  const suggestion = ` → Check the file path, or create it first with old_string: "" (empty string).`
321
- results.push(` FAIL ${relPath}: File not found — Raw input: ${JSON.stringify(rawInput)}
582
+ results.push(` FAIL ${relPath}: File not found
322
583
  Edit: ${diag}
323
584
  ${suggestion}`)
324
585
  if (!anyFailed) {
@@ -338,13 +599,9 @@ ${suggestion}`)
338
599
  // Exact match found — check for duplicates
339
600
  const lastIdx = content.lastIndexOf(e.old_string)
340
601
  if (exactIdx !== lastIdx) {
341
- const diag = JSON.stringify({
342
- file_path: e.file_path,
343
- old_string: e.old_string,
344
- new_string: e.new_string,
345
- })
602
+ const diag = buildDiag(e)
346
603
  const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
347
- results.push(` FAIL ${relPath}: old_string appears MULTIPLE times — Raw input: ${JSON.stringify(rawInput)}
604
+ results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
348
605
  Edit: ${diag}
349
606
  ${suggestion}`)
350
607
  if (!anyFailed) {
@@ -380,15 +637,11 @@ ${suggestion}`)
380
637
  const lines = contentLines.slice(0, Math.min(contentLines.length, 5))
381
638
  hint = `\n File content (first ${lines.length} lines):\n """\n${lines.join('\n')}\n """`
382
639
  }
383
- const diag = JSON.stringify({
384
- file_path: e.file_path,
385
- old_string: e.old_string,
386
- new_string: e.new_string,
387
- })
640
+ const diag = buildDiag(e)
388
641
  const readHint = readWarning ? `\n ${readWarning}` : ''
389
642
  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}`
390
- results.push(` FAIL ${relPath}: old_string not found — Raw input: ${JSON.stringify(rawInput)}
391
- Edit: ${diag}${hint}
643
+ results.push(` FAIL ${relPath}: old_string not found${hint}
644
+ Edit: ${diag}
392
645
  ${suggestion}`)
393
646
  if (!anyFailed) {
394
647
  anyFailed = true
@@ -397,14 +650,10 @@ ${suggestion}`)
397
650
  break
398
651
  }
399
652
  if (tolerant.length > 1) {
400
- const diag = JSON.stringify({
401
- file_path: e.file_path,
402
- old_string: e.old_string,
403
- new_string: e.new_string,
404
- })
653
+ const diag = buildDiag(e)
405
654
  const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
406
655
  results.push(
407
- ` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized) — Raw input: ${JSON.stringify(rawInput)}\n Edit: ${diag}\n${suggestion}`,
656
+ ` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)\n Edit: ${diag}\n${suggestion}`,
408
657
  )
409
658
  if (!anyFailed) {
410
659
  anyFailed = true
@@ -419,9 +668,20 @@ ${suggestion}`)
419
668
  }
420
669
  }
421
670
 
422
- const strategyLabel = matchInfo.strategy === 'tolerant' ? ' (whitespace-normalized)' : ''
423
- const diff = generateDiff(e.old_string, e.new_string)
424
- results.push(` Edited ${relPath}${strategyLabel}:\n${diff}`)
671
+ // For tolerant matching, use the actual file text at the match position
672
+ // so the diff reflects what was really in the file (with original whitespace).
673
+ const matchedOld =
674
+ matchInfo.strategy === 'tolerant'
675
+ ? content.slice(matchInfo.index, matchInfo.index + matchInfo.length)
676
+ : e.old_string
677
+ const diff = generateDiffWithContext(
678
+ content,
679
+ matchedOld,
680
+ e.new_string,
681
+ matchInfo.index,
682
+ matchInfo.length,
683
+ )
684
+ results.push(` Edited ${relPath}:\n${diff}`)
425
685
  content =
426
686
  content.slice(0, matchInfo.index) +
427
687
  e.new_string +
@@ -456,7 +716,7 @@ ${suggestion}`)
456
716
  return {
457
717
  success: false,
458
718
  output: '',
459
- error: `Edit FAILED — all changes rolled back. Raw input: ${JSON.stringify(rawInput)}\n${failLines}\n\n${firstError}`,
719
+ error: `Edit FAILED — all changes rolled back.\n${failLines}\n\n${firstError}`,
460
720
  }
461
721
  }
462
722
 
@@ -472,7 +732,7 @@ ${suggestion}`)
472
732
  return {
473
733
  success: false,
474
734
  output: '',
475
- error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${JSON.stringify(rawInput)}`,
735
+ error: `Failed to write ${path.relative(cwd, resolved).replace(/\\/g, '/')}: ${fmtErr(err)}. Input: ${summarizeRawInput(rawInput)}`,
476
736
  }
477
737
  }
478
738
  applier.markRead(resolved)