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
@@ -1,265 +1,231 @@
1
- import type { FileReadTracker } from '../diff/apply.js'
2
- import { bashTool } from './bash.js'
3
- import { createEditTool } from './edit.js'
4
- import { fmtErr } from './errors.js'
5
- import { createExecTool, updateExecToolDefinition } from './exec.js'
6
- import { fetchTool } from './fetch.js'
7
- import { createFindTool } from './find.js'
8
- import { createGitTool } from './git.js'
9
- import { globTool } from './glob.js'
10
- import { createGrepTool } from './grep.js'
11
- import { createInstallSkillTool } from './install_skill.js'
12
- import { createLsTool } from './ls.js'
13
- import { createReadTool } from './read.js'
14
- import { searchTool } from './search.js'
15
- import type { Tool, ToolCall, ToolDefinition, ToolResult } from './types.js'
16
- import { createWritePlanTool } from './write_plan.js'
17
-
18
- /**
19
- * Normalize tool input to prevent common LLM call misuses.
20
- * The LLM sometimes passes parameters in the wrong format — this function
21
- * auto-corrects those patterns so tools work reliably.
22
- *
23
- * Common misuses handled:
24
- * 1. Passed as a string instead of { ... } object wrapper
25
- * 2. Passed top-level params directly instead of nested in the expected key
26
- * 3. Array passed directly (for tools that expect { items: [...] })
27
- */
28
- function normalizeToolInput(
29
- toolName: string,
30
- _definition: ToolDefinition,
31
- input: Record<string, unknown>,
32
- ): Record<string, unknown> {
33
- // If input is a string (e.g. install_skill("package-name") or bash("ls -la"))
34
- // wrap it into the appropriate key
35
- if (typeof input === 'string') {
36
- if (toolName === 'bash' || toolName === 'git') {
37
- return { command: input }
38
- }
39
- if (
40
- toolName === 'install_skill' ||
41
- toolName === 'find' ||
42
- toolName === 'glob' ||
43
- toolName === 'grep'
44
- ) {
45
- return { pattern: input }
46
- }
47
- if (toolName === 'search' || toolName === 'fetch') {
48
- return { query: input, url: input }
49
- }
50
- if (toolName === 'write_plan') {
51
- return { filename: input, content: input }
52
- }
53
- if (toolName === 'exec') {
54
- return { code: input }
55
- }
56
- // For tools that take a single string param, guess from the definition
57
- const params = Object.keys(_definition.parameters)
58
- if (params.length === 1) {
59
- return { [params[0]]: input }
60
- }
61
- return input
62
- }
63
-
64
- // If input is an array (e.g. read called with ["file.ts"] directly)
65
- if (Array.isArray(input)) {
66
- if (toolName === 'read' || toolName === 'ls') {
67
- return { paths: input, path: (input as string[])[0] }
68
- }
69
- if (toolName === 'edit') {
70
- return { edits: input }
71
- }
72
- return input
73
- }
74
-
75
- // If edit tool gets an empty object, some models hallucinate the call
76
- if (toolName === 'edit' && Object.keys(input).length === 0) {
77
- return { edits: [] }
78
- }
79
-
80
- return input
81
- }
82
-
83
- export interface ToolContext {
84
- cwd: string
85
- autoApprove: boolean
86
- applier: FileReadTracker
87
- mode: 'code' | 'plan' | 'ask'
88
- onPlanWritten?: (display: string) => void
89
- }
90
-
91
- export interface ToolPlugin {
92
- name: string
93
- description: string
94
- create: (context: ToolContext) => Tool
95
- }
96
-
97
- /**
98
- * Extensible ToolRegistry with plugin support.
99
- * Inspired by pi's extension system for dynamic tool registration.
100
- */
101
- export class ToolRegistry {
102
- private tools: Map<string, Tool> = new Map()
103
- private context: ToolContext
104
- private plugins: Map<string, ToolPlugin> = new Map()
105
- /** Reference to the exec tool for dynamic description updates */
106
- private execTool: Tool | null = null
107
-
108
- constructor(context: ToolContext) {
109
- this.context = context
110
- this.registerBuiltins()
111
- }
112
-
113
- /** Register all built-in tools */
114
- private registerBuiltins(): void {
115
- // ask mode: only fetch and search + exec (exec works with any toolset)
116
- if (this.context.mode === 'ask') {
117
- this.register(fetchTool)
118
- this.register(searchTool)
119
- return
120
- }
121
-
122
- this.register(createReadTool(this.context.applier, this.context.cwd))
123
- this.register(globTool)
124
- this.register(createGrepTool(this.context.cwd))
125
- this.register(createLsTool(this.context.cwd))
126
- this.register(createFindTool(this.context.cwd))
127
- this.register(fetchTool)
128
- this.register(searchTool)
129
-
130
- if (this.context.mode === 'code') {
131
- // Code mode: full toolset including write operations
132
- this.register(bashTool)
133
- this.register(createGitTool(this.context.cwd))
134
- this.register(createInstallSkillTool(this.context.cwd))
135
- this.register(createEditTool(this.context.applier, this.context.cwd))
136
- this.registerExecTool()
137
- } else {
138
- // Plan mode: read-only investigation + write_plan
139
- this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
140
- }
141
- }
142
-
143
- /** Register the exec tool with dynamic type declarations for all registered tools */
144
- private registerExecTool(): void {
145
- const execTool = createExecTool(() => Array.from(this.tools.values()))
146
- this.execTool = execTool
147
- this.register(execTool)
148
- }
149
-
150
- /** Refresh the exec tool's description to include current tool type declarations */
151
- private refreshExecDescription(): void {
152
- if (this.execTool) {
153
- updateExecToolDefinition(this.execTool, this.getDefinitions())
154
- }
155
- }
156
-
157
- setMode(mode: 'code' | 'plan' | 'ask'): void {
158
- // Update context.mode FIRST so registerBuiltins() and mode-based logic
159
- // use the NEW mode, not the stale one (fixes bug when switching from
160
- // ask→code or plan→code where all tools would be missing)
161
- this.context.mode = mode
162
-
163
- if (mode === 'code') {
164
- // Re-register all built-in tools for code mode
165
- this.tools.clear()
166
- this.execTool = null
167
- this.registerBuiltins()
168
- } else if (mode === 'plan') {
169
- // Keep existing tools but swap edit/write_plan, remove exec
170
- if (!this.tools.has('write_plan')) {
171
- this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
172
- }
173
- this.tools.delete('edit')
174
- this.tools.delete('exec')
175
- this.execTool = null
176
- } else if (mode === 'ask') {
177
- // Ask mode: only fetch and search
178
- this.tools.clear()
179
- this.execTool = null
180
- this.register(fetchTool)
181
- this.register(searchTool)
182
- }
183
- }
184
-
185
- /** Register a single tool */
186
- register(tool: Tool): void {
187
- this.tools.set(tool.definition.name, tool)
188
- // Refresh exec tool description to include the new tool's type declarations
189
- if (this.execTool && tool.definition.name !== 'exec') {
190
- this.refreshExecDescription()
191
- }
192
- }
193
-
194
- /** Register a tool plugin (lazy creation pattern) */
195
- registerPlugin(plugin: ToolPlugin): void {
196
- this.plugins.set(plugin.name, plugin)
197
- // Activate immediately
198
- try {
199
- const tool = plugin.create(this.context)
200
- this.register(tool)
201
- } catch (err) {
202
- console.error(`Failed to activate plugin "${plugin.name}": ${err}`)
203
- }
204
- }
205
-
206
- /** Unregister a tool by name */
207
- unregister(name: string): boolean {
208
- return this.tools.delete(name)
209
- }
210
-
211
- /** Check if a tool is registered */
212
- has(name: string): boolean {
213
- return this.tools.has(name)
214
- }
215
-
216
- /** Re-register write_plan tool with an updated callback */
217
- reRegisterWritePlan(cwd: string, cb?: (display: string) => void): void {
218
- this.tools.delete('write_plan')
219
- this.tools.set('write_plan', createWritePlanTool(cwd, cb))
220
- }
221
-
222
- /** Partially update the context (used by session.onPlanWritten setter) */
223
- updateContext(partial: Partial<ToolContext>): void {
224
- Object.assign(this.context, partial)
225
- }
226
-
227
- /** List all registered tool names */
228
- listTools(): string[] {
229
- return Array.from(this.tools.keys())
230
- }
231
-
232
- getDefinitions(): ToolDefinition[] {
233
- return Array.from(this.tools.values()).map(t => t.definition)
234
- }
235
-
236
- async dispatch(call: ToolCall): Promise<ToolResult> {
237
- const tool = this.tools.get(call.name)
238
- if (!tool) {
239
- return {
240
- success: false,
241
- output: '',
242
- error: `Unknown tool: "${call.name}". Available: ${Array.from(this.tools.keys()).join(', ')}`,
243
- }
244
- }
245
-
246
- // ── Universal input normalization ──────────────────────────────────
247
- // Auto-correct common LLM call misuses before they reach tool execute()
248
- let input = call.input
249
- try {
250
- input = normalizeToolInput(call.name, tool.definition, input)
251
- } catch {
252
- // If normalization throws, let the tool handle it (or fail naturally)
253
- }
254
-
255
- try {
256
- return await tool.execute(input)
257
- } catch (err) {
258
- return {
259
- success: false,
260
- output: '',
261
- error: `Tool "${call.name}" threw: ${fmtErr(err)}`,
262
- }
263
- }
264
- }
265
- }
1
+ import type { FileReadTracker } from '../diff/apply.js'
2
+ import { bashTool } from './bash.js'
3
+ import { createEditTool } from './edit.js'
4
+ import { fmtErr } from './errors.js'
5
+ import { fetchTool } from './fetch.js'
6
+ import { createFindTool } from './find.js'
7
+ import { createGitTool } from './git.js'
8
+ import { globTool } from './glob.js'
9
+ import { createGrepTool } from './grep.js'
10
+ import { createInstallSkillTool } from './install_skill.js'
11
+ import { createLsTool } from './ls.js'
12
+ import { createReadTool } from './read.js'
13
+ import { searchTool } from './search.js'
14
+ import type { Tool, ToolCall, ToolDefinition, ToolResult } from './types.js'
15
+ import { createWritePlanTool } from './write_plan.js'
16
+
17
+ /**
18
+ * Normalize tool input to prevent common LLM call misuses.
19
+ * The LLM sometimes passes parameters in the wrong format — this function
20
+ * auto-corrects those patterns so tools work reliably.
21
+ *
22
+ * Common misuses handled:
23
+ * 1. Passed as a string instead of { ... } object wrapper
24
+ * 2. Passed top-level params directly instead of nested in the expected key
25
+ * 3. Array passed directly (for tools that expect { items: [...] })
26
+ */
27
+ function normalizeToolInput(
28
+ toolName: string,
29
+ _definition: ToolDefinition,
30
+ input: Record<string, unknown>,
31
+ ): Record<string, unknown> {
32
+ // If input is a string (e.g. install_skill("package-name") or bash("ls -la"))
33
+ // wrap it into the appropriate key
34
+ if (typeof input === 'string') {
35
+ if (toolName === 'bash' || toolName === 'git') {
36
+ return { command: input }
37
+ }
38
+ if (
39
+ toolName === 'install_skill' ||
40
+ toolName === 'find' ||
41
+ toolName === 'glob' ||
42
+ toolName === 'grep'
43
+ ) {
44
+ return { pattern: input }
45
+ }
46
+ if (toolName === 'search' || toolName === 'fetch') {
47
+ return { query: input, url: input }
48
+ }
49
+ if (toolName === 'write_plan') {
50
+ return { filename: input, content: input }
51
+ }
52
+ // For tools that take a single string param, guess from the definition
53
+ const params = Object.keys(_definition.parameters)
54
+ if (params.length === 1) {
55
+ return { [params[0]]: input }
56
+ }
57
+ return input
58
+ }
59
+
60
+ // If input is an array (e.g. read called with ["file.ts"] directly)
61
+ if (Array.isArray(input)) {
62
+ if (toolName === 'read' || toolName === 'ls') {
63
+ return { paths: input, path: (input as string[])[0] }
64
+ }
65
+ if (toolName === 'edit') {
66
+ return { edits: input }
67
+ }
68
+ return input
69
+ }
70
+
71
+ return input
72
+ }
73
+
74
+ export interface ToolContext {
75
+ cwd: string
76
+ autoApprove: boolean
77
+ applier: FileReadTracker
78
+ mode: 'code' | 'plan' | 'ask'
79
+ onPlanWritten?: (display: string) => void
80
+ }
81
+
82
+ export interface ToolPlugin {
83
+ name: string
84
+ description: string
85
+ create: (context: ToolContext) => Tool
86
+ }
87
+
88
+ /**
89
+ * Extensible ToolRegistry with plugin support.
90
+ * Inspired by pi's extension system for dynamic tool registration.
91
+ */
92
+ export class ToolRegistry {
93
+ private tools: Map<string, Tool> = new Map()
94
+ private context: ToolContext
95
+ private plugins: Map<string, ToolPlugin> = new Map()
96
+
97
+ constructor(context: ToolContext) {
98
+ this.context = context
99
+ this.registerBuiltins()
100
+ }
101
+
102
+ /** Register all built-in tools */
103
+ private registerBuiltins(): void {
104
+ // ask mode: only fetch and search
105
+ if (this.context.mode === 'ask') {
106
+ this.register(fetchTool)
107
+ this.register(searchTool)
108
+ return
109
+ }
110
+
111
+ this.register(createReadTool(this.context.applier, this.context.cwd))
112
+ this.register(globTool)
113
+ this.register(createGrepTool(this.context.cwd))
114
+ this.register(createLsTool(this.context.cwd))
115
+ this.register(createFindTool(this.context.cwd))
116
+ this.register(fetchTool)
117
+ this.register(searchTool)
118
+
119
+ if (this.context.mode === 'code') {
120
+ // Code mode: full toolset including write operations
121
+ this.register(bashTool)
122
+ this.register(createGitTool(this.context.cwd))
123
+ this.register(createInstallSkillTool(this.context.cwd))
124
+ this.register(createEditTool(this.context.applier, this.context.cwd))
125
+ } else {
126
+ // Plan mode: read-only investigation + write_plan
127
+ this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
128
+ }
129
+ }
130
+
131
+ setMode(mode: 'code' | 'plan' | 'ask'): void {
132
+ // Update context.mode FIRST so registerBuiltins() and mode-based logic
133
+ // use the NEW mode, not the stale one (fixes bug when switching from
134
+ // ask→code or plan→code where all tools would be missing)
135
+ this.context.mode = mode
136
+
137
+ if (mode === 'code') {
138
+ // Re-register all built-in tools for code mode
139
+ this.tools.clear()
140
+ this.registerBuiltins()
141
+ } else if (mode === 'plan') {
142
+ // Keep existing tools but swap edit/write_plan
143
+ if (!this.tools.has('write_plan')) {
144
+ this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
145
+ }
146
+ this.tools.delete('edit')
147
+ } else if (mode === 'ask') {
148
+ // Ask mode: only fetch and search
149
+ this.tools.clear()
150
+ this.register(fetchTool)
151
+ this.register(searchTool)
152
+ }
153
+ }
154
+
155
+ /** Register a single tool */
156
+ register(tool: Tool): void {
157
+ this.tools.set(tool.definition.name, tool)
158
+ }
159
+
160
+ /** Register a tool plugin (lazy creation pattern) */
161
+ registerPlugin(plugin: ToolPlugin): void {
162
+ this.plugins.set(plugin.name, plugin)
163
+ // Activate immediately
164
+ try {
165
+ const tool = plugin.create(this.context)
166
+ this.register(tool)
167
+ } catch (err) {
168
+ console.error(`Failed to activate plugin "${plugin.name}": ${err}`)
169
+ }
170
+ }
171
+
172
+ /** Unregister a tool by name */
173
+ unregister(name: string): boolean {
174
+ return this.tools.delete(name)
175
+ }
176
+
177
+ /** Check if a tool is registered */
178
+ has(name: string): boolean {
179
+ return this.tools.has(name)
180
+ }
181
+
182
+ /** Re-register write_plan tool with an updated callback */
183
+ reRegisterWritePlan(cwd: string, cb?: (display: string) => void): void {
184
+ this.tools.delete('write_plan')
185
+ this.tools.set('write_plan', createWritePlanTool(cwd, cb))
186
+ }
187
+
188
+ /** Partially update the context (used by session.onPlanWritten setter) */
189
+ updateContext(partial: Partial<ToolContext>): void {
190
+ Object.assign(this.context, partial)
191
+ }
192
+
193
+ /** List all registered tool names */
194
+ listTools(): string[] {
195
+ return Array.from(this.tools.keys())
196
+ }
197
+
198
+ getDefinitions(): ToolDefinition[] {
199
+ return Array.from(this.tools.values()).map(t => t.definition)
200
+ }
201
+
202
+ async dispatch(call: ToolCall): Promise<ToolResult> {
203
+ const tool = this.tools.get(call.name)
204
+ if (!tool) {
205
+ return {
206
+ success: false,
207
+ output: '',
208
+ error: `Unknown tool: "${call.name}". Available: ${Array.from(this.tools.keys()).join(', ')}`,
209
+ }
210
+ }
211
+
212
+ // ── Universal input normalization ──────────────────────────────────
213
+ // Auto-correct common LLM call misuses before they reach tool execute()
214
+ let input = call.input
215
+ try {
216
+ input = normalizeToolInput(call.name, tool.definition, input)
217
+ } catch {
218
+ // If normalization throws, let the tool handle it (or fail naturally)
219
+ }
220
+
221
+ try {
222
+ return await tool.execute(input)
223
+ } catch (err) {
224
+ return {
225
+ success: false,
226
+ output: '',
227
+ error: `Tool "${call.name}" threw: ${fmtErr(err)}`,
228
+ }
229
+ }
230
+ }
231
+ }
@@ -1,4 +1,4 @@
1
- import * as fs from 'node:fs'
1
+ import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
  import { fmtErr } from './errors.js'
