UNPKG

pyb-ts

Version:

PYB-CLI - Minimal AI Agent with multi-model support and CLI interface

227 lines (226 loc) 8.63 kB
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