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.
- package/README.md +326 -326
- package/dist/agent/index.js +1 -1
- package/dist/agent/index.js.map +1 -1
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/project.d.ts +26 -0
- package/dist/agent/project.d.ts.map +1 -0
- package/dist/agent/project.js +303 -0
- package/dist/agent/project.js.map +1 -0
- package/dist/agent/prompt-builder.d.ts +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +22 -13
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/anthropic.d.ts.map +1 -1
- package/dist/agent/providers/anthropic.js +20 -2
- package/dist/agent/providers/anthropic.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/ollama.d.ts.map +1 -1
- package/dist/agent/providers/ollama.js +2 -1
- package/dist/agent/providers/ollama.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +63 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +8 -2
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +250 -153
- 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 +58 -0
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +366 -149
- 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/read.d.ts +6 -0
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +42 -15
- package/dist/tools/read.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 -33
- 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/tools/write_plan.d.ts +1 -1
- package/dist/tools/write_plan.d.ts.map +1 -1
- package/dist/tools/write_plan.js +14 -4
- package/dist/tools/write_plan.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +5 -4
- package/dist/tui/index.js.map +1 -1
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +30 -7
- 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/project.ts +366 -0
- package/src/agent/prompt-builder.ts +23 -13
- package/src/agent/providers/anthropic.ts +179 -172
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -182
- package/src/agent/providers/openai.ts +73 -5
- package/src/agent/session.ts +275 -153
- 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 +414 -154
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/read.ts +61 -25
- package/src/tools/registry.ts +231 -265
- package/src/tools/write_plan.ts +21 -5
- package/src/tui/index.ts +786 -785
- package/src/web/index.ts +29 -7
- 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/.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/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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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, '&')
|
|
106
|
+
.replace(/</g, '<')
|
|
107
|
+
.replace(/>/g, '>')
|
|
108
|
+
.replace(/"/g, '"')
|
|
109
|
+
.replace(/'/g, ''')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
395
|
+
3. Use markdown code block format below
|
|
110
396
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
type: '
|
|
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
|
-
//
|
|
155
|
-
|
|
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
|
-
//
|
|
159
|
-
|
|
160
|
-
input = { edits: input }
|
|
161
|
-
}
|
|
441
|
+
// ── Parse markdown format ─────────────────────────────────────────
|
|
442
|
+
let edits: Edit[] = []
|
|
162
443
|
|
|
163
|
-
//
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
}
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
455
|
+
} catch {
|
|
456
|
+
// Not JSON — use as-is
|
|
457
|
+
}
|
|
458
|
+
edits = parseMarkdownEdit(contentStr)
|
|
459
|
+
if (edits.length === 0) {
|
|
207
460
|
return {
|
|
208
461
|
success: false,
|
|
209
462
|
output: '',
|
|
210
|
-
error: `
|
|
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
|
-
//
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
:
|
|
223
|
-
|
|
224
|
-
|
|
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 FAILED — no 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
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 = `
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
391
|
-
Edit: ${diag}
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
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: ${
|
|
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)
|