lonny-agent 0.2.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/llm.d.ts +4 -0
  5. package/dist/agent/llm.d.ts.map +1 -1
  6. package/dist/agent/project.d.ts +26 -0
  7. package/dist/agent/project.d.ts.map +1 -0
  8. package/dist/agent/project.js +303 -0
  9. package/dist/agent/project.js.map +1 -0
  10. package/dist/agent/prompt-builder.d.ts +1 -1
  11. package/dist/agent/prompt-builder.d.ts.map +1 -1
  12. package/dist/agent/prompt-builder.js +22 -13
  13. package/dist/agent/prompt-builder.js.map +1 -1
  14. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  15. package/dist/agent/providers/anthropic.js +20 -2
  16. package/dist/agent/providers/anthropic.js.map +1 -1
  17. package/dist/agent/providers/google.d.ts.map +1 -1
  18. package/dist/agent/providers/google.js +1 -1
  19. package/dist/agent/providers/google.js.map +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts.map +1 -1
  24. package/dist/agent/providers/openai.js +63 -3
  25. package/dist/agent/providers/openai.js.map +1 -1
  26. package/dist/agent/session.d.ts +8 -2
  27. package/dist/agent/session.d.ts.map +1 -1
  28. package/dist/agent/session.js +250 -153
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +7 -1
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/tools/__tests__/bash.test.js +1 -1
  34. package/dist/tools/__tests__/bash.test.js.map +1 -1
  35. package/dist/tools/__tests__/edit.test.js +875 -371
  36. package/dist/tools/__tests__/edit.test.js.map +1 -1
  37. package/dist/tools/__tests__/grep.test.js +32 -0
  38. package/dist/tools/__tests__/grep.test.js.map +1 -1
  39. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  40. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  41. package/dist/tools/__tests__/sed.test.js +228 -0
  42. package/dist/tools/__tests__/sed.test.js.map +1 -0
  43. package/dist/tools/bash.d.ts.map +1 -1
  44. package/dist/tools/bash.js +83 -10
  45. package/dist/tools/bash.js.map +1 -1
  46. package/dist/tools/edit.d.ts +58 -0
  47. package/dist/tools/edit.d.ts.map +1 -1
  48. package/dist/tools/edit.js +366 -149
  49. package/dist/tools/edit.js.map +1 -1
  50. package/dist/tools/grep.d.ts.map +1 -1
  51. package/dist/tools/grep.js +54 -30
  52. package/dist/tools/grep.js.map +1 -1
  53. package/dist/tools/read.d.ts +6 -0
  54. package/dist/tools/read.d.ts.map +1 -1
  55. package/dist/tools/read.js +42 -15
  56. package/dist/tools/read.js.map +1 -1
  57. package/dist/tools/registry.d.ts +0 -6
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +2 -33
  60. package/dist/tools/registry.js.map +1 -1
  61. package/dist/tools/sed.d.ts +4 -0
  62. package/dist/tools/sed.d.ts.map +1 -0
  63. package/dist/tools/sed.js +121 -0
  64. package/dist/tools/sed.js.map +1 -0
  65. package/dist/tools/write_plan.d.ts +1 -1
  66. package/dist/tools/write_plan.d.ts.map +1 -1
  67. package/dist/tools/write_plan.js +14 -4
  68. package/dist/tools/write_plan.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +5 -4
  71. package/dist/tui/index.js.map +1 -1
  72. package/dist/web/index.d.ts.map +1 -1
  73. package/dist/web/index.js +30 -7
  74. package/dist/web/index.js.map +1 -1
  75. package/dist/web/public/app.js +5 -855
  76. package/dist/web/public/confirm.js +70 -0
  77. package/dist/web/public/index.html +121 -120
  78. package/dist/web/public/input.js +107 -0
  79. package/dist/web/public/messages.js +395 -0
  80. package/dist/web/public/sidebar.js +82 -0
  81. package/dist/web/public/state.js +72 -0
  82. package/dist/web/public/style.css +996 -949
  83. package/dist/web/public/utils.js +37 -0
  84. package/dist/web/public/websocket.js +267 -0
  85. package/dist/web/public/ws.js +19 -0
  86. package/dist/web/session-bridge.d.ts.map +1 -1
  87. package/dist/web/session-bridge.js +3 -7
  88. package/dist/web/session-bridge.js.map +1 -1
  89. package/package.json +55 -54
  90. package/scripts/copy-native.mjs +24 -0
  91. package/scripts/copy-web.mjs +15 -0
  92. package/src/agent/index.ts +25 -25
  93. package/src/agent/llm.ts +4 -0
  94. package/src/agent/project.ts +366 -0
  95. package/src/agent/prompt-builder.ts +23 -13
  96. package/src/agent/providers/anthropic.ts +179 -172
  97. package/src/agent/providers/google.ts +210 -211
  98. package/src/agent/providers/ollama.ts +186 -182
  99. package/src/agent/providers/openai.ts +73 -5
  100. package/src/agent/session.ts +275 -153
  101. package/src/config/index.ts +342 -335
  102. package/src/tools/__tests__/bash.test.ts +1 -1
  103. package/src/tools/__tests__/edit.test.ts +981 -385
  104. package/src/tools/__tests__/grep.test.ts +35 -0
  105. package/src/tools/bash.ts +97 -11
  106. package/src/tools/edit.ts +414 -154
  107. package/src/tools/git.ts +76 -76
  108. package/src/tools/grep.ts +57 -30
  109. package/src/tools/read.ts +61 -25
  110. package/src/tools/registry.ts +231 -265
  111. package/src/tools/write_plan.ts +21 -5
  112. package/src/tui/index.ts +786 -785
  113. package/src/web/index.ts +29 -7
  114. package/src/web/public/app.js +5 -855
  115. package/src/web/public/confirm.js +70 -0
  116. package/src/web/public/index.html +121 -120
  117. package/src/web/public/input.js +107 -0
  118. package/src/web/public/messages.js +395 -0
  119. package/src/web/public/sidebar.js +82 -0
  120. package/src/web/public/state.js +72 -0
  121. package/src/web/public/style.css +996 -949
  122. package/src/web/public/utils.js +37 -0
  123. package/src/web/public/websocket.js +267 -0
  124. package/src/web/public/ws.js +19 -0
  125. package/src/web/session-bridge.ts +193 -194
  126. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  127. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  128. package/.lonny/plan-web-cwd-status.md +0 -38
  129. package/src/tools/exec.ts +0 -348
