@measey/mycoder-agent
Version:
Agent module for mycoder - an AI-powered software development assistant
244 lines • 11.7 kB
JavaScript
import { execSync } from 'child_process';
import * as fsSync from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const OUTPUT_LIMIT = 10 * 1024; // 10KB limit
// Store file states for undo functionality
const fileStateHistory = {};
const parameterSchema = z.object({
command: z
.enum(['view', 'create', 'str_replace', 'insert', 'undo_edit'])
.describe('The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.'),
path: z
.string()
.describe('Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.'),
file_text: z
.string()
.optional()
.describe('Required parameter of `create` command, with the content of the file to be created.'),
insert_line: z
.number()
.optional()
.describe('Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.'),
new_str: z
.string()
.optional()
.describe('Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.'),
old_str: z
.string()
.optional()
.describe('Required parameter of `str_replace` command containing the string in `path` to replace.'),
view_range: z
.array(z.number())
.optional()
.describe('Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.'),
description: z
.string()
.describe('The reason you are using the text editor (max 80 chars)'),
});
const returnSchema = z.object({
success: z.boolean(),
message: z.string(),
content: z.string().optional(),
});
export const textEditorTool = {
name: 'textEditor',
description: 'View, create, and edit files with persistent state across command calls',
logPrefix: '📝',
parameters: parameterSchema,
returns: returnSchema,
parametersJsonSchema: zodToJsonSchema(parameterSchema),
returnsJsonSchema: zodToJsonSchema(returnSchema),
execute: async ({ command, path: filePath, file_text, insert_line, new_str, old_str, view_range, }, context) => {
const normalizedPath = path.normalize(filePath);
const absolutePath = path.isAbsolute(normalizedPath)
? normalizedPath
: context?.workingDirectory
? path.join(context.workingDirectory, normalizedPath)
: path.resolve(normalizedPath);
switch (command) {
case 'view': {
// Check if path is a directory
const stats = await fs.stat(absolutePath).catch(() => null);
if (!stats) {
throw new Error(`File or directory not found: ${filePath}`);
}
if (stats.isDirectory()) {
// List directory contents up to 2 levels deep
try {
const output = execSync(`find "${absolutePath}" -type f -not -path "*/\\.*" -maxdepth 2 | sort`, { encoding: 'utf8' });
return {
success: true,
message: `Directory listing for ${filePath}:`,
content: output,
};
}
catch (error) {
throw new Error(`Error listing directory: ${error}`);
}
}
else {
// Read file content
const content = await fs.readFile(absolutePath, 'utf8');
const lines = content.split('\n');
// Apply view range if specified
let displayContent = content;
if (view_range && view_range.length === 2) {
const [start, end] = view_range;
const startLine = Math.max(1, start || 1) - 1; // Convert to 0-indexed
const endLine = end === -1 ? lines.length : end;
displayContent = lines.slice(startLine, endLine).join('\n');
}
// Add line numbers
const startLineNum = view_range && view_range.length === 2 ? view_range[0] : 1;
const numberedContent = displayContent
.split('\n')
.map((line, i) => `${(startLineNum || 1) + i}: ${line}`)
.join('\n');
// Truncate if too large
if (numberedContent.length > OUTPUT_LIMIT) {
const truncatedContent = numberedContent.substring(0, OUTPUT_LIMIT);
return {
success: true,
message: `File content (truncated):`,
content: `${truncatedContent}\n<response clipped>`,
};
}
return {
success: true,
message: `File content:`,
content: numberedContent,
};
}
}
case 'create': {
if (!file_text) {
throw new Error('file_text parameter is required for create command');
}
// Create parent directories if they don't exist
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
// Check if file already exists
const fileExists = fsSync.existsSync(absolutePath);
if (fileExists) {
// Save current state for undo if file exists
const currentContent = await fs.readFile(absolutePath, 'utf8');
if (!fileStateHistory[absolutePath]) {
fileStateHistory[absolutePath] = [];
}
fileStateHistory[absolutePath].push(currentContent);
}
else {
// Initialize history for new files
fileStateHistory[absolutePath] = [];
}
// Create or overwrite the file
await fs.writeFile(absolutePath, file_text, 'utf8');
return {
success: true,
message: fileExists
? `File overwritten: ${filePath}`
: `File created: ${filePath}`,
};
}
case 'str_replace': {
if (!old_str) {
throw new Error('old_str parameter is required for str_replace command');
}
// Ensure the file exists
if (!fsSync.existsSync(absolutePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Read the current content
const content = await fs.readFile(absolutePath, 'utf8');
// Check if old_str exists uniquely in the file
const occurrences = content.split(old_str).length - 1;
if (occurrences === 0) {
throw new Error(`The specified old_str was not found in the file`);
}
if (occurrences > 1) {
throw new Error(`Found ${occurrences} occurrences of old_str, expected exactly 1`);
}
// Save current state for undo
if (!fileStateHistory[absolutePath]) {
fileStateHistory[absolutePath] = [];
}
fileStateHistory[absolutePath].push(content);
// Replace the content
const updatedContent = content.replace(old_str, new_str || '');
await fs.writeFile(absolutePath, updatedContent, 'utf8');
return {
success: true,
message: `Successfully replaced text in ${filePath}`,
};
}
case 'insert': {
if (insert_line === undefined) {
throw new Error('insert_line parameter is required for insert command');
}
if (!new_str) {
throw new Error('new_str parameter is required for insert command');
}
// Ensure the file exists
if (!fsSync.existsSync(absolutePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Read the current content
const content = await fs.readFile(absolutePath, 'utf8');
const lines = content.split('\n');
// Validate line number
if (insert_line < 0 || insert_line > lines.length) {
throw new Error(`Invalid line number: ${insert_line}. File has ${lines.length} lines.`);
}
// Save current state for undo
if (!fileStateHistory[absolutePath]) {
fileStateHistory[absolutePath] = [];
}
fileStateHistory[absolutePath].push(content);
// Insert the new content after the specified line
lines.splice(insert_line, 0, new_str);
const updatedContent = lines.join('\n');
await fs.writeFile(absolutePath, updatedContent, 'utf8');
return {
success: true,
message: `Successfully inserted text after line ${insert_line} in ${filePath}`,
};
}
case 'undo_edit': {
// Check if we have history for this file
if (!fileStateHistory[absolutePath] ||
fileStateHistory[absolutePath].length === 0) {
throw new Error(`No edit history found for ${filePath}`);
}
// Get the previous state
const previousState = fileStateHistory[absolutePath].pop();
await fs.writeFile(absolutePath, previousState, 'utf8');
return {
success: true,
message: `Successfully reverted last edit to ${filePath}`,
};
}
default:
throw new Error(`Unknown command: ${command}`);
}
},
logParameters: (input, { logger, workingDirectory }) => {
// Convert absolute path to relative path if possible
let displayPath = input.path;
if (workingDirectory && path.isAbsolute(input.path)) {
// Check if the path is within the working directory
if (input.path.startsWith(workingDirectory)) {
// Convert to relative path with ./ prefix
displayPath = './' + path.relative(workingDirectory, input.path);
}
}
logger.log(`${input.command} operation on "${displayPath}", ${input.description}`);
},
logReturns: (result, { logger }) => {
if (!result.success) {
logger.error(`Text editor operation failed: ${result.message}`);
}
},
};
//# sourceMappingURL=textEditor.js.map