| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- /**
- * 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 "@kirincode-ai/llm"
- import { FileDiff } from "@kirincode-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],
- })
|