@hanzo/dev 1.2.0 → 2.1.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.
@@ -0,0 +1,395 @@
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
+ constructor(workingDir: string = process.cwd()) {
33
+ // No need to instantiate ChunkLocalizer as it has static methods
34
+ }
35
+
36
+ async execute(command: EditCommand): Promise<EditResult> {
37
+ switch (command.command) {
38
+ case 'view':
39
+ return this.viewFile(command.path!, command.startLine, command.endLine);
40
+ case 'create':
41
+ return this.createFile(command.path!, command.content || '');
42
+ case 'str_replace':
43
+ return this.strReplace(command.path!, command.oldStr!, command.newStr!);
44
+ case 'insert':
45
+ return this.insertLine(command.path!, command.lineNumber!, command.content!);
46
+ case 'undo_edit':
47
+ return this.undoEdit(command.path!);
48
+ default:
49
+ return {
50
+ success: false,
51
+ message: `Unknown command: ${command.command}`,
52
+ error: 'Invalid command'
53
+ };
54
+ }
55
+ }
56
+
57
+ private async viewFile(filePath: string, startLine?: number, endLine?: number): Promise<EditResult> {
58
+ try {
59
+ if (!fs.existsSync(filePath)) {
60
+ return {
61
+ success: false,
62
+ message: `File not found: ${filePath}`,
63
+ error: 'FILE_NOT_FOUND'
64
+ };
65
+ }
66
+
67
+ const ext = path.extname(filePath).toLowerCase();
68
+ if (FileEditor.SUPPORTED_BINARY_FORMATS.includes(ext)) {
69
+ const stats = fs.statSync(filePath);
70
+ return {
71
+ success: true,
72
+ message: `Binary file: ${filePath} (${this.formatBytes(stats.size)})`,
73
+ content: `[Binary file of type ${ext}]`
74
+ };
75
+ }
76
+
77
+ const content = fs.readFileSync(filePath, 'utf-8');
78
+ const lines = content.split('\n');
79
+
80
+ if (startLine !== undefined || endLine !== undefined) {
81
+ const start = (startLine || 1) - 1;
82
+ const end = endLine || lines.length;
83
+ const viewLines = lines.slice(start, end);
84
+
85
+ const result = viewLines.map((line, idx) =>
86
+ `${chalk.gray(`${start + idx + 1}:`)} ${line}`
87
+ ).join('\n');
88
+
89
+ return {
90
+ success: true,
91
+ message: `Viewing ${filePath} lines ${start + 1}-${end}`,
92
+ content: result
93
+ };
94
+ }
95
+
96
+ // Full file view with line numbers
97
+ const result = lines.map((line, idx) =>
98
+ `${chalk.gray(`${idx + 1}:`)} ${line}`
99
+ ).join('\n');
100
+
101
+ this.currentFile = filePath;
102
+
103
+ return {
104
+ success: true,
105
+ message: `Viewing ${filePath} (${lines.length} lines)`,
106
+ content: result
107
+ };
108
+ } catch (error) {
109
+ return {
110
+ success: false,
111
+ message: `Error reading file: ${error}`,
112
+ error: 'READ_ERROR'
113
+ };
114
+ }
115
+ }
116
+
117
+ private async createFile(filePath: string, content: string): Promise<EditResult> {
118
+ try {
119
+ if (fs.existsSync(filePath)) {
120
+ return {
121
+ success: false,
122
+ message: `File already exists: ${filePath}`,
123
+ error: 'FILE_EXISTS'
124
+ };
125
+ }
126
+
127
+ const dir = path.dirname(filePath);
128
+ if (!fs.existsSync(dir)) {
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ }
131
+
132
+ fs.writeFileSync(filePath, content);
133
+ this.addToHistory(filePath, '');
134
+
135
+ return {
136
+ success: true,
137
+ message: `Created file: ${filePath}`,
138
+ content: content
139
+ };
140
+ } catch (error) {
141
+ return {
142
+ success: false,
143
+ message: `Error creating file: ${error}`,
144
+ error: 'CREATE_ERROR'
145
+ };
146
+ }
147
+ }
148
+
149
+ private async strReplace(filePath: string, oldStr: string, newStr: string): Promise<EditResult> {
150
+ try {
151
+ if (!fs.existsSync(filePath)) {
152
+ return {
153
+ success: false,
154
+ message: `File not found: ${filePath}`,
155
+ error: 'FILE_NOT_FOUND'
156
+ };
157
+ }
158
+
159
+ const content = fs.readFileSync(filePath, 'utf-8');
160
+
161
+ // Check if old string exists
162
+ if (!content.includes(oldStr)) {
163
+ return {
164
+ success: false,
165
+ message: `String not found in file: "${oldStr}"`,
166
+ error: 'STRING_NOT_FOUND'
167
+ };
168
+ }
169
+
170
+ // Check if old string is unique
171
+ const occurrences = content.split(oldStr).length - 1;
172
+ if (occurrences > 1) {
173
+ return {
174
+ success: false,
175
+ message: `String "${oldStr}" found ${occurrences} times. Please provide a unique string.`,
176
+ error: 'STRING_NOT_UNIQUE'
177
+ };
178
+ }
179
+
180
+ // Save to history
181
+ this.addToHistory(filePath, content);
182
+
183
+ // Replace
184
+ const newContent = content.replace(oldStr, newStr);
185
+ fs.writeFileSync(filePath, newContent);
186
+
187
+ return {
188
+ success: true,
189
+ message: `Replaced string in ${filePath}`,
190
+ content: this.showDiff(oldStr, newStr)
191
+ };
192
+ } catch (error) {
193
+ return {
194
+ success: false,
195
+ message: `Error replacing string: ${error}`,
196
+ error: 'REPLACE_ERROR'
197
+ };
198
+ }
199
+ }
200
+
201
+ private async insertLine(filePath: string, lineNumber: number, content: string): Promise<EditResult> {
202
+ try {
203
+ if (!fs.existsSync(filePath)) {
204
+ return {
205
+ success: false,
206
+ message: `File not found: ${filePath}`,
207
+ error: 'FILE_NOT_FOUND'
208
+ };
209
+ }
210
+
211
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
212
+ const lines = fileContent.split('\n');
213
+
214
+ if (lineNumber < 1 || lineNumber > lines.length + 1) {
215
+ return {
216
+ success: false,
217
+ message: `Invalid line number: ${lineNumber}. File has ${lines.length} lines.`,
218
+ error: 'INVALID_LINE_NUMBER'
219
+ };
220
+ }
221
+
222
+ // Save to history
223
+ this.addToHistory(filePath, fileContent);
224
+
225
+ // Insert line
226
+ lines.splice(lineNumber - 1, 0, content);
227
+ const newContent = lines.join('\n');
228
+ fs.writeFileSync(filePath, newContent);
229
+
230
+ return {
231
+ success: true,
232
+ message: `Inserted line at ${lineNumber} in ${filePath}`,
233
+ content: content
234
+ };
235
+ } catch (error) {
236
+ return {
237
+ success: false,
238
+ message: `Error inserting line: ${error}`,
239
+ error: 'INSERT_ERROR'
240
+ };
241
+ }
242
+ }
243
+
244
+ private async undoEdit(filePath: string): Promise<EditResult> {
245
+ const history = this.editHistory.get(filePath);
246
+ if (!history || history.length === 0) {
247
+ return {
248
+ success: false,
249
+ message: `No edit history for ${filePath}`,
250
+ error: 'NO_HISTORY'
251
+ };
252
+ }
253
+
254
+ const previousContent = history.pop()!;
255
+ fs.writeFileSync(filePath, previousContent);
256
+
257
+ return {
258
+ success: true,
259
+ message: `Undid last edit to ${filePath}`,
260
+ content: 'Edit undone'
261
+ };
262
+ }
263
+
264
+ private addToHistory(filePath: string, content: string): void {
265
+ if (!this.editHistory.has(filePath)) {
266
+ this.editHistory.set(filePath, []);
267
+ }
268
+ const history = this.editHistory.get(filePath)!;
269
+ history.push(content);
270
+
271
+ // Keep only last 10 edits
272
+ if (history.length > 10) {
273
+ history.shift();
274
+ }
275
+ }
276
+
277
+ private showDiff(oldStr: string, newStr: string): string {
278
+ const oldLines = oldStr.split('\n');
279
+ const newLines = newStr.split('\n');
280
+
281
+ let diff = '';
282
+ oldLines.forEach(line => {
283
+ diff += chalk.red(`- ${line}\n`);
284
+ });
285
+ newLines.forEach(line => {
286
+ diff += chalk.green(`+ ${line}\n`);
287
+ });
288
+
289
+ return diff.trim();
290
+ }
291
+
292
+ private formatBytes(bytes: number): string {
293
+ if (bytes < 1024) return `${bytes} bytes`;
294
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
295
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
296
+ }
297
+
298
+ async getRelevantChunks(filePath: string, query: string): Promise<any[]> {
299
+ try {
300
+ if (!fs.existsSync(filePath)) {
301
+ return [];
302
+ }
303
+
304
+ const content = fs.readFileSync(filePath, 'utf-8');
305
+ const chunk = ChunkLocalizer.findRelevantChunk(content, query);
306
+
307
+ if (!chunk) {
308
+ return [];
309
+ }
310
+
311
+ return [{
312
+ startLine: chunk.startLine,
313
+ endLine: chunk.endLine,
314
+ content: chunk.content
315
+ }];
316
+ } catch (error) {
317
+ return [];
318
+ }
319
+ }
320
+ }
321
+
322
+ // Chunk localizer for finding relevant code sections
323
+ export class ChunkLocalizer {
324
+ static findRelevantChunk(content: string, searchPattern: string, maxLines: number = 50): {
325
+ startLine: number;
326
+ endLine: number;
327
+ content: string;
328
+ } | null {
329
+ const lines = content.split('\n');
330
+ const searchLower = searchPattern.toLowerCase();
331
+
332
+ let bestMatch = { score: 0, index: -1 };
333
+
334
+ // Find best matching line
335
+ lines.forEach((line, index) => {
336
+ const score = this.calculateSimilarity(line.toLowerCase(), searchLower);
337
+ if (score > bestMatch.score) {
338
+ bestMatch = { score, index };
339
+ }
340
+ });
341
+
342
+ if (bestMatch.index === -1 || bestMatch.score < 0.3) {
343
+ return null;
344
+ }
345
+
346
+ // Extract chunk around best match
347
+ const halfLines = Math.floor(maxLines / 2);
348
+ const startLine = Math.max(0, bestMatch.index - halfLines);
349
+ const endLine = Math.min(lines.length, bestMatch.index + halfLines);
350
+
351
+ return {
352
+ startLine: startLine + 1,
353
+ endLine: endLine,
354
+ content: lines.slice(startLine, endLine).join('\n')
355
+ };
356
+ }
357
+
358
+ private static calculateSimilarity(str1: string, str2: string): number {
359
+ const longer = str1.length > str2.length ? str1 : str2;
360
+ const shorter = str1.length > str2.length ? str2 : str1;
361
+
362
+ if (longer.length === 0) return 1.0;
363
+
364
+ const distance = this.levenshteinDistance(longer, shorter);
365
+ return (longer.length - distance) / longer.length;
366
+ }
367
+
368
+ private static levenshteinDistance(str1: string, str2: string): number {
369
+ const matrix: number[][] = [];
370
+
371
+ for (let i = 0; i <= str2.length; i++) {
372
+ matrix[i] = [i];
373
+ }
374
+
375
+ for (let j = 0; j <= str1.length; j++) {
376
+ matrix[0][j] = j;
377
+ }
378
+
379
+ for (let i = 1; i <= str2.length; i++) {
380
+ for (let j = 1; j <= str1.length; j++) {
381
+ if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
382
+ matrix[i][j] = matrix[i - 1][j - 1];
383
+ } else {
384
+ matrix[i][j] = Math.min(
385
+ matrix[i - 1][j - 1] + 1,
386
+ matrix[i][j - 1] + 1,
387
+ matrix[i - 1][j] + 1
388
+ );
389
+ }
390
+ }
391
+ }
392
+
393
+ return matrix[str2.length][str1.length];
394
+ }
395
+ }
@@ -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
+ }