@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.
- package/README.md +241 -36
- package/bun.lock +24 -6
- package/commands/build-python-bridge.ts +43 -0
- package/docs/README.md +1 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -1
- package/docs/apis/clients/rest.md +7 -7
- package/docs/apis/clients/websocket.md +23 -10
- package/docs/apis/features/agi/assistant.md +155 -8
- package/docs/apis/features/agi/assistants-manager.md +90 -22
- package/docs/apis/features/agi/auto-assistant.md +377 -0
- package/docs/apis/features/agi/browser-use.md +802 -0
- package/docs/apis/features/agi/claude-code.md +6 -1
- package/docs/apis/features/agi/conversation-history.md +7 -6
- package/docs/apis/features/agi/conversation.md +111 -38
- package/docs/apis/features/agi/docs-reader.md +35 -57
- package/docs/apis/features/agi/file-tools.md +163 -0
- package/docs/apis/features/agi/openapi.md +2 -2
- package/docs/apis/features/agi/skills-library.md +227 -0
- package/docs/apis/features/node/content-db.md +125 -4
- package/docs/apis/features/node/disk-cache.md +11 -11
- package/docs/apis/features/node/downloader.md +1 -1
- package/docs/apis/features/node/file-manager.md +15 -15
- package/docs/apis/features/node/fs.md +78 -21
- package/docs/apis/features/node/git.md +50 -10
- package/docs/apis/features/node/google-calendar.md +3 -0
- package/docs/apis/features/node/google-docs.md +10 -1
- package/docs/apis/features/node/google-drive.md +3 -0
- package/docs/apis/features/node/google-mail.md +214 -0
- package/docs/apis/features/node/google-sheets.md +3 -0
- package/docs/apis/features/node/ink.md +10 -10
- package/docs/apis/features/node/ipc-socket.md +83 -93
- package/docs/apis/features/node/networking.md +5 -5
- package/docs/apis/features/node/os.md +7 -7
- package/docs/apis/features/node/package-finder.md +14 -14
- package/docs/apis/features/node/proc.md +2 -1
- package/docs/apis/features/node/process-manager.md +70 -3
- package/docs/apis/features/node/python.md +265 -9
- package/docs/apis/features/node/redis.md +380 -0
- package/docs/apis/features/node/ui.md +13 -13
- package/docs/apis/servers/express.md +35 -7
- package/docs/apis/servers/mcp.md +3 -3
- package/docs/apis/servers/websocket.md +51 -8
- package/docs/bootstrap/CLAUDE.md +1 -1
- package/docs/bootstrap/SKILL.md +93 -7
- package/docs/examples/feature-as-tool-provider.md +143 -0
- package/docs/examples/python.md +42 -1
- package/docs/introspection.md +15 -5
- package/docs/tutorials/00-bootstrap.md +3 -3
- package/docs/tutorials/02-container.md +2 -2
- package/docs/tutorials/10-creating-features.md +5 -0
- package/docs/tutorials/13-introspection.md +12 -2
- package/docs/tutorials/19-python-sessions.md +401 -0
- package/package.json +8 -5
- package/scripts/examples/using-assistant-with-mcp.ts +2 -7
- package/scripts/test-linux-binary.sh +80 -0
- package/src/agi/container.server.ts +8 -0
- package/src/agi/features/assistant.ts +18 -0
- package/src/agi/features/autonomous-assistant.ts +435 -0
- package/src/agi/features/conversation.ts +58 -6
- package/src/agi/features/file-tools.ts +286 -0
- package/src/agi/features/luca-coder.ts +643 -0
- package/src/bootstrap/generated.ts +705 -107
- package/src/cli/build-info.ts +2 -2
- package/src/cli/cli.ts +22 -13
- package/src/commands/bootstrap.ts +49 -6
- package/src/commands/code.ts +369 -0
- package/src/commands/describe.ts +7 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/sandbox-mcp.ts +7 -7
- package/src/commands/save-api-docs.ts +1 -1
- package/src/container-describer.ts +4 -4
- package/src/container.ts +10 -19
- package/src/helper.ts +24 -33
- package/src/introspection/generated.agi.ts +3026 -849
- package/src/introspection/generated.node.ts +1690 -1012
- package/src/introspection/generated.web.ts +15 -57
- package/src/node/container.ts +5 -5
- package/src/node/features/figlet-fonts.ts +597 -0
- package/src/node/features/fs.ts +3 -9
- package/src/node/features/helpers.ts +20 -0
- package/src/node/features/python.ts +429 -16
- package/src/node/features/redis.ts +446 -0
- package/src/node/features/ui.ts +4 -11
- package/src/python/bridge.py +220 -0
- package/src/python/generated.ts +227 -0
- package/src/scaffolds/generated.ts +1 -1
- package/test/python-session.test.ts +105 -0
- package/assistants/lucaExpert/CORE.md +0 -37
- package/assistants/lucaExpert/hooks.ts +0 -9
- package/assistants/lucaExpert/tools.ts +0 -177
- package/docs/examples/port-exposer.md +0 -89
- 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 >
|
|
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 ??
|
|
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 ? {
|
|
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
|