package/src/tools/git.ts CHANGED
@@ -1,76 +1,76 @@
1
- import { execSync } from 'node:child_process'
2
- import type { Tool, ToolDefinition } from './types.js'
3
-
4
- export const createGitTool = (cwd: string): Tool => {
5
- const definition: ToolDefinition = {
6
- name: 'git',
7
- description:
8
- 'Run git commands for repository operations. Supports status, diff, log, show, branch, and other read-only git operations.',
9
- parameters: {
10
- command: {
11
- type: 'string',
12
- description: 'Git subcommand and arguments (e.g. "status", "diff", "log --oneline -5")',
13
- required: true,
14
- },
15
- path: { type: 'string', description: 'Repository path (default: cwd)', required: false },
16
- },
17
- }
18
-
19
- const execute = async (
20
- input: Record<string, unknown>,
21
- ): Promise<{ success: boolean; output: string; error?: string }> => {
22
- const gitCmd = String(input.command || '').trim()
23
- const repoPath = String(input.path || cwd)
24
-
25
- if (!gitCmd) {
26
- return { success: false, output: '', error: 'command is required' }
27
- }
28
-
29
- // Safety: block destructive commands
30
- const destructivePatterns = [
31
- /^reset\s+(--hard|--soft|--mixed|--merge|--keep)/i,
32
- /^push\s+.*(--force|-f)/i,
33
- /^(rebase|merge|cherry-pick|revert)\b/i,
34
- /^branch\s+-[dD]/i,
35
- /^tag\s+-[dD]/i,
36
- /^(clean|gc|prune)\b/i,
37
- /^update-ref\b/i,
38
- /^rm\b/i,
39
- /^commit\s+--amend/i,
40
- /^checkout\s+.*-B/i,
41
- ]
42
-
43
- for (const pattern of destructivePatterns) {
44
- if (pattern.test(gitCmd)) {
45
- return {
46
- success: false,
47
- output: '',
48
- error: `Destructive git command not allowed: "${gitCmd}". Only read-only operations are permitted.`,
49
- }
50
- }
51
- }
52
-
53
- try {
54
- const result = execSync(`git ${gitCmd}`, {
55
- cwd: repoPath,
56
- encoding: 'utf-8',
57
- timeout: 30000,
58
- maxBuffer: 10 * 1024 * 1024, // 10MB
59
- })
60
-
61
- return { success: true, output: result.trim() || '(empty output)' }
62
- } catch (err) {
63
- const error = err as { stderr?: string; stdout?: string; message?: string }
64
- const stderr = error.stderr || ''
65
- const stdout = error.stdout || ''
66
- const msg = error.message || String(err)
67
- return {
68
- success: false,
69
- output: stdout.trim(),
70
- error: stderr.trim() || msg,
71
- }
72
- }
73
- }
74
-
75
- return { definition, execute }
76
- }
1
+ import { execSync } from 'node:child_process'
2
+ import type { Tool, ToolDefinition } from './types.js'
3
+
4
+ export const createGitTool = (cwd: string): Tool => {
5
+ const definition: ToolDefinition = {
6
+ name: 'git',
7
+ description:
8
+ 'Run git commands for repository operations. Supports status, diff, log, show, branch, and other read-only git operations.',
9
+ parameters: {
10
+ command: {
11
+ type: 'string',
12
+ description: 'Git subcommand and arguments (e.g. "status", "diff", "log --oneline -5")',
13
+ required: true,
14
+ },
15
+ path: { type: 'string', description: 'Repository path (default: cwd)', required: false },
16
+ },
17
+ }
18
+
19
+ const execute = async (
20
+ input: Record<string, unknown>,
21
+ ): Promise<{ success: boolean; output: string; error?: string }> => {
22
+ const gitCmd = String(input.command || '').trim()
23
+ const repoPath = String(input.path || cwd)
24
+
25
+ if (!gitCmd) {
26
+ return { success: false, output: '', error: 'command is required' }
27
+ }
28
+
29
+ // Safety: block destructive commands
30
+ const destructivePatterns = [
31
+ /^reset\s+(--hard|--soft|--mixed|--merge|--keep)/i,
32
+ /^push\s+.*(--force|-f)/i,
33
+ /^(rebase|merge|cherry-pick|revert)\b/i,
34
+ /^branch\s+-[dD]/i,
35
+ /^tag\s+-[dD]/i,
36
+ /^(clean|gc|prune)\b/i,
37
+ /^update-ref\b/i,
38
+ /^rm\b/i,
39
+ /^commit\s+--amend/i,
40
+ /^checkout\s+.*-B/i,
41
+ ]
42
+
43
+ for (const pattern of destructivePatterns) {
44
+ if (pattern.test(gitCmd)) {
45
+ return {
46
+ success: false,
47
+ output: '',
48
+ error: `Destructive git command not allowed: "${gitCmd}". Only read-only operations are permitted.`,
49
+ }
50
+ }
51
+ }
52
+
53
+ try {
54
+ const result = execSync(`git ${gitCmd}`, {
55
+ cwd: repoPath,
56
+ encoding: 'utf-8',
57
+ timeout: 30000,
58
+ maxBuffer: 10 * 1024 * 1024, // 10MB
59
+ })
60
+
61
+ return { success: true, output: result.trim() || '(empty output)' }
62
+ } catch (err) {
63
+ const error = err as { stderr?: string; stdout?: string; message?: string }
64
+ const stderr = error.stderr || ''
65
+ const stdout = error.stdout || ''
66
+ const msg = error.message || String(err)
67
+ return {
68
+ success: false,
69
+ output: stdout.trim(),
70
+ error: stderr.trim() || msg,
71
+ }
72
+ }
73
+ }
74
+
75
+ return { definition, execute }
76
+ }
package/src/tools/grep.ts CHANGED
@@ -1,16 +1,20 @@
1
- import { execSync } from 'node:child_process'
1
+ import { execFileSync } from 'node:child_process'
2
2
  import * as fs from 'node:fs/promises'
