feat: letta code
This commit is contained in:
145
src/tools/impl/Bash.ts
Normal file
145
src/tools/impl/Bash.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { ExecOptions } from "node:child_process";
|
||||
import { exec, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { backgroundProcesses, getNextBashId } from "./process_manager.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface BashArgs {
|
||||
command: string;
|
||||
timeout?: number;
|
||||
description?: string;
|
||||
run_in_background?: boolean;
|
||||
}
|
||||
|
||||
interface BashResult {
|
||||
content: Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
}>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export async function bash(args: BashArgs): Promise<BashResult> {
|
||||
const {
|
||||
command,
|
||||
timeout = 120000,
|
||||
description: _description,
|
||||
run_in_background = false,
|
||||
} = args;
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
|
||||
if (command === "/bashes") {
|
||||
const processes = Array.from(backgroundProcesses.entries());
|
||||
if (processes.length === 0) {
|
||||
return { content: [{ type: "text", text: "(no content)" }] };
|
||||
}
|
||||
let output = "";
|
||||
for (const [id, proc] of processes) {
|
||||
const runtime = proc.startTime
|
||||
? `${Math.floor((Date.now() - proc.startTime.getTime()) / 1000)}s`
|
||||
: "unknown";
|
||||
output += `${id}: ${proc.command} (${proc.status}, runtime: ${runtime})\n`;
|
||||
}
|
||||
return { content: [{ type: "text", text: output.trim() }] };
|
||||
}
|
||||
|
||||
if (run_in_background) {
|
||||
const bashId = getNextBashId();
|
||||
const childProcess = spawn(command, [], {
|
||||
shell: true,
|
||||
cwd: userCwd,
|
||||
env: { ...process.env },
|
||||
});
|
||||
backgroundProcesses.set(bashId, {
|
||||
process: childProcess,
|
||||
command,
|
||||
stdout: [],
|
||||
stderr: [],
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
lastReadIndex: { stdout: 0, stderr: 0 },
|
||||
startTime: new Date(),
|
||||
});
|
||||
const bgProcess = backgroundProcesses.get(bashId);
|
||||
if (!bgProcess) {
|
||||
throw new Error("Failed to track background process state");
|
||||
}
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
bgProcess.stdout.push(...lines);
|
||||
});
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
bgProcess.stderr.push(...lines);
|
||||
});
|
||||
childProcess.on("exit", (code: number | null) => {
|
||||
bgProcess.status = code === 0 ? "completed" : "failed";
|
||||
bgProcess.exitCode = code;
|
||||
});
|
||||
childProcess.on("error", (err: Error) => {
|
||||
bgProcess.status = "failed";
|
||||
bgProcess.stderr.push(err.message);
|
||||
});
|
||||
if (timeout && timeout > 0) {
|
||||
setTimeout(() => {
|
||||
if (bgProcess.status === "running") {
|
||||
childProcess.kill("SIGTERM");
|
||||
bgProcess.status = "failed";
|
||||
bgProcess.stderr.push(`Command timed out after ${timeout}ms`);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Command running in background with ID: ${bashId}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveTimeout = Math.min(Math.max(timeout, 1), 600000);
|
||||
try {
|
||||
const options: ExecOptions = {
|
||||
timeout: effectiveTimeout,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
cwd: userCwd,
|
||||
env: { ...process.env },
|
||||
};
|
||||
const { stdout, stderr } = await execAsync(command, options);
|
||||
const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString();
|
||||
const stderrStr = typeof stderr === "string" ? stderr : stderr.toString();
|
||||
let output = stdoutStr;
|
||||
if (stderrStr) output = output ? `${output}\n${stderrStr}` : stderrStr;
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: output || "(Command completed with no output)" },
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
let errorMessage = "";
|
||||
if (err.killed && err.signal === "SIGTERM")
|
||||
errorMessage = `Command timed out after ${effectiveTimeout}ms\n`;
|
||||
if (err.code) errorMessage += `Exit code: ${err.code}\n`;
|
||||
if (err.stderr) errorMessage += err.stderr;
|
||||
else if (err.message) errorMessage += err.message;
|
||||
if (err.stdout) errorMessage = `${err.stdout}\n${errorMessage}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage.trim() || "Command failed with unknown error",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/tools/impl/BashOutput.ts
Normal file
29
src/tools/impl/BashOutput.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { backgroundProcesses } from "./process_manager.js";
|
||||
|
||||
interface BashOutputArgs {
|
||||
bash_id: string;
|
||||
filter?: string;
|
||||
}
|
||||
interface BashOutputResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function bash_output(
|
||||
args: BashOutputArgs,
|
||||
): Promise<BashOutputResult> {
|
||||
const { bash_id, filter } = args;
|
||||
const proc = backgroundProcesses.get(bash_id);
|
||||
if (!proc)
|
||||
return { message: `No background process found with ID: ${bash_id}` };
|
||||
const stdout = proc.stdout.join("\n");
|
||||
const stderr = proc.stderr.join("\n");
|
||||
let text = stdout;
|
||||
if (stderr) text = text ? `${text}\n${stderr}` : stderr;
|
||||
if (filter) {
|
||||
text = text
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(filter))
|
||||
.join("\n");
|
||||
}
|
||||
return { message: text || "(no output yet)" };
|
||||
}
|
||||
64
src/tools/impl/Edit.ts
Normal file
64
src/tools/impl/Edit.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface EditArgs {
|
||||
file_path: string;
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}
|
||||
interface EditResult {
|
||||
message: string;
|
||||
replacements: number;
|
||||
}
|
||||
|
||||
export async function edit(args: EditArgs): Promise<EditResult> {
|
||||
const { file_path, old_string, new_string, replace_all = false } = args;
|
||||
if (!path.isAbsolute(file_path))
|
||||
throw new Error(`File path must be absolute, got: ${file_path}`);
|
||||
if (old_string === new_string)
|
||||
throw new Error(
|
||||
"No changes to make: old_string and new_string are exactly the same.",
|
||||
);
|
||||
try {
|
||||
const content = await fs.readFile(file_path, "utf-8");
|
||||
const occurrences = content.split(old_string).length - 1;
|
||||
if (occurrences === 0)
|
||||
throw new Error(
|
||||
`String to replace not found in file.\nString: ${old_string}`,
|
||||
);
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
if (replace_all) {
|
||||
newContent = content.split(old_string).join(new_string);
|
||||
replacements = occurrences;
|
||||
} else {
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1)
|
||||
throw new Error(`String not found in file: ${old_string}`);
|
||||
newContent =
|
||||
content.substring(0, index) +
|
||||
new_string +
|
||||
content.substring(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
await fs.writeFile(file_path, newContent, "utf-8");
|
||||
return {
|
||||
message: `Successfully replaced ${replacements} occurrence${replacements !== 1 ? "s" : ""} in ${file_path}`,
|
||||
replacements,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
throw new Error(
|
||||
`File does not exist. Current working directory: ${userCwd}`,
|
||||
);
|
||||
} else if (err.code === "EACCES")
|
||||
throw new Error(`Permission denied: ${file_path}`);
|
||||
else if (err.code === "EISDIR")
|
||||
throw new Error(`Path is a directory: ${file_path}`);
|
||||
else if (err.message) throw err;
|
||||
else throw new Error(`Failed to edit file: ${err}`);
|
||||
}
|
||||
}
|
||||
21
src/tools/impl/ExitPlanMode.ts
Normal file
21
src/tools/impl/ExitPlanMode.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ExitPlanMode tool implementation
|
||||
* Exits plan mode by presenting the plan to the user for approval
|
||||
*/
|
||||
|
||||
interface ExitPlanModeArgs {
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export async function exit_plan_mode(
|
||||
args: ExitPlanModeArgs,
|
||||
): Promise<{ message: string }> {
|
||||
const { plan: _plan } = args;
|
||||
|
||||
// Return confirmation message that plan was approved
|
||||
// Note: The plan itself should be displayed by the UI/system before this return is shown
|
||||
return {
|
||||
message:
|
||||
"User has approved your plan. You can now start coding.\nStart with updating your todo list if applicable",
|
||||
};
|
||||
}
|
||||
74
src/tools/impl/Glob.ts
Normal file
74
src/tools/impl/Glob.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
interface GlobArgs {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
}
|
||||
interface GlobResult {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
const subFiles = await walkDirectory(fullPath);
|
||||
files.push(...subFiles);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== "EACCES" && err.code !== "EPERM") throw err;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function glob(args: GlobArgs): Promise<GlobResult> {
|
||||
const { pattern, path: searchPath } = args;
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
let baseDir: string;
|
||||
if (searchPath)
|
||||
baseDir = path.isAbsolute(searchPath)
|
||||
? searchPath
|
||||
: path.resolve(userCwd, searchPath);
|
||||
else baseDir = userCwd;
|
||||
try {
|
||||
const stats = await fs.stat(baseDir);
|
||||
if (!stats.isDirectory())
|
||||
throw new Error(`Path is not a directory: ${baseDir}`);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT")
|
||||
throw new Error(`Directory does not exist: ${baseDir}`);
|
||||
throw err;
|
||||
}
|
||||
const allFiles = await walkDirectory(baseDir);
|
||||
let matcher: (input: string) => boolean;
|
||||
if (pattern.startsWith("**/")) {
|
||||
const subPattern = pattern.slice(3);
|
||||
matcher = picomatch(subPattern);
|
||||
const matchedFiles = allFiles.filter((file) =>
|
||||
matcher(path.basename(file)),
|
||||
);
|
||||
return { files: matchedFiles.sort() };
|
||||
} else if (pattern.includes("**")) {
|
||||
const fullPattern = path.join(baseDir, pattern);
|
||||
matcher = picomatch(fullPattern, { dot: true });
|
||||
const matchedFiles = allFiles.filter((file) => matcher(file));
|
||||
return { files: matchedFiles.sort() };
|
||||
} else {
|
||||
matcher = picomatch(pattern, { dot: true });
|
||||
const matchedFiles = allFiles.filter((file) =>
|
||||
matcher(path.relative(baseDir, file)),
|
||||
);
|
||||
return { files: matchedFiles.sort() };
|
||||
}
|
||||
}
|
||||
150
src/tools/impl/Grep.ts
Normal file
150
src/tools/impl/Grep.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function getRipgrepPath(): string {
|
||||
try {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const require = createRequire(__filename);
|
||||
const rgPackage = require("@vscode/ripgrep");
|
||||
return rgPackage.rgPath;
|
||||
} catch (_error) {
|
||||
return "rg";
|
||||
}
|
||||
}
|
||||
|
||||
const rgPath = getRipgrepPath();
|
||||
|
||||
export interface GrepArgs {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
output_mode?: "content" | "files_with_matches" | "count";
|
||||
"-B"?: number;
|
||||
"-A"?: number;
|
||||
"-C"?: number;
|
||||
"-n"?: boolean;
|
||||
"-i"?: boolean;
|
||||
type?: string;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
interface GrepResult {
|
||||
output: string;
|
||||
matches?: number;
|
||||
files?: number;
|
||||
}
|
||||
|
||||
export async function grep(args: GrepArgs): Promise<GrepResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath,
|
||||
glob,
|
||||
output_mode = "files_with_matches",
|
||||
"-B": before,
|
||||
"-A": after,
|
||||
"-C": context,
|
||||
"-n": lineNumbers,
|
||||
"-i": ignoreCase,
|
||||
type: fileType,
|
||||
multiline,
|
||||
} = args;
|
||||
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
const rgArgs: string[] = [];
|
||||
if (output_mode === "files_with_matches") rgArgs.push("-l");
|
||||
else if (output_mode === "count") rgArgs.push("-c");
|
||||
if (output_mode === "content") {
|
||||
if (context !== undefined) rgArgs.push("-C", context.toString());
|
||||
else {
|
||||
if (before !== undefined) rgArgs.push("-B", before.toString());
|
||||
if (after !== undefined) rgArgs.push("-A", after.toString());
|
||||
}
|
||||
if (lineNumbers) rgArgs.push("-n");
|
||||
}
|
||||
if (ignoreCase) rgArgs.push("-i");
|
||||
if (fileType) rgArgs.push("--type", fileType);
|
||||
if (glob) rgArgs.push("--glob", glob);
|
||||
if (multiline) rgArgs.push("-U", "--multiline-dotall");
|
||||
rgArgs.push(pattern);
|
||||
if (searchPath)
|
||||
rgArgs.push(
|
||||
path.isAbsolute(searchPath)
|
||||
? searchPath
|
||||
: path.resolve(userCwd, searchPath),
|
||||
);
|
||||
else rgArgs.push(userCwd);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(rgPath, rgArgs, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
cwd: userCwd,
|
||||
});
|
||||
if (output_mode === "files_with_matches") {
|
||||
const files = stdout.trim().split("\n").filter(Boolean);
|
||||
const fileCount = files.length;
|
||||
if (fileCount === 0) return { output: "No files found", files: 0 };
|
||||
return {
|
||||
output: `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${files.join("\n")}`,
|
||||
files: fileCount,
|
||||
};
|
||||
} else if (output_mode === "count") {
|
||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||
let totalMatches = 0;
|
||||
let filesWithMatches = 0;
|
||||
for (const line of lines) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length >= 2) {
|
||||
const count = parseInt(parts[parts.length - 1], 10);
|
||||
if (!Number.isNaN(count) && count > 0) {
|
||||
totalMatches += count;
|
||||
filesWithMatches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalMatches === 0)
|
||||
return {
|
||||
output: "0\n\nFound 0 total occurrences across 0 files.",
|
||||
matches: 0,
|
||||
files: 0,
|
||||
};
|
||||
const countOutput = lines.join("\n");
|
||||
return {
|
||||
output: `${countOutput}\n\nFound ${totalMatches} total occurrence${totalMatches !== 1 ? "s" : ""} across ${filesWithMatches} file${filesWithMatches !== 1 ? "s" : ""}.`,
|
||||
matches: totalMatches,
|
||||
files: filesWithMatches,
|
||||
};
|
||||
} else {
|
||||
if (!stdout || stdout.trim() === "")
|
||||
return { output: "No matches found", matches: 0 };
|
||||
return {
|
||||
output: stdout,
|
||||
matches: stdout.split("\n").filter(Boolean).length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
};
|
||||
const code = typeof err.code === "number" ? err.code : undefined;
|
||||
const _stdout = typeof err.stdout === "string" ? err.stdout : "";
|
||||
const message =
|
||||
typeof err.message === "string" ? err.message : "Unknown error";
|
||||
if (code === 1) {
|
||||
if (output_mode === "files_with_matches")
|
||||
return { output: "No files found", files: 0 };
|
||||
if (output_mode === "count")
|
||||
return {
|
||||
output: "0\n\nFound 0 total occurrences across 0 files.",
|
||||
matches: 0,
|
||||
files: 0,
|
||||
};
|
||||
return { output: "No matches found", matches: 0 };
|
||||
}
|
||||
throw new Error(`Grep failed: ${message}`);
|
||||
}
|
||||
}
|
||||
21
src/tools/impl/KillBash.ts
Normal file
21
src/tools/impl/KillBash.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { backgroundProcesses } from "./process_manager.js";
|
||||
|
||||
interface KillBashArgs {
|
||||
shell_id: string;
|
||||
}
|
||||
interface KillBashResult {
|
||||
killed: boolean;
|
||||
}
|
||||
|
||||
export async function kill_bash(args: KillBashArgs): Promise<KillBashResult> {
|
||||
const { shell_id } = args;
|
||||
const proc = backgroundProcesses.get(shell_id);
|
||||
if (!proc) return { killed: false };
|
||||
try {
|
||||
proc.process.kill("SIGTERM");
|
||||
backgroundProcesses.delete(shell_id);
|
||||
return { killed: true };
|
||||
} catch {
|
||||
return { killed: false };
|
||||
}
|
||||
}
|
||||
81
src/tools/impl/LS.ts
Normal file
81
src/tools/impl/LS.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
interface LSArgs {
|
||||
path: string;
|
||||
ignore?: string[];
|
||||
}
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function ls(
|
||||
args: LSArgs,
|
||||
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||||
const { path: inputPath, ignore = [] } = args;
|
||||
const dirPath = resolve(inputPath);
|
||||
try {
|
||||
const items = await readdir(dirPath);
|
||||
const filteredItems = items.filter(
|
||||
(item) => !ignore.some((pattern) => picomatch.isMatch(item, pattern)),
|
||||
);
|
||||
const fileInfos: FileInfo[] = await Promise.all(
|
||||
filteredItems.map(async (item) => {
|
||||
const fullPath = join(dirPath, item);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
return {
|
||||
name: item,
|
||||
type: stats.isDirectory() ? "directory" : "file",
|
||||
size: stats.isFile() ? stats.size : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { name: item, type: "file" } as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
fileInfos.sort((a, b) =>
|
||||
a.type !== b.type
|
||||
? a.type === "directory"
|
||||
? -1
|
||||
: 1
|
||||
: a.name.localeCompare(b.name),
|
||||
);
|
||||
const tree = formatTree(dirPath, fileInfos);
|
||||
return { content: [{ type: "text", text: tree }] };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
const code = String(err?.code ?? "");
|
||||
if (code === "ENOENT") throw new Error(`Directory not found: ${dirPath}`);
|
||||
if (code === "ENOTDIR") throw new Error(`Not a directory: ${dirPath}`);
|
||||
if (code === "EACCES") throw new Error(`Permission denied: ${dirPath}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTree(basePath: string, items: FileInfo[]): string {
|
||||
if (items.length === 0) return `${basePath}/ (empty directory)`;
|
||||
const lines: string[] = [];
|
||||
const pathParts = basePath.split("/");
|
||||
const lastPart = pathParts[pathParts.length - 1] || "/";
|
||||
const parentPath = pathParts.slice(0, -1).join("/") || "/";
|
||||
lines.push(`- ${parentPath}/`);
|
||||
lines.push(` - ${lastPart}/`);
|
||||
items.forEach((item) => {
|
||||
const prefix = " ";
|
||||
lines.push(
|
||||
`${prefix}- ${item.name}${item.type === "directory" ? "/" : ""}`,
|
||||
);
|
||||
});
|
||||
const hasHiddenFiles = items.some((item) => item.name.startsWith("."));
|
||||
if (hasHiddenFiles) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"NOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work.",
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
84
src/tools/impl/MultiEdit.ts
Normal file
84
src/tools/impl/MultiEdit.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface Edit {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}
|
||||
export interface MultiEditArgs {
|
||||
file_path: string;
|
||||
edits: Edit[];
|
||||
}
|
||||
interface MultiEditResult {
|
||||
message: string;
|
||||
edits_applied: number;
|
||||
}
|
||||
|
||||
export async function multi_edit(
|
||||
args: MultiEditArgs,
|
||||
): Promise<MultiEditResult> {
|
||||
const { file_path, edits } = args;
|
||||
if (!path.isAbsolute(file_path))
|
||||
throw new Error(`File path must be absolute, got: ${file_path}`);
|
||||
if (!edits || edits.length === 0) throw new Error("No edits provided");
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
if (edits[i].old_string === edits[i].new_string)
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: No changes to make: old_string and new_string are exactly the same.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
let content = await fs.readFile(file_path, "utf-8");
|
||||
const appliedEdits: string[] = [];
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const { old_string, new_string, replace_all = false } = edits[i];
|
||||
const occurrences = content.split(old_string).length - 1;
|
||||
if (occurrences === 0) {
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: String to replace not found in file.\nString: ${old_string}`,
|
||||
);
|
||||
}
|
||||
if (occurrences > 1 && !replace_all) {
|
||||
throw new Error(
|
||||
`Found ${occurrences} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
|
||||
);
|
||||
}
|
||||
if (replace_all) {
|
||||
content = content.split(old_string).join(new_string);
|
||||
} else {
|
||||
const index = content.indexOf(old_string);
|
||||
content =
|
||||
content.substring(0, index) +
|
||||
new_string +
|
||||
content.substring(index + old_string.length);
|
||||
}
|
||||
appliedEdits.push(
|
||||
`Replaced "${old_string.substring(0, 50)}${old_string.length > 50 ? "..." : ""}" with "${new_string.substring(0, 50)}${new_string.length > 50 ? "..." : ""}"`,
|
||||
);
|
||||
}
|
||||
await fs.writeFile(file_path, content, "utf-8");
|
||||
const editList = appliedEdits
|
||||
.map((edit, i) => `${i + 1}. ${edit}`)
|
||||
.join("\n");
|
||||
return {
|
||||
message: `Applied ${edits.length} edit${edits.length !== 1 ? "s" : ""} to ${file_path}:\n${editList}`,
|
||||
edits_applied: edits.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
const code = String(err?.code ?? "");
|
||||
const message = String(err?.message ?? "");
|
||||
if (code === "ENOENT") {
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
throw new Error(
|
||||
`File does not exist. Current working directory: ${userCwd}`,
|
||||
);
|
||||
} else if (code === "EACCES")
|
||||
throw new Error(`Permission denied: ${file_path}`);
|
||||
else if (code === "EISDIR")
|
||||
throw new Error(`Path is a directory: ${file_path}`);
|
||||
else if (message) throw new Error(message);
|
||||
else throw new Error(`Failed to edit file: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
95
src/tools/impl/Read.ts
Normal file
95
src/tools/impl/Read.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface ReadArgs {
|
||||
file_path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
interface ReadResult {
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fd = await fs.open(filePath, "r");
|
||||
try {
|
||||
const stats = await fd.stat();
|
||||
const bufferSize = Math.min(4096, stats.size);
|
||||
if (bufferSize === 0) return false;
|
||||
const buffer = Buffer.alloc(bufferSize);
|
||||
const { bytesRead } = await fd.read(buffer, 0, bufferSize, 0);
|
||||
if (bytesRead === 0) return false;
|
||||
for (let i = 0; i < bytesRead; i++) if (buffer[i] === 0) return true;
|
||||
let nonPrintableCount = 0;
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
const byte = buffer[i];
|
||||
if (byte < 9 || (byte > 13 && byte < 32) || byte > 126)
|
||||
nonPrintableCount++;
|
||||
}
|
||||
return nonPrintableCount / bytesRead > 0.3;
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatWithLineNumbers(
|
||||
content: string,
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
): string {
|
||||
const lines = content.split("\n");
|
||||
const startLine = offset || 0;
|
||||
const endLine = limit
|
||||
? Math.min(startLine + limit, lines.length)
|
||||
: lines.length;
|
||||
const actualStartLine = Math.min(startLine, lines.length);
|
||||
const actualEndLine = Math.min(endLine, lines.length);
|
||||
const selectedLines = lines.slice(actualStartLine, actualEndLine);
|
||||
const maxLineNumber = actualStartLine + selectedLines.length;
|
||||
const padding = Math.max(1, maxLineNumber.toString().length);
|
||||
return selectedLines
|
||||
.map((line, index) => {
|
||||
const lineNumber = actualStartLine + index + 1;
|
||||
const paddedNumber = lineNumber.toString().padStart(padding);
|
||||
return `${paddedNumber}→${line}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function read(args: ReadArgs): Promise<ReadResult> {
|
||||
const { file_path, offset, limit } = args;
|
||||
if (!path.isAbsolute(file_path))
|
||||
throw new Error(`File path must be absolute, got: ${file_path}`);
|
||||
try {
|
||||
const stats = await fs.stat(file_path);
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${file_path}`);
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (stats.size > maxSize)
|
||||
throw new Error(
|
||||
`File too large: ${stats.size} bytes (max ${maxSize} bytes)`,
|
||||
);
|
||||
if (await isBinaryFile(file_path))
|
||||
throw new Error(`Cannot read binary file: ${file_path}`);
|
||||
const content = await fs.readFile(file_path, "utf-8");
|
||||
const formattedContent = formatWithLineNumbers(content, offset, limit);
|
||||
return { content: formattedContent };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
throw new Error(
|
||||
`File does not exist. Current working directory: ${userCwd}`,
|
||||
);
|
||||
} else if (err.code === "EACCES")
|
||||
throw new Error(`Permission denied: ${file_path}`);
|
||||
else if (err.code === "EISDIR")
|
||||
throw new Error(`Path is a directory: ${file_path}`);
|
||||
else if (err.message) throw err;
|
||||
else throw new Error(`Failed to read file: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
38
src/tools/impl/TodoWrite.ts
Normal file
38
src/tools/impl/TodoWrite.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
id: string;
|
||||
priority?: "high" | "medium" | "low";
|
||||
}
|
||||
interface TodoWriteArgs {
|
||||
todos: TodoItem[];
|
||||
}
|
||||
interface TodoWriteResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function todo_write(
|
||||
args: TodoWriteArgs,
|
||||
): Promise<TodoWriteResult> {
|
||||
if (!args.todos || !Array.isArray(args.todos))
|
||||
throw new Error("todos must be an array");
|
||||
for (const todo of args.todos) {
|
||||
if (!todo.content || typeof todo.content !== "string")
|
||||
throw new Error("Each todo must have a content string");
|
||||
if (
|
||||
!todo.status ||
|
||||
!["pending", "in_progress", "completed"].includes(todo.status)
|
||||
)
|
||||
throw new Error(
|
||||
"Each todo must have a valid status (pending, in_progress, or completed)",
|
||||
);
|
||||
if (!todo.id || typeof todo.id !== "string")
|
||||
throw new Error("Each todo must have an id string");
|
||||
if (todo.priority && !["high", "medium", "low"].includes(todo.priority))
|
||||
throw new Error("If provided, priority must be high, medium, or low");
|
||||
}
|
||||
return {
|
||||
message:
|
||||
"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable",
|
||||
};
|
||||
}
|
||||
42
src/tools/impl/Write.ts
Normal file
42
src/tools/impl/Write.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface WriteArgs {
|
||||
file_path: string;
|
||||
content: string;
|
||||
}
|
||||
interface WriteResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function write(args: WriteArgs): Promise<WriteResult> {
|
||||
const { file_path, content } = args;
|
||||
if (!path.isAbsolute(file_path))
|
||||
throw new Error(`File path must be absolute, got: ${file_path}`);
|
||||
try {
|
||||
const dir = path.dirname(file_path);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
try {
|
||||
const stats = await fs.stat(file_path);
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${file_path}`);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
}
|
||||
await fs.writeFile(file_path, content, "utf-8");
|
||||
return {
|
||||
message: `Successfully wrote ${content.length} characters to ${file_path}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "EACCES")
|
||||
throw new Error(`Permission denied: ${file_path}`);
|
||||
else if (err.code === "ENOSPC")
|
||||
throw new Error(`No space left on device: ${file_path}`);
|
||||
else if (err.code === "EISDIR")
|
||||
throw new Error(`Path is a directory: ${file_path}`);
|
||||
else if (err.message) throw err;
|
||||
else throw new Error(`Failed to write file: ${err}`);
|
||||
}
|
||||
}
|
||||
14
src/tools/impl/process_manager.ts
Normal file
14
src/tools/impl/process_manager.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface BackgroundProcess {
|
||||
process: import("child_process").ChildProcess;
|
||||
command: string;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
status: "running" | "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
lastReadIndex: { stdout: number; stderr: number };
|
||||
startTime?: Date;
|
||||
}
|
||||
|
||||
export const backgroundProcesses = new Map<string, BackgroundProcess>();
|
||||
let bashIdCounter = 1;
|
||||
export const getNextBashId = () => `bash_${bashIdCounter++}`;
|
||||
Reference in New Issue
Block a user