lonny-agent 0.2.2 → 0.2.4
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/.claude/settings.local.json +14 -0
- package/dist/agent/index.js +1 -1
- package/dist/agent/index.js.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 +10 -5
- 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.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 +23 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +4 -2
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +206 -147
- package/dist/agent/session.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +14 -14
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/edit.d.ts +17 -0
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +81 -34
- package/dist/tools/edit.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.map +1 -1
- package/dist/tools/registry.js +0 -4
- package/dist/tools/registry.js.map +1 -1
- 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.js +5 -5
- package/dist/web/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +1 -1
- package/src/agent/project.ts +366 -0
- package/src/agent/prompt-builder.ts +11 -5
- package/src/agent/providers/anthropic.ts +21 -2
- package/src/agent/providers/google.ts +1 -1
- package/src/agent/providers/ollama.ts +5 -1
- package/src/agent/providers/openai.ts +27 -3
- package/src/agent/session.ts +216 -150
- package/src/tools/__tests__/edit.test.ts +14 -14
- package/src/tools/edit.ts +103 -36
- package/src/tools/read.ts +61 -25
- package/src/tools/registry.ts +260 -265
- package/src/tools/write_plan.ts +21 -5
- package/src/tui/index.ts +5 -4
- package/src/web/index.ts +5 -5
package/src/tools/edit.ts
CHANGED
|
@@ -4,22 +4,101 @@ import type { FileReadTracker } from '../diff/apply.js'
|
|
|
4
4
|
import { fmtErr } from './errors.js'
|
|
5
5
|
import type { Tool, ToolResult } from './types.js'
|
|
6
6
|
|
|
7
|
-
// ──
|
|
7
|
+
// ── Diff types ────────────────────────────────────────────────────────────
|
|
8
|
+
export type DiffLineType = 'old' | 'new'
|
|
9
|
+
|
|
10
|
+
export interface DiffLine {
|
|
11
|
+
lineNum: number
|
|
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'
|
|
10
19
|
const DIFF_RESET = '\x1b[0m'
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
// ── ANSI colors for HTML output ───────────────────────────────────────────
|
|
22
|
+
const HTML_RED = '#ff5050'
|
|
23
|
+
const HTML_GREEN = '#00c864'
|
|
24
|
+
|
|
25
|
+
/** Build diagnostic JSON for error messages */
|
|
26
|
+
function buildDiag(edit: SingleEdit): string {
|
|
27
|
+
return JSON.stringify({
|
|
28
|
+
file_path: edit.file_path,
|
|
29
|
+
old_string: edit.old_string,
|
|
30
|
+
new_string: edit.new_string,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compute diff lines (pure data, no rendering) */
|
|
35
|
+
export function computeDiff(oldStr: string, newStr: string, startLine = 0): DiffLine[] {
|
|
13
36
|
const oldLines = oldStr === '' ? [] : oldStr.split('\n')
|
|
14
37
|
const newLines = newStr.split('\n')
|
|
15
|
-
const lines:
|
|
16
|
-
|
|
17
|
-
|
|
38
|
+
const lines: DiffLine[] = []
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
return lines
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Terminal renderer */
|
|
51
|
+
export function renderDiffTerminal(lines: DiffLine[]): string {
|
|
52
|
+
if (lines.length === 0) return ''
|
|
53
|
+
|
|
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
|
+
const output: string[] = []
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const color = line.type === 'old' ? DIFF_RED : DIFF_GREEN
|
|
61
|
+
const reset = DIFF_RESET
|
|
62
|
+
output.push(` ${fmtLineNum(line.lineNum)} ${color}${line.content}${reset}`)
|
|
18
63
|
}
|
|
19
|
-
|
|
20
|
-
|
|
64
|
+
return output.join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** HTML renderer for web output */
|
|
68
|
+
export function renderDiffHtml(lines: DiffLine[]): string {
|
|
69
|
+
if (lines.length === 0) return ''
|
|
70
|
+
|
|
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
|
+
const output: string[] = []
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const color = line.type === 'old' ? HTML_RED : HTML_GREEN
|
|
78
|
+
const style = `color: ${color};`
|
|
79
|
+
output.push(
|
|
80
|
+
` <span style="${style}">${fmtLineNum(line.lineNum)} ${escapeHtml(line.content)}</span>`,
|
|
81
|
+
)
|
|
21
82
|
}
|
|
22
|
-
return
|
|
83
|
+
return output.join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Escape HTML special characters */
|
|
87
|
+
function escapeHtml(str: string): string {
|
|
88
|
+
return str
|
|
89
|
+
.replace(/&/g, '&')
|
|
90
|
+
.replace(/</g, '<')
|
|
91
|
+
.replace(/>/g, '>')
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate diff output (backward compatible).
|
|
97
|
+
* Delegates to terminal renderer by default.
|
|
98
|
+
*/
|
|
99
|
+
export function generateDiff(oldStr: string, newStr: string, startLine = 0): string {
|
|
100
|
+
const lines = computeDiff(oldStr, newStr, startLine)
|
|
101
|
+
return renderDiffTerminal(lines)
|
|
23
102
|
}
|
|
24
103
|
|
|
25
104
|
/**
|
|
@@ -128,6 +207,7 @@ EXAMPLES:
|
|
|
128
207
|
parameters: {
|
|
129
208
|
edits: {
|
|
130
209
|
type: 'array',
|
|
210
|
+
minItems: 1,
|
|
131
211
|
description:
|
|
132
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" }]',
|
|
133
213
|
required: true,
|
|
@@ -153,7 +233,13 @@ EXAMPLES:
|
|
|
153
233
|
async execute(input): Promise<ToolResult> {
|
|
154
234
|
// ── Auto-correction: detect common misuse patterns ────────────────
|
|
155
235
|
// ── Debug: log raw input for diagnosing failures ─────────────
|
|
156
|
-
|
|
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
|
+
}
|
|
157
243
|
|
|
158
244
|
// Pattern 0: input is an array (edits passed directly instead of wrapped)
|
|
159
245
|
if (Array.isArray(input)) {
|
|
@@ -215,7 +301,7 @@ EXAMPLES:
|
|
|
215
301
|
const edits = input.edits as SingleEdit[]
|
|
216
302
|
if (edits.length === 0) {
|
|
217
303
|
// Build a diagnostic: show what the AI received vs expected
|
|
218
|
-
const receivedKeys = Object.keys(rawInput)
|
|
304
|
+
const receivedKeys = Object.keys(rawInput as Record<string, unknown>)
|
|
219
305
|
const hint =
|
|
220
306
|
receivedKeys.length === 1 && receivedKeys[0] === 'edits'
|
|
221
307
|
? 'The edits array exists but is empty — include at least one edit object with file_path, old_string, and new_string.'
|
|
@@ -288,11 +374,7 @@ Do NOT split old_string (text to find) and new_string (replacement) across diffe
|
|
|
288
374
|
|
|
289
375
|
if (e.old_string === '') {
|
|
290
376
|
if (content !== null) {
|
|
291
|
-
const diag =
|
|
292
|
-
file_path: e.file_path,
|
|
293
|
-
old_string: '',
|
|
294
|
-
new_string: e.new_string,
|
|
295
|
-
})
|
|
377
|
+
const diag = buildDiag({ ...e, old_string: '' })
|
|
296
378
|
const suggestion = ` → Use edit with old_string to replace existing content, or choose a different path.`
|
|
297
379
|
results.push(` FAIL ${relPath}: File already exists — Raw input: ${JSON.stringify(rawInput)}
|
|
298
380
|
Edit: ${diag}
|
|
@@ -312,11 +394,7 @@ ${suggestion}`)
|
|
|
312
394
|
}
|
|
313
395
|
|
|
314
396
|
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
|
-
})
|
|
397
|
+
const diag = buildDiag(e)
|
|
320
398
|
const suggestion = ` → Check the file path, or create it first with old_string: "" (empty string).`
|
|
321
399
|
results.push(` FAIL ${relPath}: File not found — Raw input: ${JSON.stringify(rawInput)}
|
|
322
400
|
Edit: ${diag}
|
|
@@ -338,11 +416,7 @@ ${suggestion}`)
|
|
|
338
416
|
// Exact match found — check for duplicates
|
|
339
417
|
const lastIdx = content.lastIndexOf(e.old_string)
|
|
340
418
|
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
|
-
})
|
|
419
|
+
const diag = buildDiag(e)
|
|
346
420
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
|
|
347
421
|
results.push(` FAIL ${relPath}: old_string appears MULTIPLE times — Raw input: ${JSON.stringify(rawInput)}
|
|
348
422
|
Edit: ${diag}
|
|
@@ -380,11 +454,7 @@ ${suggestion}`)
|
|
|
380
454
|
const lines = contentLines.slice(0, Math.min(contentLines.length, 5))
|
|
381
455
|
hint = `\n File content (first ${lines.length} lines):\n """\n${lines.join('\n')}\n """`
|
|
382
456
|
}
|
|
383
|
-
const diag =
|
|
384
|
-
file_path: e.file_path,
|
|
385
|
-
old_string: e.old_string,
|
|
386
|
-
new_string: e.new_string,
|
|
387
|
-
})
|
|
457
|
+
const diag = buildDiag(e)
|
|
388
458
|
const readHint = readWarning ? `\n ${readWarning}` : ''
|
|
389
459
|
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
460
|
results.push(` FAIL ${relPath}: old_string not found — Raw input: ${JSON.stringify(rawInput)}
|
|
@@ -397,11 +467,7 @@ ${suggestion}`)
|
|
|
397
467
|
break
|
|
398
468
|
}
|
|
399
469
|
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
|
-
})
|
|
470
|
+
const diag = buildDiag(e)
|
|
405
471
|
const suggestion = ` → Include more surrounding context lines (2-3 lines before and after) in old_string to make the match unique.`
|
|
406
472
|
results.push(
|
|
407
473
|
` FAIL ${relPath}: old_string appears MULTIPLE times (whitespace-normalized) — Raw input: ${JSON.stringify(rawInput)}\n Edit: ${diag}\n${suggestion}`,
|
|
@@ -420,7 +486,8 @@ ${suggestion}`)
|
|
|
420
486
|
}
|
|
421
487
|
|
|
422
488
|
const strategyLabel = matchInfo.strategy === 'tolerant' ? ' (whitespace-normalized)' : ''
|
|
423
|
-
const
|
|
489
|
+
const matchLineNum = content.substring(0, matchInfo.index).split('\n').length
|
|
490
|
+
const diff = generateDiff(e.old_string, e.new_string, matchLineNum - 1)
|
|
424
491
|
results.push(` Edited ${relPath}${strategyLabel}:\n${diff}`)
|
|
425
492
|
content =
|
|
426
493
|
content.slice(0, matchInfo.index) +
|
package/src/tools/read.ts
CHANGED
|
@@ -4,14 +4,23 @@ import type { FileReadTracker } from '../diff/apply.js'
|
|
|
4
4
|
import { fmtErr } from './errors.js'
|
|
5
5
|
import type { Tool, ToolResult } from './types.js'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
8
|
+
const MAX_FILE_SIZE = 1_000_000 // 1MB
|
|
9
|
+
const DEFAULT_MAX_LINES = 500 // Default pagination limit
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format content with line numbers.
|
|
13
|
+
* Handles trailing newline correctly.
|
|
14
|
+
* Exported for testing and reuse.
|
|
15
|
+
*/
|
|
16
|
+
export function formatWithLineNumbers(content: string, startLine = 1): string {
|
|
8
17
|
const lines = content.split('\n')
|
|
9
18
|
// Drop trailing empty element produced by a final \n so the displayed
|
|
10
19
|
// line count matches the actual line count of the file.
|
|
11
20
|
const hasTrailingNewline = lines.length > 0 && lines[lines.length - 1] === ''
|
|
12
21
|
const body = hasTrailingNewline ? lines.slice(0, -1) : lines
|
|
13
|
-
const pad = String(body.length).length
|
|
14
|
-
return body.map((l, i) => `${String(
|
|
22
|
+
const pad = String(startLine + body.length - 1).length
|
|
23
|
+
return body.map((l, i) => `${String(startLine + i).padStart(pad, ' ')}: ${l}`).join('\n')
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export function createReadTool(applier: FileReadTracker, cwd: string): Tool {
|
|
@@ -19,9 +28,15 @@ export function createReadTool(applier: FileReadTracker, cwd: string): Tool {
|
|
|
19
28
|
definition: {
|
|
20
29
|
name: 'read',
|
|
21
30
|
description:
|
|
22
|
-
'Read the contents of one or more files. Always read a file before editing it. Each line is prefixed with "<lineNumber>: " for accurate line references; the prefix is a display aid only — do NOT include it in batch_edit patch content.',
|
|
31
|
+
'Read the contents of one or more files. Always read a file before editing it. Each line is prefixed with "<lineNumber>: " for accurate line references; the prefix is a display aid only — do NOT include it in batch_edit patch content. Supports pagination via startLine and maxLines for large files.',
|
|
23
32
|
parameters: {
|
|
24
33
|
paths: { type: 'array', description: 'File paths to read', required: true },
|
|
34
|
+
startLine: { type: 'number', description: 'Start line number (1-based)', required: false },
|
|
35
|
+
maxLines: {
|
|
36
|
+
type: 'number',
|
|
37
|
+
description: 'Maximum number of lines to read',
|
|
38
|
+
required: false,
|
|
39
|
+
},
|
|
25
40
|
},
|
|
26
41
|
},
|
|
27
42
|
async execute(input): Promise<ToolResult> {
|
|
@@ -30,28 +45,49 @@ export function createReadTool(applier: FileReadTracker, cwd: string): Tool {
|
|
|
30
45
|
return { success: false, output: '', error: 'paths must be a non-empty array' }
|
|
31
46
|
}
|
|
32
47
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`=== ${filePath} ===\n(error:
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
const startLine = typeof input.startLine === 'number' ? Math.max(1, input.startLine) : 1
|
|
49
|
+
const maxLines =
|
|
50
|
+
typeof input.maxLines === 'number' ? Math.max(1, input.maxLines) : DEFAULT_MAX_LINES
|
|
51
|
+
|
|
52
|
+
// Read files in parallel
|
|
53
|
+
const results = await Promise.all(
|
|
54
|
+
paths.map(async (filePath): Promise<string> => {
|
|
55
|
+
try {
|
|
56
|
+
const resolved = path.resolve(cwd, filePath)
|
|
57
|
+
const stat = await fs.stat(resolved)
|
|
58
|
+
if (!stat.isFile()) {
|
|
59
|
+
return `=== ${filePath} ===\n(error: not a file)`
|
|
60
|
+
}
|
|
61
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
62
|
+
return `=== ${filePath} ===\n(error: file too large (>1MB), use bash to read it selectively)`
|
|
63
|
+
}
|
|
64
|
+
let content = await fs.readFile(resolved, 'utf-8')
|
|
65
|
+
|
|
66
|
+
// Handle pagination
|
|
67
|
+
const allLines = content.split('\n')
|
|
68
|
+
const hasTrailingNewline = allLines.length > 0 && allLines[allLines.length - 1] === ''
|
|
69
|
+
const totalLines = hasTrailingNewline ? allLines.length - 1 : allLines.length
|
|
70
|
+
|
|
71
|
+
if (startLine > totalLines) {
|
|
72
|
+
return `=== ${filePath} ===\n(error: startLine ${startLine} exceeds file length ${totalLines})`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const endLine = Math.min(startLine + maxLines - 1, totalLines)
|
|
76
|
+
const selectedLines = allLines.slice(startLine - 1, endLine)
|
|
77
|
+
content = selectedLines.join('\n') + (hasTrailingNewline ? '\n' : '')
|
|
78
|
+
|
|
79
|
+
const displayStart = startLine
|
|
80
|
+
const displayEnd = endLine
|
|
81
|
+
const truncated =
|
|
82
|
+
endLine < totalLines ? ` (lines ${displayStart}-${displayEnd} of ${totalLines})` : ''
|
|
83
|
+
|
|
84
|
+
applier.markRead(resolved)
|
|
85
|
+
return `=== ${filePath} ===${truncated}\n${formatWithLineNumbers(content, startLine)}`
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return `=== ${filePath} ===\n(error: ${fmtErr(err)})`
|
|
47
88
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
results.push(`=== ${filePath} ===\n${formatWithLineNumbers(content)}`)
|
|
51
|
-
} catch (err) {
|
|
52
|
-
results.push(`=== ${filePath} ===\n(error: ${fmtErr(err)})`)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
55
91
|
|
|
56
92
|
return { success: true, output: results.join('\n\n') }
|
|
57
93
|
},
|