@soederpop/luca 0.0.32 → 0.0.35

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 (92) hide show
  1. package/README.md +241 -36
  2. package/bun.lock +24 -6
  3. package/commands/build-python-bridge.ts +43 -0
  4. package/docs/README.md +1 -1
  5. package/docs/TABLE-OF-CONTENTS.md +0 -1
  6. package/docs/apis/clients/rest.md +7 -7
  7. package/docs/apis/clients/websocket.md +23 -10
  8. package/docs/apis/features/agi/assistant.md +155 -8
  9. package/docs/apis/features/agi/assistants-manager.md +90 -22
  10. package/docs/apis/features/agi/auto-assistant.md +377 -0
  11. package/docs/apis/features/agi/browser-use.md +802 -0
  12. package/docs/apis/features/agi/claude-code.md +6 -1
  13. package/docs/apis/features/agi/conversation-history.md +7 -6
  14. package/docs/apis/features/agi/conversation.md +111 -38
  15. package/docs/apis/features/agi/docs-reader.md +35 -57
  16. package/docs/apis/features/agi/file-tools.md +163 -0
  17. package/docs/apis/features/agi/openapi.md +2 -2
  18. package/docs/apis/features/agi/skills-library.md +227 -0
  19. package/docs/apis/features/node/content-db.md +125 -4
  20. package/docs/apis/features/node/disk-cache.md +11 -11
  21. package/docs/apis/features/node/downloader.md +1 -1
  22. package/docs/apis/features/node/file-manager.md +15 -15
  23. package/docs/apis/features/node/fs.md +78 -21
  24. package/docs/apis/features/node/git.md +50 -10
  25. package/docs/apis/features/node/google-calendar.md +3 -0
  26. package/docs/apis/features/node/google-docs.md +10 -1
  27. package/docs/apis/features/node/google-drive.md +3 -0
  28. package/docs/apis/features/node/google-mail.md +214 -0
  29. package/docs/apis/features/node/google-sheets.md +3 -0
  30. package/docs/apis/features/node/ink.md +10 -10
  31. package/docs/apis/features/node/ipc-socket.md +83 -93
  32. package/docs/apis/features/node/networking.md +5 -5
  33. package/docs/apis/features/node/os.md +7 -7
  34. package/docs/apis/features/node/package-finder.md +14 -14
  35. package/docs/apis/features/node/proc.md +2 -1
  36. package/docs/apis/features/node/process-manager.md +70 -3
  37. package/docs/apis/features/node/python.md +265 -9
  38. package/docs/apis/features/node/redis.md +380 -0
  39. package/docs/apis/features/node/ui.md +13 -13
  40. package/docs/apis/servers/express.md +35 -7
  41. package/docs/apis/servers/mcp.md +3 -3
  42. package/docs/apis/servers/websocket.md +51 -8
  43. package/docs/bootstrap/CLAUDE.md +1 -1
  44. package/docs/bootstrap/SKILL.md +93 -7
  45. package/docs/examples/feature-as-tool-provider.md +143 -0
  46. package/docs/examples/python.md +42 -1
  47. package/docs/introspection.md +15 -5
  48. package/docs/tutorials/00-bootstrap.md +3 -3
  49. package/docs/tutorials/02-container.md +2 -2
  50. package/docs/tutorials/10-creating-features.md +5 -0
  51. package/docs/tutorials/13-introspection.md +12 -2
  52. package/docs/tutorials/19-python-sessions.md +401 -0
  53. package/package.json +8 -5
  54. package/scripts/examples/using-assistant-with-mcp.ts +2 -7
  55. package/scripts/test-linux-binary.sh +80 -0
  56. package/src/agi/container.server.ts +8 -0
  57. package/src/agi/features/assistant.ts +18 -0
  58. package/src/agi/features/autonomous-assistant.ts +435 -0
  59. package/src/agi/features/conversation.ts +58 -6
  60. package/src/agi/features/file-tools.ts +286 -0
  61. package/src/agi/features/luca-coder.ts +643 -0
  62. package/src/bootstrap/generated.ts +705 -107
  63. package/src/cli/build-info.ts +2 -2
  64. package/src/cli/cli.ts +22 -13
  65. package/src/commands/bootstrap.ts +49 -6
  66. package/src/commands/code.ts +369 -0
  67. package/src/commands/describe.ts +7 -2
  68. package/src/commands/index.ts +1 -0
  69. package/src/commands/sandbox-mcp.ts +7 -7
  70. package/src/commands/save-api-docs.ts +1 -1
  71. package/src/container-describer.ts +4 -4
  72. package/src/container.ts +10 -19
  73. package/src/helper.ts +24 -33
  74. package/src/introspection/generated.agi.ts +3026 -849
  75. package/src/introspection/generated.node.ts +1690 -1012
  76. package/src/introspection/generated.web.ts +15 -57
  77. package/src/node/container.ts +5 -5
  78. package/src/node/features/figlet-fonts.ts +597 -0
  79. package/src/node/features/fs.ts +3 -9
  80. package/src/node/features/helpers.ts +20 -0
  81. package/src/node/features/python.ts +429 -16
  82. package/src/node/features/redis.ts +446 -0
  83. package/src/node/features/ui.ts +4 -11
  84. package/src/python/bridge.py +220 -0
  85. package/src/python/generated.ts +227 -0
  86. package/src/scaffolds/generated.ts +1 -1
  87. package/test/python-session.test.ts +105 -0
  88. package/assistants/lucaExpert/CORE.md +0 -37
  89. package/assistants/lucaExpert/hooks.ts +0 -9
  90. package/assistants/lucaExpert/tools.ts +0 -177
  91. package/docs/examples/port-exposer.md +0 -89
  92. package/src/node/features/port-exposer.ts +0 -351