4
4
  import type { Tool, ToolResult } from './types.js'
@@ -6,6 +6,9 @@ import type { Tool, ToolResult } from './types.js'
6
6
  /** Directory where plans are stored, relative to project root */
7
7
  export const PLAN_DIR = '.lonny'
8
8
 
9
+ /** Maximum plan content size (1MB) */
10
+ const MAX_CONTENT_SIZE = 1_000_000
11
+
9
12
  function sanitizeFilename(name: string): string {
10
13
  const trimmed = name.trim()
11
14
  if (!trimmed) return ''
@@ -22,7 +25,10 @@ function sanitizeFilename(name: string): string {
22
25
  return normalized
23
26
  }
24
27
 
25
- export function createWritePlanTool(cwd: string, onPlanWritten?: (display: string) => void): Tool {
28
+ export function createWritePlanTool(
29
+ cwd: string,
30
+ onPlanWritten?: (display: string) => void | Promise<void>,
31
+ ): Tool {
26
32
  return {
27
33
  definition: {
28
34
  name: 'write_plan',
@@ -49,9 +55,19 @@ export function createWritePlanTool(cwd: string, onPlanWritten?: (display: strin
49
55
  if (typeof input.content !== 'string') {
50
56
  return { success: false, output: '', error: 'content is required (string)' }
51
57
  }
58
+
52
59
  const filename = input.filename
53
60
  const content = input.content
54
61
 
62
+ // Check content size
63
+ if (content.length > MAX_CONTENT_SIZE) {
64
+ return {
65
+ success: false,
66
+ output: '',
67
+ error: `Content too large (max ${MAX_CONTENT_SIZE} bytes)`,
68
+ }
69
+ }
70
+
55
71
  const safeName = sanitizeFilename(filename)
56
72
  if (!safeName) {
57
73
  return {
@@ -69,10 +85,10 @@ export function createWritePlanTool(cwd: string, onPlanWritten?: (display: strin
69
85
  }
70
86
 
71
87
  try {
72
- fs.mkdirSync(path.dirname(target), { recursive: true })
73
- fs.writeFileSync(target, content, 'utf8')
88
+ await fs.mkdir(path.dirname(target), { recursive: true })
89
+ await fs.writeFile(target, content, 'utf8')
74
90
  const display = path.relative(cwd, target).replace(/\\/g, '/')
75
- onPlanWritten?.(display)
91
+ await Promise.resolve(onPlanWritten?.(display))
76
92
  return {
77
93
  success: true,
78
94
  output: `Wrote plan to ${display} (${Buffer.byteLength(content, 'utf8')} bytes)`,