pyb-ts
Version:
PYB-CLI - Minimal AI Agent with multi-model support and CLI interface
227 lines (226 loc) • 8.63 kB
JavaScript
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
import { Box, Text } from "ink";
import { dirname, isAbsolute, relative, resolve, sep } from "path";
import * as React from "react";
import { z } from "zod";
import { FileEditToolUpdatedMessage } from "@components/FileEditToolUpdatedMessage";
import { StructuredDiff } from "@components/StructuredDiff";
import { FallbackToolUseRejectedMessage } from "@components/FallbackToolUseRejectedMessage";
import { intersperse } from "@utils/array";
import {
addLineNumbers,
detectFileEncoding,
detectLineEndings,
findSimilarFile,
writeTextContent
} from "@utils/file";
import { logError } from "@utils/log";
import { getCwd } from "@utils/state";
import { getTheme } from "@utils/theme";
import { emitReminderEvent } from "@services/systemReminder";
import { recordFileEdit } from "@services/fileFreshness";
import { NotebookEditTool } from "@tools/NotebookEditTool/NotebookEditTool";
import { DESCRIPTION } from "./prompt.js";
import { applyEdit } from "./utils.js";
import { hasWritePermission } from "@utils/permissions/filesystem";
import { PROJECT_FILE } from "@constants/product";
const inputSchema = z.strictObject({
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with")
});
const N_LINES_SNIPPET = 4;
const FileEditTool = {
name: "Edit",
async description() {
return "A tool for editing files";
},
async prompt() {
return DESCRIPTION;
},
inputSchema,
userFacingName() {
return "Edit";
},
async isEnabled() {
return true;
},
isReadOnly() {
return false;
},
isConcurrencySafe() {
return false;
},
needsPermissions({ file_path }) {
return !hasWritePermission(file_path);
},
renderToolUseMessage(input, { verbose }) {
return `file_path: ${verbose ? input.file_path : relative(getCwd(), input.file_path)}`;
},
renderToolResultMessage({ filePath, structuredPatch }) {
const verbose = false;
return /* @__PURE__ */ React.createElement(
FileEditToolUpdatedMessage,
{
filePath,
structuredPatch,
verbose
}
);
},
renderToolUseRejectedMessage({ file_path, old_string, new_string } = {}, { columns, verbose } = {}) {
try {
if (!file_path) {
return /* @__PURE__ */ React.createElement(FallbackToolUseRejectedMessage, null);
}
const { patch } = applyEdit(file_path, old_string, new_string);
return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, null, " ", "\u23BF", " ", /* @__PURE__ */ React.createElement(Text, { color: getTheme().error }, "User rejected ", old_string === "" ? "write" : "update", " to", " "), /* @__PURE__ */ React.createElement(Text, { bold: true }, verbose ? file_path : relative(getCwd(), file_path))), intersperse(
patch.map((patch2) => /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", paddingLeft: 5, key: patch2.newStart }, /* @__PURE__ */ React.createElement(StructuredDiff, { patch: patch2, dim: true, width: columns - 12 }))),
(i) => /* @__PURE__ */ React.createElement(Box, { paddingLeft: 5, key: `ellipsis-${i}` }, /* @__PURE__ */ React.createElement(Text, { color: getTheme().secondaryText }, "..."))
));
} catch (e) {
logError(e);
return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, null, " ", "\uFFFD\uFFFD?(No changes)"));
}
},
async validateInput({ file_path, old_string, new_string }, { readFileTimestamps }) {
if (old_string === new_string) {
return {
result: false,
message: "No changes to make: old_string and new_string are exactly the same.",
meta: {
old_string
}
};
}
const fullFilePath = isAbsolute(file_path) ? file_path : resolve(getCwd(), file_path);
if (existsSync(fullFilePath) && old_string === "") {
return {
result: false,
message: "Cannot create new file - file already exists."
};
}
if (!existsSync(fullFilePath) && old_string === "") {
return {
result: true
};
}
if (!existsSync(fullFilePath)) {
const similarFilename = findSimilarFile(fullFilePath);
let message = "File does not exist.";
if (similarFilename) {
message += ` Did you mean ${similarFilename}?`;
}
return {
result: false,
message
};
}
if (fullFilePath.endsWith(".ipynb")) {
return {
result: false,
message: `File is a Jupyter Notebook. Use the ${NotebookEditTool.name} to edit this file.`
};
}
const readTimestamp = readFileTimestamps[fullFilePath];
if (!readTimestamp) {
return {
result: false,
message: "File has not been read yet. Read it first before writing to it.",
meta: {
isFilePathAbsolute: String(isAbsolute(file_path))
}
};
}
const stats = statSync(fullFilePath);
const lastWriteTime = stats.mtimeMs;
if (lastWriteTime > readTimestamp) {
return {
result: false,
message: "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it."
};
}
const enc = detectFileEncoding(fullFilePath);
const file = readFileSync(fullFilePath, enc);
if (!file.includes(old_string)) {
return {
result: false,
message: `String to replace not found in file.`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path))
}
};
}
const matches = file.split(old_string).length - 1;
if (matches > 1) {
return {
result: false,
message: `Found ${matches} matches of the string to replace. For safety, this tool only supports replacing exactly one occurrence at a time. Add more lines of context to your edit and try again.`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path))
}
};
}
return { result: true };
},
async *call({ file_path, old_string, new_string }, { readFileTimestamps }) {
const { patch, updatedFile } = applyEdit(file_path, old_string, new_string);
const fullFilePath = isAbsolute(file_path) ? file_path : resolve(getCwd(), file_path);
const dir = dirname(fullFilePath);
mkdirSync(dir, { recursive: true });
const enc = existsSync(fullFilePath) ? detectFileEncoding(fullFilePath) : "utf8";
const endings = existsSync(fullFilePath) ? detectLineEndings(fullFilePath) : "LF";
const originalFile = existsSync(fullFilePath) ? readFileSync(fullFilePath, enc) : "";
writeTextContent(fullFilePath, updatedFile, enc, endings);
recordFileEdit(fullFilePath, updatedFile);
readFileTimestamps[fullFilePath] = statSync(fullFilePath).mtimeMs;
if (fullFilePath.endsWith(`${sep}${PROJECT_FILE}`)) {
}
emitReminderEvent("file:edited", {
filePath: fullFilePath,
oldString: old_string,
newString: new_string,
timestamp: Date.now(),
operation: old_string === "" ? "create" : new_string === "" ? "delete" : "update"
});
const data = {
filePath: file_path,
oldString: old_string,
newString: new_string,
originalFile,
structuredPatch: patch
};
yield {
type: "result",
data,
resultForAssistant: this.renderResultForAssistant(data)
};
},
renderResultForAssistant({ filePath, originalFile, oldString, newString }) {
const { snippet, startLine } = getSnippet(
originalFile || "",
oldString,
newString
);
return `The file ${filePath} has been updated. Here's the result of running \`cat -n\` on a snippet of the edited file:
${addLineNumbers({
content: snippet,
startLine
})}`;
}
};
function getSnippet(initialText, oldStr, newStr) {
const before = initialText.split(oldStr)[0] ?? "";
const replacementLine = before.split(/\r?\n/).length - 1;
const newFileLines = initialText.replace(oldStr, newStr).split(/\r?\n/);
const startLine = Math.max(0, replacementLine - N_LINES_SNIPPET);
const endLine = replacementLine + N_LINES_SNIPPET + newStr.split(/\r?\n/).length;
const snippetLines = newFileLines.slice(startLine, endLine + 1);
const snippet = snippetLines.join("\n");
return { snippet, startLine: startLine + 1 };
}
export {
FileEditTool,
getSnippet
};
//# sourceMappingURL=FileEditTool.js.map