@hanzo/dev 1.2.0 → 2.0.0
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/.eslintrc.js +25 -0
- package/dist/cli/dev.js +8202 -553
- package/jest.config.js +30 -0
- package/package.json +13 -1
- package/src/cli/dev.ts +456 -106
- package/src/lib/agent-loop.ts +552 -0
- package/src/lib/code-act-agent.ts +378 -0
- package/src/lib/config.ts +163 -0
- package/src/lib/editor.ts +368 -0
- package/src/lib/function-calling.ts +318 -0
- package/src/lib/mcp-client.ts +259 -0
- package/src/lib/peer-agent-network.ts +584 -0
- package/src/lib/unified-workspace.ts +435 -0
- package/tests/browser-integration.test.ts +242 -0
- package/tests/code-act-agent.test.ts +305 -0
- package/tests/editor.test.ts +223 -0
- package/tests/mcp-client.test.ts +238 -0
- package/tests/peer-agent-network.test.ts +340 -0
- package/tests/setup.ts +25 -0
- package/tests/swe-bench.test.ts +357 -0
- package/tsconfig.json +13 -15
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export interface EditCommand {
|
|
6
|
+
command: 'view' | 'create' | 'str_replace' | 'insert' | 'undo_edit';
|
|
7
|
+
path?: string;
|
|
8
|
+
content?: string;
|
|
9
|
+
oldStr?: string;
|
|
10
|
+
newStr?: string;
|
|
11
|
+
startLine?: number;
|
|
12
|
+
endLine?: number;
|
|
13
|
+
lineNumber?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EditResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
message: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FileEditor {
|
|
24
|
+
private static readonly MAX_LINES_TO_EDIT = 300;
|
|
25
|
+
private static readonly SUPPORTED_BINARY_FORMATS = [
|
|
26
|
+
'.pdf', '.docx', '.xlsx', '.mp3', '.mp4', '.jpg', '.jpeg', '.png', '.gif'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
private editHistory: Map<string, string[]> = new Map();
|
|
30
|
+
private currentFile: string | null = null;
|
|
31
|
+
|
|
32
|
+
async execute(command: EditCommand): Promise<EditResult> {
|
|
33
|
+
switch (command.command) {
|
|
34
|
+
case 'view':
|
|
35
|
+
return this.viewFile(command.path!, command.startLine, command.endLine);
|
|
36
|
+
case 'create':
|
|
37
|
+
return this.createFile(command.path!, command.content || '');
|
|
38
|
+
case 'str_replace':
|
|
39
|
+
return this.strReplace(command.path!, command.oldStr!, command.newStr!);
|
|
40
|
+
case 'insert':
|
|
41
|
+
return this.insertLine(command.path!, command.lineNumber!, command.content!);
|
|
42
|
+
case 'undo_edit':
|
|
43
|
+
return this.undoEdit(command.path!);
|
|
44
|
+
default:
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
message: `Unknown command: ${command.command}`,
|
|
48
|
+
error: 'Invalid command'
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async viewFile(filePath: string, startLine?: number, endLine?: number): Promise<EditResult> {
|
|
54
|
+
try {
|
|
55
|
+
if (!fs.existsSync(filePath)) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
message: `File not found: ${filePath}`,
|
|
59
|
+
error: 'FILE_NOT_FOUND'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
64
|
+
if (FileEditor.SUPPORTED_BINARY_FORMATS.includes(ext)) {
|
|
65
|
+
const stats = fs.statSync(filePath);
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
message: `Binary file: ${filePath} (${this.formatBytes(stats.size)})`,
|
|
69
|
+
content: `[Binary file of type ${ext}]`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
74
|
+
const lines = content.split('\n');
|
|
75
|
+
|
|
76
|
+
if (startLine !== undefined || endLine !== undefined) {
|
|
77
|
+
const start = (startLine || 1) - 1;
|
|
78
|
+
const end = endLine || lines.length;
|
|
79
|
+
const viewLines = lines.slice(start, end);
|
|
80
|
+
|
|
81
|
+
const result = viewLines.map((line, idx) =>
|
|
82
|
+
`${chalk.gray(`${start + idx + 1}:`)} ${line}`
|
|
83
|
+
).join('\n');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
message: `Viewing ${filePath} lines ${start + 1}-${end}`,
|
|
88
|
+
content: result
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Full file view with line numbers
|
|
93
|
+
const result = lines.map((line, idx) =>
|
|
94
|
+
`${chalk.gray(`${idx + 1}:`)} ${line}`
|
|
95
|
+
).join('\n');
|
|
96
|
+
|
|
97
|
+
this.currentFile = filePath;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
message: `Viewing ${filePath} (${lines.length} lines)`,
|
|
102
|
+
content: result
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
message: `Error reading file: ${error}`,
|
|
108
|
+
error: 'READ_ERROR'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async createFile(filePath: string, content: string): Promise<EditResult> {
|
|
114
|
+
try {
|
|
115
|
+
if (fs.existsSync(filePath)) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
message: `File already exists: ${filePath}`,
|
|
119
|
+
error: 'FILE_EXISTS'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const dir = path.dirname(filePath);
|
|
124
|
+
if (!fs.existsSync(dir)) {
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fs.writeFileSync(filePath, content);
|
|
129
|
+
this.addToHistory(filePath, '');
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
message: `Created file: ${filePath}`,
|
|
134
|
+
content: content
|
|
135
|
+
};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
message: `Error creating file: ${error}`,
|
|
140
|
+
error: 'CREATE_ERROR'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async strReplace(filePath: string, oldStr: string, newStr: string): Promise<EditResult> {
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.existsSync(filePath)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
message: `File not found: ${filePath}`,
|
|
151
|
+
error: 'FILE_NOT_FOUND'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
156
|
+
|
|
157
|
+
// Check if old string exists
|
|
158
|
+
if (!content.includes(oldStr)) {
|
|
159
|
+
return {
|
|
160
|
+
success: false,
|
|
161
|
+
message: `String not found in file: "${oldStr}"`,
|
|
162
|
+
error: 'STRING_NOT_FOUND'
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if old string is unique
|
|
167
|
+
const occurrences = content.split(oldStr).length - 1;
|
|
168
|
+
if (occurrences > 1) {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
message: `String "${oldStr}" found ${occurrences} times. Please provide a unique string.`,
|
|
172
|
+
error: 'STRING_NOT_UNIQUE'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Save to history
|
|
177
|
+
this.addToHistory(filePath, content);
|
|
178
|
+
|
|
179
|
+
// Replace
|
|
180
|
+
const newContent = content.replace(oldStr, newStr);
|
|
181
|
+
fs.writeFileSync(filePath, newContent);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
success: true,
|
|
185
|
+
message: `Replaced string in ${filePath}`,
|
|
186
|
+
content: this.showDiff(oldStr, newStr)
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
message: `Error replacing string: ${error}`,
|
|
192
|
+
error: 'REPLACE_ERROR'
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async insertLine(filePath: string, lineNumber: number, content: string): Promise<EditResult> {
|
|
198
|
+
try {
|
|
199
|
+
if (!fs.existsSync(filePath)) {
|
|
200
|
+
return {
|
|
201
|
+
success: false,
|
|
202
|
+
message: `File not found: ${filePath}`,
|
|
203
|
+
error: 'FILE_NOT_FOUND'
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
208
|
+
const lines = fileContent.split('\n');
|
|
209
|
+
|
|
210
|
+
if (lineNumber < 1 || lineNumber > lines.length + 1) {
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
message: `Invalid line number: ${lineNumber}. File has ${lines.length} lines.`,
|
|
214
|
+
error: 'INVALID_LINE_NUMBER'
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Save to history
|
|
219
|
+
this.addToHistory(filePath, fileContent);
|
|
220
|
+
|
|
221
|
+
// Insert line
|
|
222
|
+
lines.splice(lineNumber - 1, 0, content);
|
|
223
|
+
const newContent = lines.join('\n');
|
|
224
|
+
fs.writeFileSync(filePath, newContent);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
success: true,
|
|
228
|
+
message: `Inserted line at ${lineNumber} in ${filePath}`,
|
|
229
|
+
content: content
|
|
230
|
+
};
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
message: `Error inserting line: ${error}`,
|
|
235
|
+
error: 'INSERT_ERROR'
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async undoEdit(filePath: string): Promise<EditResult> {
|
|
241
|
+
const history = this.editHistory.get(filePath);
|
|
242
|
+
if (!history || history.length === 0) {
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
message: `No edit history for ${filePath}`,
|
|
246
|
+
error: 'NO_HISTORY'
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const previousContent = history.pop()!;
|
|
251
|
+
fs.writeFileSync(filePath, previousContent);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
success: true,
|
|
255
|
+
message: `Undid last edit to ${filePath}`,
|
|
256
|
+
content: 'Edit undone'
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private addToHistory(filePath: string, content: string): void {
|
|
261
|
+
if (!this.editHistory.has(filePath)) {
|
|
262
|
+
this.editHistory.set(filePath, []);
|
|
263
|
+
}
|
|
264
|
+
const history = this.editHistory.get(filePath)!;
|
|
265
|
+
history.push(content);
|
|
266
|
+
|
|
267
|
+
// Keep only last 10 edits
|
|
268
|
+
if (history.length > 10) {
|
|
269
|
+
history.shift();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private showDiff(oldStr: string, newStr: string): string {
|
|
274
|
+
const oldLines = oldStr.split('\n');
|
|
275
|
+
const newLines = newStr.split('\n');
|
|
276
|
+
|
|
277
|
+
let diff = '';
|
|
278
|
+
oldLines.forEach(line => {
|
|
279
|
+
diff += chalk.red(`- ${line}\n`);
|
|
280
|
+
});
|
|
281
|
+
newLines.forEach(line => {
|
|
282
|
+
diff += chalk.green(`+ ${line}\n`);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return diff.trim();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private formatBytes(bytes: number): string {
|
|
289
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
290
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
|
291
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Chunk localizer for finding relevant code sections
|
|
296
|
+
export class ChunkLocalizer {
|
|
297
|
+
static findRelevantChunk(content: string, searchPattern: string, maxLines: number = 50): {
|
|
298
|
+
startLine: number;
|
|
299
|
+
endLine: number;
|
|
300
|
+
content: string;
|
|
301
|
+
} | null {
|
|
302
|
+
const lines = content.split('\n');
|
|
303
|
+
const searchLower = searchPattern.toLowerCase();
|
|
304
|
+
|
|
305
|
+
let bestMatch = { score: 0, index: -1 };
|
|
306
|
+
|
|
307
|
+
// Find best matching line
|
|
308
|
+
lines.forEach((line, index) => {
|
|
309
|
+
const score = this.calculateSimilarity(line.toLowerCase(), searchLower);
|
|
310
|
+
if (score > bestMatch.score) {
|
|
311
|
+
bestMatch = { score, index };
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (bestMatch.index === -1 || bestMatch.score < 0.3) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Extract chunk around best match
|
|
320
|
+
const halfLines = Math.floor(maxLines / 2);
|
|
321
|
+
const startLine = Math.max(0, bestMatch.index - halfLines);
|
|
322
|
+
const endLine = Math.min(lines.length, bestMatch.index + halfLines);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
startLine: startLine + 1,
|
|
326
|
+
endLine: endLine,
|
|
327
|
+
content: lines.slice(startLine, endLine).join('\n')
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private static calculateSimilarity(str1: string, str2: string): number {
|
|
332
|
+
const longer = str1.length > str2.length ? str1 : str2;
|
|
333
|
+
const shorter = str1.length > str2.length ? str2 : str1;
|
|
334
|
+
|
|
335
|
+
if (longer.length === 0) return 1.0;
|
|
336
|
+
|
|
337
|
+
const distance = this.levenshteinDistance(longer, shorter);
|
|
338
|
+
return (longer.length - distance) / longer.length;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private static levenshteinDistance(str1: string, str2: string): number {
|
|
342
|
+
const matrix: number[][] = [];
|
|
343
|
+
|
|
344
|
+
for (let i = 0; i <= str2.length; i++) {
|
|
345
|
+
matrix[i] = [i];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (let j = 0; j <= str1.length; j++) {
|
|
349
|
+
matrix[0][j] = j;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (let i = 1; i <= str2.length; i++) {
|
|
353
|
+
for (let j = 1; j <= str1.length; j++) {
|
|
354
|
+
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
355
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
356
|
+
} else {
|
|
357
|
+
matrix[i][j] = Math.min(
|
|
358
|
+
matrix[i - 1][j - 1] + 1,
|
|
359
|
+
matrix[i][j - 1] + 1,
|
|
360
|
+
matrix[i - 1][j] + 1
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return matrix[str2.length][str1.length];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { FileEditor, EditCommand } from './editor';
|
|
2
|
+
import { MCPClient, MCPSession } from './mcp-client';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
|
|
7
|
+
export interface Tool {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
parameters: any; // JSON Schema
|
|
11
|
+
handler: (args: any) => Promise<any>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FunctionCall {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
arguments: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToolCallResult {
|
|
21
|
+
id: string;
|
|
22
|
+
result?: any;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class FunctionCallingSystem {
|
|
27
|
+
private tools: Map<string, Tool> = new Map();
|
|
28
|
+
private fileEditor: FileEditor;
|
|
29
|
+
private mcpClient: MCPClient;
|
|
30
|
+
private mcpSessions: Map<string, MCPSession> = new Map();
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
this.fileEditor = new FileEditor();
|
|
34
|
+
this.mcpClient = new MCPClient();
|
|
35
|
+
this.registerBuiltinTools();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private registerBuiltinTools() {
|
|
39
|
+
// File editing tools
|
|
40
|
+
this.registerTool({
|
|
41
|
+
name: 'view_file',
|
|
42
|
+
description: 'View contents of a file with optional line range',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
path: { type: 'string', description: 'File path' },
|
|
47
|
+
start_line: { type: 'number', description: 'Start line (optional)' },
|
|
48
|
+
end_line: { type: 'number', description: 'End line (optional)' }
|
|
49
|
+
},
|
|
50
|
+
required: ['path']
|
|
51
|
+
},
|
|
52
|
+
handler: async (args) => {
|
|
53
|
+
const result = await this.fileEditor.execute({
|
|
54
|
+
command: 'view',
|
|
55
|
+
path: args.path,
|
|
56
|
+
startLine: args.start_line,
|
|
57
|
+
endLine: args.end_line
|
|
58
|
+
});
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.registerTool({
|
|
64
|
+
name: 'create_file',
|
|
65
|
+
description: 'Create a new file with content',
|
|
66
|
+
parameters: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
path: { type: 'string', description: 'File path' },
|
|
70
|
+
content: { type: 'string', description: 'File content' }
|
|
71
|
+
},
|
|
72
|
+
required: ['path', 'content']
|
|
73
|
+
},
|
|
74
|
+
handler: async (args) => {
|
|
75
|
+
const result = await this.fileEditor.execute({
|
|
76
|
+
command: 'create',
|
|
77
|
+
path: args.path,
|
|
78
|
+
content: args.content
|
|
79
|
+
});
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.registerTool({
|
|
85
|
+
name: 'str_replace',
|
|
86
|
+
description: 'Replace exact string match in file',
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
path: { type: 'string', description: 'File path' },
|
|
91
|
+
old_str: { type: 'string', description: 'String to replace' },
|
|
92
|
+
new_str: { type: 'string', description: 'Replacement string' }
|
|
93
|
+
},
|
|
94
|
+
required: ['path', 'old_str', 'new_str']
|
|
95
|
+
},
|
|
96
|
+
handler: async (args) => {
|
|
97
|
+
const result = await this.fileEditor.execute({
|
|
98
|
+
command: 'str_replace',
|
|
99
|
+
path: args.path,
|
|
100
|
+
oldStr: args.old_str,
|
|
101
|
+
newStr: args.new_str
|
|
102
|
+
});
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Command execution
|
|
108
|
+
this.registerTool({
|
|
109
|
+
name: 'run_command',
|
|
110
|
+
description: 'Execute a shell command',
|
|
111
|
+
parameters: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
command: { type: 'string', description: 'Command to execute' },
|
|
115
|
+
cwd: { type: 'string', description: 'Working directory (optional)' },
|
|
116
|
+
timeout: { type: 'number', description: 'Timeout in ms (optional)' }
|
|
117
|
+
},
|
|
118
|
+
required: ['command']
|
|
119
|
+
},
|
|
120
|
+
handler: async (args) => {
|
|
121
|
+
return this.executeCommand(args.command, args.cwd, args.timeout);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// File system tools
|
|
126
|
+
this.registerTool({
|
|
127
|
+
name: 'list_directory',
|
|
128
|
+
description: 'List contents of a directory',
|
|
129
|
+
parameters: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
path: { type: 'string', description: 'Directory path' }
|
|
133
|
+
},
|
|
134
|
+
required: ['path']
|
|
135
|
+
},
|
|
136
|
+
handler: async (args) => {
|
|
137
|
+
try {
|
|
138
|
+
const files = fs.readdirSync(args.path);
|
|
139
|
+
const details = files.map(file => {
|
|
140
|
+
const fullPath = path.join(args.path, file);
|
|
141
|
+
const stats = fs.statSync(fullPath);
|
|
142
|
+
return {
|
|
143
|
+
name: file,
|
|
144
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
145
|
+
size: stats.size,
|
|
146
|
+
modified: stats.mtime
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
return { success: true, files: details };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { success: false, error: error.toString() };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.registerTool({
|
|
157
|
+
name: 'search_files',
|
|
158
|
+
description: 'Search for files matching a pattern',
|
|
159
|
+
parameters: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
pattern: { type: 'string', description: 'Search pattern' },
|
|
163
|
+
path: { type: 'string', description: 'Directory to search in' },
|
|
164
|
+
regex: { type: 'boolean', description: 'Use regex matching' }
|
|
165
|
+
},
|
|
166
|
+
required: ['pattern']
|
|
167
|
+
},
|
|
168
|
+
handler: async (args) => {
|
|
169
|
+
const searchPath = args.path || process.cwd();
|
|
170
|
+
return this.searchFiles(searchPath, args.pattern, args.regex);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
registerTool(tool: Tool): void {
|
|
176
|
+
this.tools.set(tool.name, tool);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async registerMCPServer(name: string, session: MCPSession): Promise<void> {
|
|
180
|
+
this.mcpSessions.set(name, session);
|
|
181
|
+
|
|
182
|
+
// Register MCP tools as function calling tools
|
|
183
|
+
for (const tool of session.tools) {
|
|
184
|
+
this.registerTool({
|
|
185
|
+
name: `${name}.${tool.name}`,
|
|
186
|
+
description: tool.description,
|
|
187
|
+
parameters: tool.inputSchema,
|
|
188
|
+
handler: async (args) => {
|
|
189
|
+
return this.mcpClient.callTool(session.id, tool.name, args);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async callFunction(call: FunctionCall): Promise<ToolCallResult> {
|
|
196
|
+
const tool = this.tools.get(call.name);
|
|
197
|
+
if (!tool) {
|
|
198
|
+
return {
|
|
199
|
+
id: call.id,
|
|
200
|
+
error: `Tool '${call.name}' not found`
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const result = await tool.handler(call.arguments);
|
|
206
|
+
return {
|
|
207
|
+
id: call.id,
|
|
208
|
+
result
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
id: call.id,
|
|
213
|
+
error: error instanceof Error ? error.message : String(error)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async callFunctions(calls: FunctionCall[]): Promise<ToolCallResult[]> {
|
|
219
|
+
return Promise.all(calls.map(call => this.callFunction(call)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getAvailableTools(): Tool[] {
|
|
223
|
+
return Array.from(this.tools.values());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
getToolSchema(name: string): any {
|
|
227
|
+
const tool = this.tools.get(name);
|
|
228
|
+
if (!tool) return null;
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
type: 'function',
|
|
232
|
+
function: {
|
|
233
|
+
name: tool.name,
|
|
234
|
+
description: tool.description,
|
|
235
|
+
parameters: tool.parameters
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getAllToolSchemas(): any[] {
|
|
241
|
+
return this.getAvailableTools().map(tool => this.getToolSchema(tool.name));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async executeCommand(command: string, cwd?: string, timeout?: number): Promise<any> {
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
const options: any = {
|
|
247
|
+
shell: true,
|
|
248
|
+
cwd: cwd || process.cwd()
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const proc = spawn(command, [], options);
|
|
252
|
+
let stdout = '';
|
|
253
|
+
let stderr = '';
|
|
254
|
+
|
|
255
|
+
const timer = timeout ? setTimeout(() => {
|
|
256
|
+
proc.kill();
|
|
257
|
+
reject(new Error(`Command timeout after ${timeout}ms`));
|
|
258
|
+
}, timeout) : null;
|
|
259
|
+
|
|
260
|
+
proc.stdout?.on('data', (data) => {
|
|
261
|
+
stdout += data.toString();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
proc.stderr?.on('data', (data) => {
|
|
265
|
+
stderr += data.toString();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
proc.on('close', (code) => {
|
|
269
|
+
if (timer) clearTimeout(timer);
|
|
270
|
+
|
|
271
|
+
resolve({
|
|
272
|
+
success: code === 0,
|
|
273
|
+
code,
|
|
274
|
+
stdout,
|
|
275
|
+
stderr
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
proc.on('error', (error) => {
|
|
280
|
+
if (timer) clearTimeout(timer);
|
|
281
|
+
reject(error);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async searchFiles(searchPath: string, pattern: string, useRegex: boolean = false): Promise<any> {
|
|
287
|
+
const results: string[] = [];
|
|
288
|
+
const regex = useRegex ? new RegExp(pattern) : null;
|
|
289
|
+
|
|
290
|
+
const walkDir = (dir: string) => {
|
|
291
|
+
try {
|
|
292
|
+
const files = fs.readdirSync(dir);
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
const fullPath = path.join(dir, file);
|
|
295
|
+
const stats = fs.statSync(fullPath);
|
|
296
|
+
|
|
297
|
+
if (stats.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
|
|
298
|
+
walkDir(fullPath);
|
|
299
|
+
} else if (stats.isFile()) {
|
|
300
|
+
if (regex ? regex.test(file) : file.includes(pattern)) {
|
|
301
|
+
results.push(fullPath);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// Ignore permission errors
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
walkDir(searchPath);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
success: true,
|
|
314
|
+
matches: results.slice(0, 100), // Limit results
|
|
315
|
+
total: results.length
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|