/
Alex_Ural
/
opencode
Обзор
Документация
Войти
/
Alex_Ural
/
opencode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
packages/core/src/tool/edit.ts
223 строки
9 KB
James Long
refactor(core): remove tool layer exports (#34622)
30 июн 2026, 18:18
Не верифицирован
30 июн 2026, 18:18
5691f36
Код
Авторство
О чём код?
/** * Model-facing V2 exact-edit leaf. Relative paths resolve within the active * Location. Absolute paths inside that Location are accepted, while explicit * absolute external paths retain mutation capability through a separate * external_directory approval before edit approval. */ export * as EditTool from "./edit" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" export const name = "edit" export const Input = Schema.Struct({ path: Schema.String.annotate({ description: "File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.", }), oldString: Schema.String.annotate({ description: "Exact text to replace" }), newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }), replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Replace all exact occurrences of oldString (default false)", }), }) export const Output = Schema.Struct({ files: Schema.Array(FileDiff.Info), replacements: Schema.Number, }) export type Output = typeof Output.Type const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n") const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n") const convertToLineEnding = (text: string, ending: "\n" | "\r\n") => ending === "\n" ? normalizeLineEndings(text) : normalizeLineEndings(text).replaceAll("\n", "\r\n") const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } const joinBom = (text: string, bom: boolean) => (bom ? `\uFEFF${text}` : text) const decodeUtf8 = (content: Uint8Array) => { const bom = content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf return { bom, content, text: new TextDecoder().decode(bom ? content.slice(3) : content) } } const countOccurrences = (content: string, search: string) => { if (search === "") return content.length + 1 let count = 0 let offset = 0 while ((offset = content.indexOf(search, offset)) !== -1) { count++ offset += search.length } return count } const previewLines = (value: string, prefix: "+" | "-") => { const lines = normalizeLineEndings(value).split("\n") const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`) if (lines.length > shown.length) shown.push(`${prefix}...`) return shown } export const toModelOutput = (output: Output, oldString: string, newString: string) => [ `Edited file successfully: ${output.files[0]?.file}`, `Replacements: ${output.replacements}`, "```diff", ...previewLines(oldString, "-"), ...previewLines(newString, "+"), "```", ].join("\n") /** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */ // TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review. // TODO: Add formatter integration after V2 formatter runtime exists. // TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service const permission = yield* PermissionV2.Service yield* tools .register({ [name]: Tool.withPermission( Tool.make({ description: "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => [ { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, ], execute: (input, context) => { const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe( Effect.mapError((error) => error instanceof FileMutation.StaleContentError ? new ToolFailure({ message: "File changed after permission approval. Read it again before editing.", }) : new ToolFailure({ message: `Unable to edit ${input.path}` }), ), ) return Effect.gen(function* () { const permissionSource = { type: "tool" as const, messageID: context.assistantMessageID, callID: context.toolCallID, } if (input.oldString === input.newString) { return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical.", }) } if (input.oldString === "") { return yield* new ToolFailure({ message: "oldString must not be empty. Use write to create or overwrite a file.", }) } const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" })) const external = target.externalDirectory if (external) { yield* unableToEdit( permission.assert({ ...LocationMutation.externalDirectoryPermission(external), sessionID: context.sessionID, agent: context.agent, source: permissionSource, }), ) } yield* unableToEdit( permission.assert({ action: "edit", resources: [target.resource], save: ["*"], sessionID: context.sessionID, agent: context.agent, source: permissionSource, }), ) const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) const ending = detectLineEnding(source.text) const oldString = convertToLineEnding(input.oldString, ending) const newString = convertToLineEnding(input.newString, ending) const replacements = countOccurrences(source.text, oldString) if (replacements === 0) { return yield* new ToolFailure({ message: "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", }) } if (replacements > 1 && input.replaceAll !== true) { return yield* new ToolFailure({ message: "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", }) } const replaced = input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) const counts = diffLines(source.text, replaced).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), }), { additions: 0, deletions: 0 }, ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ target, expected: source.content, content: joinBom(next.text, source.bom || next.bom), }), ) return { files: [ { file: result.resource, patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), status: "modified" as const, ...counts, }, ], replacements, } satisfies Output }) }, }), "edit", ), }) .pipe(Effect.orDie) }), ) export const node = makeLocationNode({ name: "tool/edit", layer, deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], })