@@ -64,7 +64,20 @@ export const ConversationOptionsSchema = FeatureOptionsSchema.extend({
64
64
  local: z.boolean().optional().describe('Whether to use the local ollama models instead of the remote OpenAI models'),
65
65
 
66
66
  /** Maximum number of output tokens per completion */
67
- maxTokens: z.number().optional().describe('Maximum number of output tokens per completion'),
67
+ maxTokens: z.number().optional().describe('Maximum number of output tokens per completion (default 512)'),
68
+
69
+ /** Sampling temperature (0-2). Higher = more random, lower = more deterministic. */
70
+ temperature: z.number().min(0).max(2).optional().describe('Sampling temperature (0-2). Higher = more random, lower = more deterministic'),
71
+ /** Nucleus sampling: only consider tokens with top_p cumulative probability (0-1). */
72
+ topP: z.number().min(0).max(1).optional().describe('Nucleus sampling cutoff (0-1). Lower = more focused'),
73
+ /** Top-K sampling: only consider the K most likely tokens. Not supported by OpenAI — used with local/Anthropic models. */
74
+ topK: z.number().optional().describe('Top-K sampling. Only supported by local/Anthropic models'),
75
+ /** Penalizes tokens based on how often they already appeared (-2 to 2). */
76
+ frequencyPenalty: z.number().min(-2).max(2).optional().describe('Frequency penalty (-2 to 2). Positive = discourage repetition'),
77
+ /** Penalizes tokens based on whether they appeared at all (-2 to 2). */
78
+ presencePenalty: z.number().min(-2).max(2).optional().describe('Presence penalty (-2 to 2). Positive = encourage new topics'),
79
+ /** Stop sequences — model stops generating when it encounters any of these strings. */
80
+ stop: z.array(z.string()).optional().describe('Stop sequences — generation halts when any of these strings is produced'),
68
81
 
69
82
  /** Enable automatic compaction when estimated input tokens approach the context limit */
70
83
  autoCompact: z.boolean().optional().describe('Enable automatic compaction when input tokens approach the context limit'),
@@ -199,9 +212,9 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
199
212
  /** The active structured output schema for the current ask() call, if any. */
200
213
  private _activeSchema: z.ZodType | null = null
201
214
 
202
- /** Resolved max tokens: per-call override > options-level > undefined (no limit). */
215
+ /** Resolved max tokens: per-call override > options-level > default 512. */
203
216
  private get maxTokens(): number | undefined {
204
- return (this.state.get('callMaxTokens') as number | null) ?? this.options.maxTokens ?? undefined
217
+ return (this.state.get('callMaxTokens') as number | null) ?? this.options.maxTokens ?? 512
205
218
  }
206
219
 
207
220
  /** @returns Default state seeded from options: id, thread, model, initial history, and zero token usage. */
@@ -290,6 +303,26 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
290
303
  return !!this.state.get('streaming')
291
304
  }
292
305
 
306
+ /**
307
+ * Returns the correct parameter name for limiting output tokens.
308
+ * Local models (LM Studio, Ollama) and legacy OpenAI models use max_tokens.
309
+ * Newer OpenAI models (gpt-4o+, gpt-4.1, gpt-5, o1, o3, o4) require max_completion_tokens.
310
+ */
311
+ private get maxTokensParam(): 'max_tokens' | 'max_completion_tokens' {
312
+ if (this.options.local) return 'max_tokens'
313
+
314
+ const model = this.model
315
+ const needsCompletionTokens = [
316
+ 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4',
317
+ ]
318
+
319
+ if (needsCompletionTokens.some((prefix) => model.startsWith(prefix))) {
320
+ return 'max_completion_tokens'
321
+ }
322
+
323
+ return 'max_tokens'
324
+ }
325
+
293
326
  /** The context window size for the current model (from options override or auto-detected). */
294
327
  get contextWindow(): number {
295
328
  return this.options.contextWindow || getContextWindow(this.model)
@@ -705,13 +738,20 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
705
738
  return result
706
739
  }
707
740
 
741
+ let args: Record<string, any>
742
+ try {
743
+ args = rawArgs ? JSON.parse(rawArgs) : {}
744
+ } catch (parseErr: any) {
745
+ const result = JSON.stringify({ error: `Failed to parse tool arguments: ${parseErr.message}`, rawArgs })
746
+ this.emit('toolError', toolName, parseErr)
747
+ return result
748
+ }
749
+
708
750
  if (this.toolExecutor) {
709
- const args = rawArgs ? JSON.parse(rawArgs) : {}
710
751
  return this.toolExecutor(toolName, args, tool.handler)
711
752
  }
712
753
 
713
754
  try {
714
- const args = rawArgs ? JSON.parse(rawArgs) : {}
715
755
  this.emit('toolCall', toolName, args)
716
756
  const output = await tool.handler(args)
717
757
  const result = typeof output === 'string' ? output : JSON.stringify(output)
@@ -759,6 +799,12 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
759
799
  ...(toolsParam ? { tools: toolsParam, tool_choice: 'auto', parallel_tool_calls: true } : {}),
760
800
  ...(this.responsesInstructions ? { instructions: this.responsesInstructions } : {}),
761
801
  ...(this.maxTokens ? { max_output_tokens: this.maxTokens } : {}),
802
+ ...(this.options.temperature != null ? { temperature: this.options.temperature } : {}),
803
+ ...(this.options.topP != null ? { top_p: this.options.topP } : {}),
804
+ ...(this.options.topK != null ? { top_k: this.options.topK } : {}),
805
+ ...(this.options.frequencyPenalty != null ? { frequency_penalty: this.options.frequencyPenalty } : {}),
806
+ ...(this.options.presencePenalty != null ? { presence_penalty: this.options.presencePenalty } : {}),
807
+ ...(this.options.stop ? { stop: this.options.stop } : {}),
762
808
  ...textFormat,
763
809
  })
