lonny-agent 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -326
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +13 -9
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +43 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +4 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +45 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +1 -1
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +875 -371
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +32 -0
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +83 -10
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +50 -9
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +328 -158
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +54 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/registry.d.ts +0 -6
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -29
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +26 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +395 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +996 -949
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +3 -7
- package/dist/web/session-bridge.js.map +1 -1
- package/package.json +55 -54
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/index.ts +25 -25
- package/src/agent/llm.ts +4 -0
- package/src/agent/prompt-builder.ts +13 -9
- package/src/agent/providers/anthropic.ts +179 -179
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -186
- package/src/agent/providers/openai.ts +61 -5
- package/src/agent/session.ts +59 -3
- package/src/config/index.ts +342 -335
- package/src/tools/__tests__/bash.test.ts +1 -1
- package/src/tools/__tests__/edit.test.ts +981 -385
- package/src/tools/__tests__/grep.test.ts +35 -0
- package/src/tools/bash.ts +97 -11
- package/src/tools/edit.ts +362 -169
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/registry.ts +2 -31
- package/src/tui/index.ts +786 -786
- package/src/web/index.ts +25 -3
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +395 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +996 -949
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +193 -194
- package/.claude/settings.local.json +0 -14
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- package/src/tools/exec.ts +0 -348
package/src/tools/edit.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
8
|
-
export type DiffLineType = '
|
|
9
|
+
export type DiffLineType = 'delete' | 'insert' | 'equal'
|
|
9
10
|
|
|
10
11
|
export interface DiffLine {
|
|
11
|
-
lineNum: number
|
|
12
12
|
type: DiffLineType
|
|
13
13
|
content: string
|
|
14
14
|
}
|
|
@@ -16,11 +16,13 @@ export interface DiffLine {
|
|
|
16
16
|
// ── ANSI colors for terminal output ───────────────────────────────────────
|
|
17
17
|
const DIFF_RED = '\x1b[38;2;255;80;80m'
|
|
18
18
|
const DIFF_GREEN = '\x1b[38;2;0;200;100m'
|
|
19
|
+
const DIFF_DIM = '\x1b[38;2;100;100;100m'
|
|
19
20
|
const DIFF_RESET = '\x1b[0m'
|
|
20
21
|
|
|
21
|
-
// ──
|
|
22
|
+
// ── Colors for HTML output ────────────────────────────────────────────────
|
|
22
23
|
const HTML_RED = '#ff5050'
|
|
23
24
|
const HTML_GREEN = '#00c864'
|
|
25
|
+
const HTML_DIM = '#888888'
|
|
24
26
|
|
|
25
27
|
/** Build diagnostic JSON for error messages */
|
|
26
28
|
function buildDiag(edit: SingleEdit): string {
|
|
@@ -31,35 +33,51 @@ function buildDiag(edit: SingleEdit): string {
|
|
|
31
33
|
})
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for (let i = 0; i < oldLines.length; i++) {
|
|
41
|
-
lines.push({ lineNum: startLine + i, type: 'old', content: oldLines[i] })
|
|
42
|
-
}
|
|
43
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
44
|
-
lines.push({ lineNum: startLine + i, type: 'new', content: newLines[i] })
|
|
45
|
-
}
|
|
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
|
+
}
|
|
46
42
|
|
|
47
|
-
|
|
43
|
+
/** Compute diff lines using jest-diff for proper line-level diffs */
|
|
44
|
+
export function computeDiff(oldStr: string, newStr: string): DiffLine[] {
|
|
45
|
+
const oldLines = oldStr === '' ? [] : oldStr.split('\n')
|
|
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
|
+
}))
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
/** Terminal renderer */
|
|
51
|
-
export function renderDiffTerminal(lines: DiffLine[]): string {
|
|
62
|
+
/** Terminal renderer — unified-diff format with color */
|
|
63
|
+
export function renderDiffTerminal(lines: DiffLine[], startLineNum?: number): string {
|
|
52
64
|
if (lines.length === 0) return ''
|
|
53
65
|
|
|
54
|
-
const maxLineNum = Math.max(...lines.map(l => l.lineNum))
|
|
55
|
-
const lineNumWidth = String(maxLineNum).length
|
|
56
|
-
const fmtLineNum = (n: number) => String(n).padStart(lineNumWidth, ' ')
|
|
57
|
-
|
|
58
66
|
const output: string[] = []
|
|
67
|
+
let oldLn = startLineNum ?? 1
|
|
68
|
+
let newLn = startLineNum ?? 1
|
|
59
69
|
for (const line of lines) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
|
63
81
|
}
|
|
64
82
|
return output.join('\n')
|
|
65
83
|
}
|
|
@@ -68,37 +86,109 @@ export function renderDiffTerminal(lines: DiffLine[]): string {
|
|
|
68
86
|
export function renderDiffHtml(lines: DiffLine[]): string {
|
|
69
87
|
if (lines.length === 0) return ''
|
|
70
88
|
|
|
71
|
-
const maxLineNum = Math.max(...lines.map(l => l.lineNum))
|
|
72
|
-
const lineNumWidth = String(maxLineNum).length
|
|
73
|
-
const fmtLineNum = (n: number) => String(n).padStart(lineNumWidth, ' ')
|
|
74
|
-
|
|
75
89
|
const output: string[] = []
|
|
76
90
|
for (const line of lines) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
` <span style="${
|
|
81
|
-
|
|
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
|
+
}
|
|
82
98
|
}
|
|
83
99
|
return output.join('\n')
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
/** Escape HTML special characters */
|
|
87
|
-
function escapeHtml(str: string): string {
|
|
103
|
+
export function escapeHtml(str: string): string {
|
|
88
104
|
return str
|
|
89
105
|
.replace(/&/g, '&')
|
|
90
106
|
.replace(/</g, '<')
|
|
91
107
|
.replace(/>/g, '>')
|
|
92
108
|
.replace(/"/g, '"')
|
|
109
|
+
.replace(/'/g, ''')
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
/**
|
|
96
|
-
* Generate diff output
|
|
97
|
-
*
|
|
113
|
+
* Generate diff output using jest-diff.
|
|
114
|
+
* Returns terminal-colored unified-diff format with line numbers.
|
|
98
115
|
*/
|
|
99
|
-
export function generateDiff(oldStr: string, newStr: string,
|
|
100
|
-
const lines = computeDiff(oldStr, newStr
|
|
101
|
-
return renderDiffTerminal(lines)
|
|
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')
|
|
102
192
|
}
|
|
103
193
|
|
|
104
194
|
/**
|
|
@@ -111,7 +201,8 @@ export function generateDiff(oldStr: string, newStr: string, startLine = 0): str
|
|
|
111
201
|
* - Extra internal spaces → `"foo bar"` → `"foo bar"`
|
|
112
202
|
* - Blank lines with spaces → `" "` → `""`
|
|
113
203
|
*/
|
|
114
|
-
|
|
204
|
+
/** Export for testing */
|
|
205
|
+
export function normalizeLine(s: string): string {
|
|
115
206
|
return s.trim().replace(/[ \t]{2,}/g, ' ')
|
|
116
207
|
}
|
|
117
208
|
|
|
@@ -128,7 +219,8 @@ interface MatchPos {
|
|
|
128
219
|
* so the caller can do content.slice(match.index, match.index + match.length)
|
|
129
220
|
* to extract the actual matched text (with its original whitespace).
|
|
130
221
|
*/
|
|
131
|
-
|
|
222
|
+
/** Export for testing */
|
|
223
|
+
export function findAllLinesTolerant(content: string, oldString: string): MatchPos[] {
|
|
132
224
|
if (oldString === '') return []
|
|
133
225
|
|
|
134
226
|
const contentLines = content.split('\n')
|
|
@@ -175,147 +267,233 @@ interface SingleEdit {
|
|
|
175
267
|
new_string: string
|
|
176
268
|
}
|
|
177
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
|
+
|
|
178
386
|
export function createEditTool(applier: FileReadTracker, cwd: string): Tool {
|
|
179
387
|
return {
|
|
180
388
|
definition: {
|
|
181
389
|
name: 'edit',
|
|
182
|
-
description: `Replace exact text in files
|
|
183
|
-
Parameter: {"edits": [{"file_path": "...", "old_string": "...", "new_string": "..."}]}
|
|
390
|
+
description: `Replace exact text in files using markdown code block format.
|
|
184
391
|
|
|
185
392
|
HOW TO USE:
|
|
186
393
|
1. Read the file first with \`read\`
|
|
187
394
|
2. Copy the EXACT text to replace — include 2-3 lines of context before/after
|
|
188
|
-
3.
|
|
395
|
+
3. Use markdown code block format below
|
|
189
396
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
397
|
+
FORMAT:
|
|
398
|
+
\`\`\`edit
|
|
399
|
+
file: <file_path>
|
|
400
|
+
old: |
|
|
401
|
+
<exact text to find>
|
|
402
|
+
new: |
|
|
403
|
+
<replacement text>
|
|
404
|
+
\`\`\`
|
|
197
405
|
|
|
198
406
|
EXAMPLES:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
\`\`\`
|
|
205
423
|
|
|
206
|
-
|
|
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`,
|
|
207
429
|
parameters: {
|
|
208
|
-
|
|
209
|
-
type: '
|
|
210
|
-
|
|
211
|
-
description:
|
|
212
|
-
'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.',
|
|
213
433
|
required: true,
|
|
214
|
-
items: {
|
|
215
|
-
type: 'object',
|
|
216
|
-
properties: {
|
|
217
|
-
file_path: { type: 'string', description: 'Path to the file' },
|
|
218
|
-
old_string: {
|
|
219
|
-
type: 'string',
|
|
220
|
-
description:
|
|
221
|
-
'Text to FIND in the file. REQUIRED. Pair with new_string to form a complete find-replace. Pass "" to create a new file.',
|
|
222
|
-
},
|
|
223
|
-
new_string: {
|
|
224
|
-
type: 'string',
|
|
225
|
-
description:
|
|
226
|
-
'Replacement text. REQUIRED. Pair with old_string to form a complete find-replace. Pass "" to delete content.',
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
},
|
|
230
434
|
},
|
|
231
435
|
},
|
|
232
436
|
},
|
|
233
437
|
async execute(input): Promise<ToolResult> {
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
let rawInput: unknown = input
|
|
237
|
-
try {
|
|
238
|
-
rawInput = structuredClone(input)
|
|
239
|
-
} catch {
|
|
240
|
-
console.error('[edit] Failed to clone input:', input)
|
|
241
|
-
rawInput = input
|
|
242
|
-
}
|
|
438
|
+
// Debug: keep rawInput for error messages (capture early for all error paths)
|
|
439
|
+
const rawInput = input
|
|
243
440
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
input = { edits: input }
|
|
247
|
-
}
|
|
441
|
+
// ── Parse markdown format ─────────────────────────────────────────
|
|
442
|
+
let edits: Edit[] = []
|
|
248
443
|
|
|
249
|
-
//
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Auto-correct: wrap into edits array
|
|
260
|
-
input = {
|
|
261
|
-
edits: [
|
|
262
|
-
{
|
|
263
|
-
file_path: input.file_path,
|
|
264
|
-
old_string: input.old_string,
|
|
265
|
-
new_string: input.new_string,
|
|
266
|
-
},
|
|
267
|
-
],
|
|
268
|
-
}
|
|
269
|
-
} else if (hasFilePath && hasOldString) {
|
|
270
|
-
// Only file_path + old_string (missing new_string) — still try
|
|
271
|
-
input = {
|
|
272
|
-
edits: [
|
|
273
|
-
{
|
|
274
|
-
file_path: input.file_path,
|
|
275
|
-
old_string: input.old_string,
|
|
276
|
-
new_string: input.new_string || '',
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
}
|
|
280
|
-
} else if (keys.length === 1 && hasFilePath) {
|
|
281
|
-
// Only file_path — maybe they meant create file with empty content?
|
|
282
|
-
input = { edits: [{ file_path: input.file_path, old_string: '', new_string: '' }] }
|
|
283
|
-
} else if (keys.length === 2 && hasFilePath && typeof input.new_string === 'string') {
|
|
284
|
-
// file_path + new_string but no old_string — treat as new file creation
|
|
285
|
-
input = {
|
|
286
|
-
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
|
|
287
454
|
}
|
|
288
|
-
}
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
455
|
+
} catch {
|
|
456
|
+
// Not JSON — use as-is
|
|
457
|
+
}
|
|
458
|
+
edits = parseMarkdownEdit(contentStr)
|
|
459
|
+
if (edits.length === 0) {
|
|
293
460
|
return {
|
|
294
461
|
success: false,
|
|
295
462
|
output: '',
|
|
296
|
-
error: `
|
|
463
|
+
error: `Failed to parse edit format. Raw input: ${summarizeRawInput(rawInput)}\nUse: \`\`\`edit\\nfile: path\\nold: |\\ntext\\nnew: |\\ntext\\n\`\`\``,
|
|
297
464
|
}
|
|
298
465
|
}
|
|
466
|
+
} else {
|
|
467
|
+
// Legacy JSON format (backward compatibility)
|
|
468
|
+
edits = extractEditsFromJSON(input as Record<string, unknown>)
|
|
299
469
|
}
|
|
300
470
|
|
|
301
|
-
const edits = input.edits as SingleEdit[]
|
|
302
471
|
if (edits.length === 0) {
|
|
303
|
-
//
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
:
|
|
309
|
-
|
|
310
|
-
|
|
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
|
+
}
|
|
311
489
|
return {
|
|
312
490
|
success: false,
|
|
313
491
|
output: '',
|
|
314
|
-
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)}`,
|
|
315
493
|
}
|
|
316
494
|
}
|
|
317
495
|
|
|
318
|
-
// Validate each edit object
|
|
496
|
+
// Validate each edit object
|
|
319
497
|
const editErrors: string[] = []
|
|
320
498
|
for (let i = 0; i < edits.length; i++) {
|
|
321
499
|
const e = edits[i]
|
|
@@ -323,9 +501,15 @@ EXAMPLES:
|
|
|
323
501
|
if (typeof e.file_path !== 'string' || !e.file_path) missing.push('file_path')
|
|
324
502
|
if (typeof e.old_string !== 'string') missing.push('old_string')
|
|
325
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
|
+
}
|
|
326
507
|
if (missing.length > 0) {
|
|
508
|
+
const present = Object.keys(e)
|
|
509
|
+
.filter(k => typeof e[k as keyof Edit] === 'string')
|
|
510
|
+
.join(', ')
|
|
327
511
|
editErrors.push(
|
|
328
|
-
` edit #${i + 1}: missing ${missing.join(', ')} (has: ${
|
|
512
|
+
` edit #${i + 1}: missing ${missing.join(', ')}${present ? ` (has: ${present})` : ''}`,
|
|
329
513
|
)
|
|
330
514
|
}
|
|
331
515
|
}
|
|
@@ -333,8 +517,7 @@ EXAMPLES:
|
|
|
333
517
|
return {
|
|
334
518
|
success: false,
|
|
335
519
|
output: '',
|
|
336
|
-
error: `edit FAILED — ${editErrors.length} of ${edits.length} edit(s) have missing fields.\n${editErrors.join('\n')}\n\nReceived: ${
|
|
337
|
-
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.`,
|
|
338
521
|
}
|
|
339
522
|
}
|
|
340
523
|
|
|
@@ -374,21 +557,21 @@ Do NOT split old_string (text to find) and new_string (replacement) across diffe
|
|
|
374
557
|
|
|
375
558
|
if (e.old_string === '') {
|
|
376
559
|
if (content !== null) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
results.push(
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
)
|
|
382
565
|
if (!anyFailed) {
|
|
383
566
|
anyFailed = true
|
|
384
|
-
firstError = `
|
|
567
|
+
firstError = `Cannot create file "${relPath}": file already exists`
|
|
385
568
|
}
|
|
386
569
|
break
|
|
387
570
|
}
|
|
388
571
|
const newLines = e.new_string.split('\n').length
|
|
389
572
|
content = e.new_string
|
|
390
573
|
results.push(
|
|
391
|
-
` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string)}`,
|
|
574
|
+
` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string, 1)}`,
|
|
392
575
|
)
|
|
393
576
|
continue
|
|
394
577
|
}
|
|
@@ -396,7 +579,7 @@ ${suggestion}`)
|
|
|
396
579
|
if (content === null) {
|
|
397
580
|
const diag = buildDiag(e)
|
|
398
581
|
const suggestion = ` → Check the file path, or create it first with old_string: "" (empty string).`
|
|
399
|
-
results.push(` FAIL ${relPath}: File not found
|
|
582
|
+
results.push(` FAIL ${relPath}: File not found
|
|
400
583
|
Edit: ${diag}
|
|
401
584
|
${suggestion}`)
|
|
402
585
|
if (!anyFailed) {
|
|
@@ -418,7 +601,7 @@ ${suggestion}`)
|
|
|
418
601
|
if (exactIdx !== lastIdx) {
|
|
419
602
|
const diag = buildDiag(e)
|
|
420
603
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
|
|
421
|
-
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
|
|
604
|
+
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times
|
|
422
605
|
Edit: ${diag}
|
|
423
606
|
${suggestion}`)
|
|
424
607
|
if (!anyFailed) {
|
|
@@ -457,8 +640,8 @@ ${suggestion}`)
|
|
|
457
640
|
const diag = buildDiag(e)
|
|
458
641
|
const readHint = readWarning ? `\n ${readWarning}` : ''
|
|
459
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}`
|
|
460
|
-
results.push(` FAIL ${relPath}: old_string not found
|
|
461
|
-
Edit: ${diag}
|
|
643
|
+
results.push(` FAIL ${relPath}: old_string not found${hint}
|
|
644
|
+
Edit: ${diag}
|
|
462
645
|
${suggestion}`)
|
|
463
646
|
if (!anyFailed) {
|
|
464
647
|
anyFailed = true
|
|
@@ -470,7 +653,7 @@ ${suggestion}`)
|
|
|
470
653
|
const diag = buildDiag(e)
|
|
471
654
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
|
|
472
655
|
results.push(
|
|
473
|
-
` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)
|
|
656
|
+
` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized)\n Edit: ${diag}\n${suggestion}`,
|
|
474
657
|
)
|
|
475
658
|
if (!anyFailed) {
|
|
476
659
|
anyFailed = true
|
|
@@ -485,10 +668,20 @@ ${suggestion}`)
|
|
|
485
668
|
}
|
|
486
669
|
}
|
|
487
670
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
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}`)
|
|
492
685
|
content =
|
|
493
686
|
content.slice(0, matchInfo.index) +
|
|
494
687
|
e.new_string +
|
|
@@ -523,7 +716,7 @@ ${suggestion}`)
|
|
|
523
716
|
return {
|
|
524
717
|
success: false,
|
|
525
718
|
output: '',
|
|
526
|
-
error: `Edit FAILED — all changes rolled back
|
|
719
|
+
error: `Edit FAILED — all changes rolled back.\n${failLines}\n\n${firstError}`,
|
|
527
720
|
}
|
|
528
721
|
}
|
|
529
722
|
|
|
@@ -539,7 +732,7 @@ ${suggestion}`)
|
|
|
539
732
|
return {
|
|
540
733
|
success: false,
|
|
541
734
|
output: '',
|
|
542
|
-
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)}`,
|
|
543
736
|
}
|
|
544
737
|
}
|
|
545
738
|
applier.markRead(resolved)
|