/** * `str_replace_editor` — a faithful port of DSH's * `@deepseek-ai/dsh-tool-str-replace-editor` over the local filesystem. * * Commands: view | create | str_replace | insert. Absolute paths only. * Messages, formats, and error strings mirror the DSH implementation so the * model's experience matches the Minimal preset. */ import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises"; import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; import { maybeTruncate } from "./schemas.ts"; export interface EditorArgs { command: "view" | "create" | "str_replace" | "insert"; path: string; file_text?: string; insert_line?: number; new_str?: string; old_str?: string; view_range?: number[]; } export interface EditorOptions { maxOutputChars?: number; } function resolveTarget(path: string): string { if (typeof path !== "string" || path.trim().length === 0) throw new Error("path must be a non-empty string"); if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`); } return resolvePath(path); } async function statExisting(target: string, command: string) { let info: Awaited>; try { info = await stat(target); } catch { throw new Error(`The path ${target} does not exist. Please provide a valid path.`); } if (info.isDirectory() && command !== "view") { throw new Error(`The path ${target} is a directory and only the \`view\` command can be used on directories`); } return info; } function requiredForCommand(value: T | undefined, parameter: string, command: string, allowEmpty = true): T { if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`); if (!allowEmpty && (value as unknown as string).length === 0) { throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`); } return value; } export function formatFileView(path: string, content: string, maxOutputChars: number, viewRange?: number[]): string { const allLines = content.split("\n"); let lines = allLines; let initialLine = 1; let finalLine: number | undefined; let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`; if (viewRange !== undefined) { if (viewRange.length !== 2 || viewRange[0] === undefined || viewRange[1] === undefined || !viewRange.every(Number.isInteger)) { throw new Error("Invalid `view_range`. It should be a list of two integers."); } initialLine = viewRange[0]; finalLine = viewRange[1]; if (initialLine < 1 || initialLine > allLines.length) { throw new Error( `Invalid \`view_range\`: [${viewRange.join(", ")}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, ); } if (finalLine > allLines.length) { throw new Error( `Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, ); } if (finalLine !== -1 && finalLine < initialLine) { throw new Error( `Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, ); } lines = finalLine === -1 ? allLines.slice(initialLine - 1) : allLines.slice(initialLine - 1, finalLine); prompt += ` with view_range=[${initialLine}, ${finalLine}]`; } const numbered = lines.map((line, index) => `${String(initialLine + index).padStart(6, " ")} ${line}`).join("\n"); return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars); } function codepointCompare(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } async function listDirectory(target: string, maxOutputChars: number): Promise { async function visit(dir: string, depth: number): Promise { const entries = await readdir(dir, { withFileTypes: true }); const rows: string[] = []; for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "__pycache__") continue; const full = `${dir}/${entry.name}`; const type = entry.isDirectory() ? "d" : entry.isFile() ? "f" : "?"; rows.push(`${type}\t${full}`); if (entry.isDirectory() && depth < 2) rows.push(...(await visit(full, depth + 1))); } return rows; } const rows = [`d\t${target}`, ...(await visit(target, 1))]; rows.sort((left, right) => codepointCompare(left.slice(left.indexOf("\t") + 1), right.slice(right.indexOf("\t") + 1))); const listing = maybeTruncate(`${rows.join("\n")}\n`, maxOutputChars); return `Here're the files and directories up to 2 levels deep in ${target}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`; } async function viewPath(path: string, viewRange: number[] | undefined, maxOutputChars: number): Promise { const target = resolveTarget(path); const info = await statExisting(target, "view"); if (info.isDirectory()) { if (viewRange !== undefined) throw new Error("The `view_range` parameter is not allowed when `path` points to a directory."); return listDirectory(target, maxOutputChars); } if (!info.isFile()) throw new Error(`cannot view "${target}": not a regular file or directory`); const content = await readFile(target, "utf8"); return formatFileView(target, content, maxOutputChars, viewRange); } async function exists(target: string): Promise { try { await stat(target); return true; } catch { return false; } } async function createFile(path: string, fileText: string | undefined): Promise { const content = requiredForCommand(fileText, "file_text", "create"); const target = resolveTarget(path); if (await exists(target)) { throw new Error(`File already exists at: ${target}. Cannot overwrite files using command \`create\`.`); } await mkdir(dirname(target), { recursive: true }); await writeFile(target, content, { encoding: "utf8", flag: "wx" }); return `New file created successfully at: ${target}`; } export function matchOffsets(haystack: string, needle: string): number[] { const offsets: number[] = []; if (needle.length === 0) return offsets; let from = 0; for (;;) { const at = haystack.indexOf(needle, from); if (at < 0) break; offsets.push(at); from = at + needle.length; } return offsets; } export function lineNumbersAt(content: string, offsets: number[]): number[] { return offsets.map((offset) => { let line = 1; for (let i = 0; i < offset; i++) if (content.charCodeAt(i) === 10) line++; return line; }); } async function replaceInFile(path: string, oldStr: string | undefined, newStr: string | undefined): Promise { const target = resolveTarget(path); const oldValue = requiredForCommand(oldStr, "old_str", "str_replace", false); const newValue = newStr ?? ""; const info = await statExisting(target, "str_replace"); if (!info.isFile()) throw new Error(`cannot edit "${target}": not a regular file`); const before = await readFile(target, "utf8"); const offsets = matchOffsets(before, oldValue); const offset = offsets[0]; if (offset === undefined) { throw new Error(`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target}.`); } if (offsets.length > 1) { throw new Error( `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lineNumbersAt(before, offsets).join(", ")}]. Please ensure it is unique`, ); } await writeFile(target, before.slice(0, offset) + newValue + before.slice(offset + oldValue.length), "utf8"); return `The file ${target} has been edited successfully.`; } async function insertInFile(path: string, insertLine: number | undefined, newStr: string | undefined): Promise { if (insertLine === undefined) throw new Error("Parameter `insert_line` is required for command: insert"); const value = requiredForCommand(newStr, "new_str", "insert"); const target = resolveTarget(path); const info = await statExisting(target, "insert"); if (!info.isFile()) throw new Error(`cannot insert into "${target}": not a regular file`); const lines = (await readFile(target, "utf8")).split("\n"); if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, ); } const after = [...lines.slice(0, insertLine), ...value.split("\n"), ...lines.slice(insertLine)].join("\n"); await writeFile(target, after, "utf8"); return `The file ${target} has been edited successfully.`; } /** Execute one editor command; throws with DSH-compatible messages on error. */ export async function runEditor(args: EditorArgs, options: EditorOptions = {}): Promise { const maxOutputChars = options.maxOutputChars ?? 16_000; switch (args.command) { case "view": return viewPath(args.path, args.view_range, maxOutputChars); case "create": return createFile(args.path, args.file_text); case "str_replace": return replaceInFile(args.path, args.old_str, args.new_str); case "insert": return insertInFile(args.path, args.insert_line, args.new_str); default: throw new Error(`Unknown command: ${String((args as { command: unknown }).command)}`); } }