764
810
 
@@ -901,7 +947,13 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
901
947
  messages: this.messages,
902
948
  stream: true,
903
949
  ...(toolsParam ? { tools: toolsParam, tool_choice: 'auto' } : {}),
904
- ...(this.maxTokens ? { max_tokens: this.maxTokens } : {}),
950
+ ...(this.maxTokens ? { [this.maxTokensParam]: this.maxTokens } : {}),
951
+ ...(this.options.temperature != null ? { temperature: this.options.temperature } : {}),
952
+ ...(this.options.topP != null ? { top_p: this.options.topP } : {}),
953
+ ...(this.options.topK != null ? { top_k: this.options.topK } : {}),
954
+ ...(this.options.frequencyPenalty != null ? { frequency_penalty: this.options.frequencyPenalty } : {}),
955
+ ...(this.options.presencePenalty != null ? { presence_penalty: this.options.presencePenalty } : {}),
956
+ ...(this.options.stop ? { stop: this.options.stop } : {}),
905
957
  ...responseFormat,
906
958
  })
907
959
 
@@ -0,0 +1,286 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from '@soederpop/luca/feature'
4
+ import type { FS } from '../../node/features/fs.js'
5
+ import type { Grep, GrepMatch } from '../../node/features/grep.js'
6
+ import type { Helper } from '../../helper.js'
7
+
8
+ declare module '@soederpop/luca/feature' {
9
+ interface AvailableFeatures {
10
+ fileTools: typeof FileTools
11
+ }
12
+ }
13
+
14
+ export const FileToolsStateSchema = FeatureStateSchema.extend({})
15
+ export const FileToolsOptionsSchema = FeatureOptionsSchema.extend({})
16
+
17
+ /**
18
+ * Curated file-system and code-search tools for AI assistants.
19
+ *
20
+ * Wraps the container's `fs` and `grep` features into a focused tool surface
21
+ * modeled on the tools that coding assistants (Claude Code, Cursor, etc.) rely on:
22
+ * read, write, edit, list, search, find, stat, mkdir, move, copy, delete.
23
+ *
24
+ * Usage:
25
+ * ```typescript
26
+ * const fileTools = container.feature('fileTools')
27
+ * assistant.use(fileTools)
28
+ * // or selectively:
29
+ * assistant.use(fileTools.toTools({ only: ['readFile', 'searchFiles', 'listDirectory'] }))
30
+ * ```
31
+ *
32
+ * @extends Feature
33
+ */
34
+ export class FileTools extends Feature {
35
+ static override shortcut = 'features.fileTools' as const
36
+ static override stateSchema = FileToolsStateSchema
37
+ static override optionsSchema = FileToolsOptionsSchema
38
+
39
+ static { Feature.register(this, 'fileTools') }
40
+
41
+ static tools: Record<string, { schema: z.ZodType; description?: string }> = {
42
+ readFile: {
43
+ description: 'Read the contents of a file. Returns the text content. Use offset/limit to read portions of large files.',
44
+ schema: z.object({
45
+ path: z.string().describe('File path relative to the project root'),
46
+ offset: z.number().optional().describe('Line number to start reading from (1-based)'),
47
+ limit: z.number().optional().describe('Maximum number of lines to read'),
48
+ }).describe('Read the contents of a file. Returns the text content. Use offset/limit to read portions of large files.'),
49
+ },
50
+ writeFile: {
51
+ description: 'Create a new file or overwrite an existing file with the given content. Prefer editFile for modifying existing files.',
52
+ schema: z.object({
53
+ path: z.string().describe('File path relative to the project root'),
54
+ content: z.string().describe('The full content to write'),
55
+ }).describe('Create a new file or overwrite an existing file with the given content. Prefer editFile for modifying existing files.'),
56
+ },
57
+ editFile: {
58
+ description: 'Make a surgical edit to a file by replacing an exact string match. The oldString must appear exactly once in the file (unless replaceAll is true). This is the preferred way to modify existing files.',
59
+ schema: z.object({
60
+ path: z.string().describe('File path relative to the project root'),
61
+ oldString: z.string().describe('The exact text to find and replace'),
62
+ newString: z.string().describe('The replacement text'),
63
+ replaceAll: z.boolean().optional().describe('Replace all occurrences instead of requiring uniqueness (default: false)'),
64
+ }).describe('Make a surgical edit to a file by replacing an exact string match. The oldString must appear exactly once in the file (unless replaceAll is true).'),
65
+ },
66
+ listDirectory: {
67
+ description: 'List files and directories at a path. Returns arrays of file and directory names.',
68
+ schema: z.object({
69
+ path: z.string().optional().describe('Directory path relative to project root (defaults to ".")'),
70
+ recursive: z.boolean().optional().describe('Whether to list recursively (default: false)'),
71
+ include: z.string().optional().describe('Glob pattern to filter results (e.g. "*.ts")'),
72
+ exclude: z.string().optional().describe('Glob pattern to exclude (e.g. "node_modules")'),
73
+ }).describe('List files and directories at a path. Returns arrays of file and directory names.'),
74
+ },
75
+ searchFiles: {
76
+ description: 'Search file contents for a pattern using ripgrep. Returns structured matches with file, line number, and content.',
77
+ schema: z.object({
78
+ pattern: z.string().describe('Search pattern (regex supported)'),
79
+ path: z.string().optional().describe('Directory to search in (defaults to project root)'),
80
+ include: z.string().optional().describe('Glob pattern to filter files (e.g. "*.ts")'),
81
+ exclude: z.string().optional().describe('Glob pattern to exclude (e.g. "node_modules")'),
82
+ ignoreCase: z.boolean().optional().describe('Case insensitive search'),
83
+ maxResults: z.number().optional().describe('Maximum number of results to return'),
84
+ }).describe('Search file contents for a pattern using ripgrep. Returns structured matches with file, line number, and content.'),
85
+ },
86
+ findFiles: {
87
+ description: 'Find files by name/glob pattern. Returns matching file paths.',
88
+ schema: z.object({
89
+ pattern: z.string().describe('Glob pattern to match (e.g. "**/*.test.ts", "src/**/*.tsx")'),
90
+ path: z.string().optional().describe('Directory to search from (defaults to project root)'),
91
+ exclude: z.string().optional().describe('Glob pattern to exclude'),
92
+ }).describe('Find files by name/glob pattern. Returns matching file paths.'),
93
+ },
94
+ fileInfo: {
95
+ description: 'Get information about a file or directory: whether it exists, its type (file/directory), size, and modification time.',
96
+ schema: z.object({
97
+ path: z.string().describe('File path relative to the project root'),
98
+ }).describe('Get information about a file or directory: whether it exists, its type, size, and modification time.'),
99
+ },
100
+ createDirectory: {
101
+ description: 'Create a directory and all parent directories if they do not exist.',
102
+ schema: z.object({
103
+ path: z.string().describe('Directory path relative to the project root'),
104
+ }).describe('Create a directory and all parent directories if they do not exist.'),
105
+ },
106
+ moveFile: {
107
+ description: 'Move or rename a file or directory.',
108
+ schema: z.object({
109
+ source: z.string().describe('Source path relative to the project root'),
110
+ destination: z.string().describe('Destination path relative to the project root'),
111
+ }).describe('Move or rename a file or directory.'),
112
+ },
113
+ copyFile: {
114
+ description: 'Copy a file or directory (recursive for directories).',
115
+ schema: z.object({
116
+ source: z.string().describe('Source path relative to the project root'),
117
+ destination: z.string().describe('Destination path relative to the project root'),
118
+ }).describe('Copy a file or directory (recursive for directories).'),
119
+ },
120
+ deleteFile: {
121
+ description: 'Delete a file. Does not delete directories — use with care.',
122
+ schema: z.object({
123
+ path: z.string().describe('File path relative to the project root'),
124
+ }).describe('Delete a file. Does not delete directories — use with care.'),
125
+ },
126
+ }
127
+
128
+ private get fs(): FS {
129
+ return this.container.feature('fs') as unknown as FS
130
+ }
131
+
132
+ private get grep(): Grep {
133
+ return this.container.feature('grep') as unknown as Grep
134
+ }
135
+
136
+ // -------------------------------------------------------------------------
137
+ // Tool implementations — each matches a static tools key by name
138
+ // -------------------------------------------------------------------------
139
+
140
+ async readFile(args: { path: string; offset?: number; limit?: number }): Promise<string> {
141
+ const content = await this.fs.readFileAsync(args.path) as string
142
+
143
+ if (args.offset || args.limit) {
144
+ const lines = content.split('\n')
145
+ const start = Math.max(0, (args.offset || 1) - 1)
146
+ const end = args.limit ? start + args.limit : lines.length
147
+ return lines.slice(start, end).map((line, i) => `${start + i + 1}\t${line}`).join('\n')
148
+ }
149
+
150
+ return content
151
+ }
152
+
153
+ async writeFile(args: { path: string; content: string }): Promise<string> {
154
+ await this.fs.ensureFolderAsync(args.path.includes('/') ? args.path.split('/').slice(0, -1).join('/') : '.')
155
+ await this.fs.writeFileAsync(args.path, args.content)
156
+ return `Wrote ${args.content.length} bytes to ${args.path}`
157
+ }
158
+
159
+ async editFile(args: { path: string; oldString: string; newString: string; replaceAll?: boolean }): Promise<string> {
160
+ const content = await this.fs.readFileAsync(args.path) as string
161
+
162
+ if (args.replaceAll) {
163
+ const updated = content.split(args.oldString).join(args.newString)
164
+ const count = (content.split(args.oldString).length - 1)
165
+ if (count === 0) return `Error: "${args.oldString}" not found in ${args.path}`
166
+ await this.fs.writeFileAsync(args.path, updated)
167
+ return `Replaced ${count} occurrence(s) in ${args.path}`
168
+ }
169
+
170
+ const idx = content.indexOf(args.oldString)
171
+ if (idx === -1) return `Error: "${args.oldString}" not found in ${args.path}`
172
+
173
+ const lastIdx = content.lastIndexOf(args.oldString)
174
+ if (idx !== lastIdx) {
175
+ const count = content.split(args.oldString).length - 1
176
+ return `Error: "${args.oldString}" appears ${count} times in ${args.path}. Use replaceAll or provide a more specific string.`
177
+ }
178
+
179
+ const updated = content.slice(0, idx) + args.newString + content.slice(idx + args.oldString.length)
180
+ await this.fs.writeFileAsync(args.path, updated)
181
+ return `Edited ${args.path}`
182
+ }
183
+
184
+ async listDirectory(args: { path?: string; recursive?: boolean; include?: string; exclude?: string }): Promise<string> {
185
+ const dir = args.path || '.'
186
+ const result = await this.fs.walkAsync(dir, {
187
+ files: true,
188
+ directories: true,
189
+ relative: true,
190
+ include: args.include ? [args.include] : undefined,
191
+ exclude: args.exclude ? [args.exclude] : ['node_modules', '.git'],
192
+ })
193
+
194
+ // For non-recursive, filter to top-level only
195
+ if (!args.recursive) {
196
+ result.files = result.files.filter(f => !f.includes('/'))
197
+ result.directories = result.directories.filter(d => !d.includes('/'))
198
+ }
199
+
200
+ return JSON.stringify({ files: result.files, directories: result.directories })
201
+ }
202
+
203
+ async searchFiles(args: { pattern: string; path?: string; include?: string; exclude?: string; ignoreCase?: boolean; maxResults?: number }): Promise<string> {
204
+ const results: GrepMatch[] = await this.grep.search({
205
+ pattern: args.pattern,
206
+ path: args.path,
207
+ include: args.include,
208
+ exclude: args.exclude || 'node_modules',
209
+ ignoreCase: args.ignoreCase,
210
+ maxResults: args.maxResults || 50,
211
+ })
212
+
213
+ return JSON.stringify(results.map(r => ({
214
+ file: r.file,
215
+ line: r.line,
216
+ content: r.content,
217
+ })))
218
+ }
219
+
220
+ async findFiles(args: { pattern: string; path?: string; exclude?: string }): Promise<string> {
221
+ const dir = args.path || '.'
222
+ const result = await this.fs.walkAsync(dir, {
223
+ files: true,
224
+ directories: false,
225
+ relative: true,
226
+ include: [args.pattern],
227
+ exclude: args.exclude ? [args.exclude, 'node_modules', '.git'] : ['node_modules', '.git'],
228
+ })
229
+ return JSON.stringify(result.files)
230
+ }
231
+
232
+ async fileInfo(args: { path: string }): Promise<string> {
233
+ const exists = await this.fs.existsAsync(args.path)
234
+ if (!exists) return JSON.stringify({ exists: false })
235
+
236
+ const stat = await this.fs.statAsync(args.path)
237
+ return JSON.stringify({
238
+ exists: true,
239
+ isFile: stat.isFile(),
240
+ isDirectory: stat.isDirectory(),
241
+ size: stat.size,
242
+ modified: stat.mtime.toISOString(),
243
+ })
244
+ }
245
+
246
+ async createDirectory(args: { path: string }): Promise<string> {
247
+ await this.fs.ensureFolderAsync(args.path)
248
+ return `Created ${args.path}`
249
+ }
250
+
251
+ async moveFile(args: { source: string; destination: string }): Promise<string> {
252
+ await this.fs.moveAsync(args.source, args.destination)
253
+ return `Moved ${args.source} → ${args.destination}`
254
+ }
255
+
256
+ async copyFile(args: { source: string; destination: string }): Promise<string> {
257
+ await this.fs.copyAsync(args.source, args.destination)
258
+ return `Copied ${args.source} → ${args.destination}`
259
+ }
260
+
261
+ async deleteFile(args: { path: string }): Promise<string> {
262
+ const isDir = await this.fs.isDirectoryAsync(args.path)
263
+ if (isDir) return `Error: "${args.path}" is a directory. Use deleteFile only for files.`
264
+ await this.fs.rm(args.path)
265
+ return `Deleted ${args.path}`
266
+ }
267
+
268
+ /**
269
+ * When an assistant uses fileTools, inject system prompt guidance
270
+ * about how to use the tools effectively.
271
+ */
272
+ override setupToolsConsumer(consumer: Helper) {
273
+ if (typeof (consumer as any).addSystemPromptExtension === 'function') {
274
+ (consumer as any).addSystemPromptExtension('fileTools', [
275
+ '## File Tools',
276
+ '- All file paths are relative to the project root unless they start with /',
277
+ '- Use `searchFiles` to understand code before modifying it',
278
+ '- Use `editFile` for surgical changes to existing files — prefer it over `writeFile`',
279
+ '- Use `listDirectory` to explore before assuming paths exist',
280
+ '- Use `readFile` with offset/limit for large files instead of reading the entire file',
281
+ ].join('\n'))
282
+ }
283
+ }
284
+ }
285
+
286
+ export default FileTools