3
3
  import * as path from 'node:path'
4
4
  import { fmtErr } from './errors.js'
5
5
  import type { Tool, ToolResult } from './types.js'
6
6
 
7
+ let _hasRg: boolean | undefined
8
+
7
9
  function hasRg(): boolean {
10
+ if (_hasRg !== undefined) return _hasRg
8
11
  try {
9
- execSync('rg --version', { stdio: 'pipe' })
10
- return true
12
+ execFileSync('rg', ['--version'], { stdio: 'pipe' })
13
+ _hasRg = true
11
14
  } catch {
12
- return false
15
+ _hasRg = false
13
16
  }
17
+ return _hasRg
14
18
  }
15
19
 
16
20
  /** Simple glob-to-regex for the `include` parameter (e.g. "*.ts", "*.{ts,tsx}"). */
@@ -25,9 +29,11 @@ function includeRe(include: string): RegExp {
25
29
  continue
26
30
  }
27
31
  if (ch === '*') {
32
+ // Handle ** (match across dirs) and * (match within a single dir segment)
28
33
  if (include[i + 1] === '*') {
29
34
  re += '.*'
30
- i += 2
35
+ // Skip all consecutive asterisks so `***` doesn't leave a trailing literal `*`
36
+ while (i < include.length && include[i] === '*') i++
31
37
  continue
32
38
  }
33
39
  re += '[^/]*'
@@ -48,6 +54,12 @@ function includeRe(include: string): RegExp {
48
54
  i++
49
55
  continue
50
56
  }
57
+ // Escape special regex characters (except those we handle above)
58
+ if (/[.+^${}()|[\]\\]/.test(ch)) {
59
+ re += '\\' + ch
60
+ i++
61
+ continue
62
+ }
51
63
  re += ch
52
64
  i++
53
65
  }
