edit.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /**
  2. * Model-facing V2 exact-edit leaf. Relative paths resolve within the active
  3. * Location. Absolute paths inside that Location are accepted, while explicit
  4. * absolute external paths retain mutation capability through a separate
  5. * external_directory approval before edit approval.
  6. */
  7. export * as EditTool from "./edit"
  8. import { ToolFailure } from "@kirincode-ai/llm"
  9. import { FileDiff } from "@kirincode-ai/schema/file-diff"
  10. import { createTwoFilesPatch, diffLines } from "diff"
  11. import { Effect, Layer, Schema } from "effect"
  12. import { makeLocationNode } from "../effect/app-node"
  13. import { FileMutation } from "../file-mutation"
  14. import { FSUtil } from "../fs-util"
  15. import { LocationMutation } from "../location-mutation"
  16. import { PermissionV2 } from "../permission"
  17. import { ToolRegistry } from "./registry"
  18. import { Tool } from "./tool"
  19. import { Tools } from "./tools"
  20. export const name = "edit"
  21. export const Input = Schema.Struct({
  22. path: Schema.String.annotate({
  23. description:
  24. "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.",
  25. }),
  26. oldString: Schema.String.annotate({ description: "Exact text to replace" }),
  27. newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
  28. replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({
  29. description: "Replace all exact occurrences of oldString (default false)",
  30. }),
  31. })
  32. export const Output = Schema.Struct({
  33. files: Schema.Array(FileDiff.Info),
  34. replacements: Schema.Number,
  35. })
  36. export type Output = typeof Output.Type
  37. const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
  38. const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
  39. const convertToLineEnding = (text: string, ending: "\n" | "\r\n") =>
  40. ending === "\n" ? normalizeLineEndings(text) : normalizeLineEndings(text).replaceAll("\n", "\r\n")
  41. const splitBom = (text: string) =>
  42. text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
  43. const joinBom = (text: string, bom: boolean) => (bom ? `\uFEFF${text}` : text)
  44. const decodeUtf8 = (content: Uint8Array) => {
  45. const bom = content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
  46. return { bom, content, text: new TextDecoder().decode(bom ? content.slice(3) : content) }
  47. }
  48. const countOccurrences = (content: string, search: string) => {
  49. if (search === "") return content.length + 1
  50. let count = 0
  51. let offset = 0
  52. while ((offset = content.indexOf(search, offset)) !== -1) {
  53. count++
  54. offset += search.length
  55. }
  56. return count
  57. }
  58. const previewLines = (value: string, prefix: "+" | "-") => {
  59. const lines = normalizeLineEndings(value).split("\n")
  60. const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`)
  61. if (lines.length > shown.length) shown.push(`${prefix}...`)
  62. return shown
  63. }
  64. export const toModelOutput = (output: Output, oldString: string, newString: string) =>
  65. [
  66. `Edited file successfully: ${output.files[0]?.file}`,
  67. `Replacements: ${output.replacements}`,
  68. "```diff",
  69. ...previewLines(oldString, "-"),
  70. ...previewLines(newString, "+"),
  71. "```",
  72. ].join("\n")
  73. /** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
  74. // 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.
  75. // TODO: Add formatter integration after V2 formatter runtime exists.
  76. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
  77. // TODO: Add snapshots / undo after design exists.
  78. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
  79. const layer = Layer.effectDiscard(
  80. Effect.gen(function* () {
  81. const tools = yield* Tools.Service
  82. const mutation = yield* LocationMutation.Service
  83. const files = yield* FileMutation.Service
  84. const fs = yield* FSUtil.Service
  85. const permission = yield* PermissionV2.Service
  86. yield* tools
  87. .register({
  88. [name]: Tool.withPermission(
  89. Tool.make({
  90. description:
  91. "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.",
  92. input: Input,
  93. output: Output,
  94. toModelOutput: ({ input, output }) => [
  95. { type: "text", text: toModelOutput(output, input.oldString, input.newString) },
  96. ],
  97. execute: (input, context) => {
  98. const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  99. effect.pipe(
  100. Effect.mapError((error) =>
  101. error instanceof FileMutation.StaleContentError
  102. ? new ToolFailure({
  103. message: "File changed after permission approval. Read it again before editing.",
  104. })
  105. : new ToolFailure({ message: `Unable to edit ${input.path}` }),
  106. ),
  107. )
  108. return Effect.gen(function* () {
  109. const permissionSource = {
  110. type: "tool" as const,
  111. messageID: context.assistantMessageID,
  112. callID: context.toolCallID,
  113. }
  114. if (input.oldString === input.newString) {
  115. return yield* new ToolFailure({
  116. message: "No changes to apply: oldString and newString are identical.",
  117. })
  118. }
  119. if (input.oldString === "") {
  120. return yield* new ToolFailure({
  121. message: "oldString must not be empty. Use write to create or overwrite a file.",
  122. })
  123. }
  124. const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
  125. const external = target.externalDirectory
  126. if (external) {
  127. yield* unableToEdit(
  128. permission.assert({
  129. ...LocationMutation.externalDirectoryPermission(external),
  130. sessionID: context.sessionID,
  131. agent: context.agent,
  132. source: permissionSource,
  133. }),
  134. )
  135. }
  136. yield* unableToEdit(
  137. permission.assert({
  138. action: "edit",
  139. resources: [target.resource],
  140. save: ["*"],
  141. sessionID: context.sessionID,
  142. agent: context.agent,
  143. source: permissionSource,
  144. }),
  145. )
  146. const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
  147. const ending = detectLineEnding(source.text)
  148. const oldString = convertToLineEnding(input.oldString, ending)
  149. const newString = convertToLineEnding(input.newString, ending)
  150. const replacements = countOccurrences(source.text, oldString)
  151. if (replacements === 0) {
  152. return yield* new ToolFailure({
  153. message:
  154. "Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
  155. })
  156. }
  157. if (replacements > 1 && input.replaceAll !== true) {
  158. return yield* new ToolFailure({
  159. message:
  160. "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
  161. })
  162. }
  163. const replaced =
  164. input.replaceAll === true
  165. ? source.text.replaceAll(oldString, newString)
  166. : source.text.replace(oldString, newString)
  167. const counts = diffLines(source.text, replaced).reduce(
  168. (result, item) => ({
  169. additions: result.additions + (item.added ? (item.count ?? 0) : 0),
  170. deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
  171. }),
  172. { additions: 0, deletions: 0 },
  173. )
  174. const next = splitBom(replaced)
  175. const result = yield* unableToEdit(
  176. files.writeIfUnchanged({
  177. target,
  178. expected: source.content,
  179. content: joinBom(next.text, source.bom || next.bom),
  180. }),
  181. )
  182. return {
  183. files: [
  184. {
  185. file: result.resource,
  186. patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
  187. status: "modified" as const,
  188. ...counts,
  189. },
  190. ],
  191. replacements,
  192. } satisfies Output
  193. })
  194. },
  195. }),
  196. "edit",
  197. ),
  198. })
  199. .pipe(Effect.orDie)
  200. }),
  201. )
  202. export const node = makeLocationNode({
  203. name: "tool/edit",
  204. layer,
  205. deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
  206. })