lonny-agent 0.2.3 → 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.
Files changed (64) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/project.d.ts +26 -0
  5. package/dist/agent/project.d.ts.map +1 -0
  6. package/dist/agent/project.js +303 -0
  7. package/dist/agent/project.js.map +1 -0
  8. package/dist/agent/prompt-builder.d.ts +1 -1
  9. package/dist/agent/prompt-builder.d.ts.map +1 -1
  10. package/dist/agent/prompt-builder.js +10 -5
  11. package/dist/agent/prompt-builder.js.map +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +20 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.js +1 -1
  16. package/dist/agent/providers/google.js.map +1 -1
  17. package/dist/agent/providers/ollama.d.ts.map +1 -1
  18. package/dist/agent/providers/ollama.js +2 -1
  19. package/dist/agent/providers/ollama.js.map +1 -1
  20. package/dist/agent/providers/openai.d.ts.map +1 -1
  21. package/dist/agent/providers/openai.js +23 -3
  22. package/dist/agent/providers/openai.js.map +1 -1
  23. package/dist/agent/session.d.ts +4 -2
  24. package/dist/agent/session.d.ts.map +1 -1
  25. package/dist/agent/session.js +206 -147
  26. package/dist/agent/session.js.map +1 -1
  27. package/dist/tools/__tests__/edit.test.js +14 -14
  28. package/dist/tools/__tests__/edit.test.js.map +1 -1
  29. package/dist/tools/edit.d.ts +17 -0
  30. package/dist/tools/edit.d.ts.map +1 -1
  31. package/dist/tools/edit.js +81 -34
  32. package/dist/tools/edit.js.map +1 -1
  33. package/dist/tools/read.d.ts +6 -0
  34. package/dist/tools/read.d.ts.map +1 -1
  35. package/dist/tools/read.js +42 -15
  36. package/dist/tools/read.js.map +1 -1
  37. package/dist/tools/registry.d.ts.map +1 -1
  38. package/dist/tools/registry.js +0 -4
  39. package/dist/tools/registry.js.map +1 -1
  40. package/dist/tools/write_plan.d.ts +1 -1
  41. package/dist/tools/write_plan.d.ts.map +1 -1
  42. package/dist/tools/write_plan.js +14 -4
  43. package/dist/tools/write_plan.js.map +1 -1
  44. package/dist/tui/index.d.ts.map +1 -1
  45. package/dist/tui/index.js +5 -4
  46. package/dist/tui/index.js.map +1 -1
  47. package/dist/web/index.js +5 -5
  48. package/dist/web/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/agent/index.ts +1 -1
  51. package/src/agent/project.ts +366 -0
  52. package/src/agent/prompt-builder.ts +11 -5
  53. package/src/agent/providers/anthropic.ts +9 -2
  54. package/src/agent/providers/google.ts +1 -1
  55. package/src/agent/providers/ollama.ts +5 -1
  56. package/src/agent/providers/openai.ts +15 -3
  57. package/src/agent/session.ts +216 -150
  58. package/src/tools/__tests__/edit.test.ts +14 -14
  59. package/src/tools/edit.ts +103 -36
  60. package/src/tools/read.ts +61 -25
  61. package/src/tools/registry.ts +260 -265
  62. package/src/tools/write_plan.ts +21 -5
  63. package/src/tui/index.ts +5 -4
  64. 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
- // ── ANSI colors for diff output ─────────────────────────────────────────
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
- function generateDiff(oldStr: string, newStr: string): string {
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: string[] = []
16
- for (const line of oldLines) {
17
- lines.push(` ${DIFF_RED}- ${line}${DIFF_RESET}`)
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
- for (const line of newLines) {
20
- lines.push(` ${DIFF_GREEN}+ ${line}${DIFF_RESET}`)
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 lines.join('\n')
83
+ return output.join('\n')
84
+ }
85
+
86
+ /** Escape HTML special characters */
87
+ function escapeHtml(str: string): string {
88
+ return str
89
+ .replace(/&/g, '&amp;')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;')
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
- const rawInput = JSON.parse(JSON.stringify(input))
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 = JSON.stringify({
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 = JSON.stringify({
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 = JSON.stringify({
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 = JSON.stringify({
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 = JSON.stringify({
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 diff = generateDiff(e.old_string, e.new_string)
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
- function formatWithLineNumbers(content: string): string {
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(i + 1).padStart(pad, ' ')}: ${l}`).join('\n')
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 results: string[] = []
34
- for (const filePath of paths) {
35
- try {
36
- const resolved = path.resolve(cwd, filePath)
37
- const stat = await fs.stat(resolved)
38
- if (!stat.isFile()) {
39
- results.push(`=== ${filePath} ===\n(error: not a file)`)
40
- continue
41
- }
42
- if (stat.size > 1_000_000) {
43
- results.push(
44
- `=== ${filePath} ===\n(error: file too large (>1MB), use bash to read it selectively)`,
45
- )
46
- continue
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
- const content = await fs.readFile(resolved, 'utf-8')
49
- applier.markRead(resolved)
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
  },