@@ -64,32 +76,38 @@ async function nodeGrep(dir: string, re: RegExp, incRe: RegExp | null): Promise<
64
76
  const results: NodeGrepMatch[] = []
65
77
  try {
66
78
  const entries = await fs.readdir(dir, { withFileTypes: true })
67
- const subResults: Promise<NodeGrepMatch[]>[] = []
79
+ const promises: Promise<void>[] = []
80
+
68
81
  for (const e of entries) {
69
82
  const full = path.join(dir, e.name)
70
83
  if (e.isDirectory()) {
71
84
  if (e.name === '.git' || e.name === 'node_modules') continue
72
- subResults.push(nodeGrep(full, re, incRe))
85
+ promises.push(
86
+ nodeGrep(full, re, incRe).then(sub => {
87
+ for (const m of sub) results.push(m)
88
+ }),
89
+ )
73
90
  } else if (e.isFile()) {
74
91
  if (incRe && !incRe.test(e.name)) continue
75
- try {
76
- const content = await fs.readFile(full, 'utf-8')
77
- const lines = content.split('\n')
78
- for (let i = 0; i < lines.length; i++) {
79
- if (re.test(lines[i])) {
80
- results.push({ file: full, line: i + 1, text: lines[i] })
92
+ promises.push(
93
+ (async () => {
94
+ try {
95
+ const content = await fs.readFile(full, 'utf-8')
96
+ const lines = content.split('\n')
97
+ for (let i = 0; i < lines.length; i++) {
98
+ if (re.test(lines[i])) {
99
+ results.push({ file: full, line: i + 1, text: lines[i] })
100
+ }
101
+ }
102
+ } catch {
103
+ /* permission denied etc */
81
104
  }
82
- }
83
- } catch {
84
- /* permission denied etc */
85
- }
105
+ })(),
106
+ )
86
107
  }
87
108
  }
88
- // Await all subdirectory results concurrently
89
- const subMatches = await Promise.all(subResults)
90
- for (const sm of subMatches) {
91
- results.push(...sm)
92
- }
109
+
110
+ await Promise.all(promises)
93
111
  } catch {
94
112
  /* permission denied etc */
95
113
  }
@@ -120,11 +138,23 @@ export function createGrepTool(cwd: string): Tool {
120
138
 
121
139
  try {
122
140
  if (useRg) {
123
- let cmd = `rg -n "${pattern.replace(/"/g, '\\"')}"`
124
- if (include) cmd += ` -g "${include}"`
125
- cmd += ` "${searchPath}"`
126
- const output = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
127
- return { success: true, output: output || 'No matches found.' }
141
+ const args = ['-n', '--no-heading', pattern]
142
+ if (include) args.push('-g', include)
143
+ args.push(searchPath)
144
+ try {
145
+ const output = execFileSync('rg', args, {
146
+ encoding: 'utf-8',
147
+ stdio: ['pipe', 'pipe', 'pipe'],
148
+ })
149
+ return { success: true, output: output || 'No matches found.' }
150
+ } catch (rgErr: unknown) {
151
+ const rgMsg = fmtErr(rgErr)
152
+ // ripgrep exit code 1 = no matches, exit code 2 = error
153
+ if (rgMsg.includes('exit code 1')) {
154
+ return { success: true, output: 'No matches found.' }
155
+ }
156
+ throw rgErr
157
+ }
128
158
  }
129
159
 
130
160
  const re = new RegExp(pattern)
@@ -141,9 +171,6 @@ export function createGrepTool(cwd: string): Tool {
141
171
  return { success: true, output }
142
172
  } catch (err) {
143
173
  const msg = fmtErr(err)
144
- if (useRg && msg.includes('exit code 1')) {
145
- return { success: true, output: 'No matches found.' }
146
- }
147
174
  return { success: false, output: '', error: `Grep failed: ${msg}` }
148
175
  }
149
176
  },
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
  },