55
66import z from "zod"
77import * as path from "path"
8+ import { Effect } from "effect"
89import { Tool } from "./tool"
910import { LSP } from "../lsp"
1011import { createTwoFilesPatch , diffLines } from "diff"
@@ -17,7 +18,7 @@ import { FileTime } from "../file/time"
1718import { Filesystem } from "../util/filesystem"
1819import { Instance } from "../project/instance"
1920import { Snapshot } from "@/snapshot"
20- import { assertExternalDirectory } from "./external-directory"
21+ import { assertExternalDirectoryEffect } from "./external-directory"
2122
2223const MAX_DIAGNOSTICS_PER_FILE = 20
2324
@@ -34,136 +35,161 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
3435 return text . replaceAll ( "\n" , "\r\n" )
3536}
3637
37- export const EditTool = Tool . define ( "edit" , {
38- description : DESCRIPTION ,
39- parameters : z . object ( {
40- filePath : z . string ( ) . describe ( "The absolute path to the file to modify" ) ,
41- oldString : z . string ( ) . describe ( "The text to replace" ) ,
42- newString : z . string ( ) . describe ( "The text to replace it with (must be different from oldString)" ) ,
43- replaceAll : z . boolean ( ) . optional ( ) . describe ( "Replace all occurrences of oldString (default false)" ) ,
44- } ) ,
45- async execute ( params , ctx ) {
46- if ( ! params . filePath ) {
47- throw new Error ( "filePath is required" )
48- }
38+ const Parameters = z . object ( {
39+ filePath : z . string ( ) . describe ( "The absolute path to the file to modify" ) ,
40+ oldString : z . string ( ) . describe ( "The text to replace" ) ,
41+ newString : z . string ( ) . describe ( "The text to replace it with (must be different from oldString)" ) ,
42+ replaceAll : z . boolean ( ) . optional ( ) . describe ( "Replace all occurrences of oldString (default false)" ) ,
43+ } )
4944
50- if ( params . oldString === params . newString ) {
51- throw new Error ( "No changes to apply: oldString and newString are identical." )
52- }
45+ export const EditTool = Tool . defineEffect (
46+ "edit" ,
47+ Effect . gen ( function * ( ) {
48+ const lsp = yield * LSP . Service
49+ const filetime = yield * FileTime . Service
5350
54- const filePath = path . isAbsolute ( params . filePath ) ? params . filePath : path . join ( Instance . directory , params . filePath )
55- await assertExternalDirectory ( ctx , filePath )
56-
57- let diff = ""
58- let contentOld = ""
59- let contentNew = ""
60- await FileTime . withLock ( filePath , async ( ) => {
61- if ( params . oldString === "" ) {
62- const existed = await Filesystem . exists ( filePath )
63- contentNew = params . newString
64- diff = trimDiff ( createTwoFilesPatch ( filePath , filePath , contentOld , contentNew ) )
65- await ctx . ask ( {
66- permission : "edit" ,
67- patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
68- always : [ "*" ] ,
69- metadata : {
70- filepath : filePath ,
71- diff,
72- } ,
73- } )
74- await Filesystem . write ( filePath , params . newString )
75- await Format . file ( filePath )
76- Bus . publish ( File . Event . Edited , { file : filePath } )
77- await Bus . publish ( FileWatcher . Event . Updated , {
78- file : filePath ,
79- event : existed ? "change" : "add" ,
80- } )
81- await FileTime . read ( ctx . sessionID , filePath )
82- return
83- }
51+ return {
52+ description : DESCRIPTION ,
53+ parameters : Parameters ,
54+ execute : ( params : z . infer < typeof Parameters > , ctx : Tool . Context ) =>
55+ Effect . gen ( function * ( ) {
56+ if ( ! params . filePath ) {
57+ throw new Error ( "filePath is required" )
58+ }
8459
85- const stats = Filesystem . stat ( filePath )
86- if ( ! stats ) throw new Error ( `File ${ filePath } not found` )
87- if ( stats . isDirectory ( ) ) throw new Error ( `Path is a directory, not a file: ${ filePath } ` )
88- await FileTime . assert ( ctx . sessionID , filePath )
89- contentOld = await Filesystem . readText ( filePath )
90-
91- const ending = detectLineEnding ( contentOld )
92- const old = convertToLineEnding ( normalizeLineEndings ( params . oldString ) , ending )
93- const next = convertToLineEnding ( normalizeLineEndings ( params . newString ) , ending )
94-
95- contentNew = replace ( contentOld , old , next , params . replaceAll )
96-
97- diff = trimDiff (
98- createTwoFilesPatch ( filePath , filePath , normalizeLineEndings ( contentOld ) , normalizeLineEndings ( contentNew ) ) ,
99- )
100- await ctx . ask ( {
101- permission : "edit" ,
102- patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
103- always : [ "*" ] ,
104- metadata : {
105- filepath : filePath ,
106- diff,
107- } ,
108- } )
109-
110- await Filesystem . write ( filePath , contentNew )
111- await Format . file ( filePath )
112- Bus . publish ( File . Event . Edited , { file : filePath } )
113- await Bus . publish ( FileWatcher . Event . Updated , {
114- file : filePath ,
115- event : "change" ,
116- } )
117- contentNew = await Filesystem . readText ( filePath )
118- diff = trimDiff (
119- createTwoFilesPatch ( filePath , filePath , normalizeLineEndings ( contentOld ) , normalizeLineEndings ( contentNew ) ) ,
120- )
121- await FileTime . read ( ctx . sessionID , filePath )
122- } )
60+ if ( params . oldString === params . newString ) {
61+ throw new Error ( "No changes to apply: oldString and newString are identical." )
62+ }
12363
124- const filediff : Snapshot . FileDiff = {
125- file : filePath ,
126- patch : diff ,
127- additions : 0 ,
128- deletions : 0 ,
129- }
130- for ( const change of diffLines ( contentOld , contentNew ) ) {
131- if ( change . added ) filediff . additions += change . count || 0
132- if ( change . removed ) filediff . deletions += change . count || 0
133- }
64+ const filePath = path . isAbsolute ( params . filePath )
65+ ? params . filePath
66+ : path . join ( Instance . directory , params . filePath )
67+ yield * assertExternalDirectoryEffect ( ctx , filePath )
68+
69+ let diff = ""
70+ let contentOld = ""
71+ let contentNew = ""
72+ yield * filetime . withLock ( filePath , async ( ) => {
73+ if ( params . oldString === "" ) {
74+ const existed = await Filesystem . exists ( filePath )
75+ contentNew = params . newString
76+ diff = trimDiff ( createTwoFilesPatch ( filePath , filePath , contentOld , contentNew ) )
77+ await ctx . ask ( {
78+ permission : "edit" ,
79+ patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
80+ always : [ "*" ] ,
81+ metadata : {
82+ filepath : filePath ,
83+ diff,
84+ } ,
85+ } )
86+ await Filesystem . write ( filePath , params . newString )
87+ await Format . file ( filePath )
88+ Bus . publish ( File . Event . Edited , { file : filePath } )
89+ await Bus . publish ( FileWatcher . Event . Updated , {
90+ file : filePath ,
91+ event : existed ? "change" : "add" ,
92+ } )
93+ await FileTime . read ( ctx . sessionID , filePath )
94+ return
95+ }
13496
135- ctx . metadata ( {
136- metadata : {
137- diff,
138- filediff,
139- diagnostics : { } ,
140- } ,
141- } )
97+ const stats = Filesystem . stat ( filePath )
98+ if ( ! stats ) throw new Error ( `File ${ filePath } not found` )
99+ if ( stats . isDirectory ( ) ) throw new Error ( `Path is a directory, not a file: ${ filePath } ` )
100+ await FileTime . assert ( ctx . sessionID , filePath )
101+ contentOld = await Filesystem . readText ( filePath )
102+
103+ const ending = detectLineEnding ( contentOld )
104+ const old = convertToLineEnding ( normalizeLineEndings ( params . oldString ) , ending )
105+ const next = convertToLineEnding ( normalizeLineEndings ( params . newString ) , ending )
106+
107+ contentNew = replace ( contentOld , old , next , params . replaceAll )
108+
109+ diff = trimDiff (
110+ createTwoFilesPatch (
111+ filePath ,
112+ filePath ,
113+ normalizeLineEndings ( contentOld ) ,
114+ normalizeLineEndings ( contentNew ) ,
115+ ) ,
116+ )
117+ await ctx . ask ( {
118+ permission : "edit" ,
119+ patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
120+ always : [ "*" ] ,
121+ metadata : {
122+ filepath : filePath ,
123+ diff,
124+ } ,
125+ } )
126+
127+ await Filesystem . write ( filePath , contentNew )
128+ await Format . file ( filePath )
129+ Bus . publish ( File . Event . Edited , { file : filePath } )
130+ await Bus . publish ( FileWatcher . Event . Updated , {
131+ file : filePath ,
132+ event : "change" ,
133+ } )
134+ contentNew = await Filesystem . readText ( filePath )
135+ diff = trimDiff (
136+ createTwoFilesPatch (
137+ filePath ,
138+ filePath ,
139+ normalizeLineEndings ( contentOld ) ,
140+ normalizeLineEndings ( contentNew ) ,
141+ ) ,
142+ )
143+ await FileTime . read ( ctx . sessionID , filePath )
144+ } )
145+
146+ const filediff : Snapshot . FileDiff = {
147+ file : filePath ,
148+ patch : diff ,
149+ additions : 0 ,
150+ deletions : 0 ,
151+ }
152+ for ( const change of diffLines ( contentOld , contentNew ) ) {
153+ if ( change . added ) filediff . additions += change . count || 0
154+ if ( change . removed ) filediff . deletions += change . count || 0
155+ }
142156
143- let output = "Edit applied successfully."
144- await LSP . touchFile ( filePath , true )
145- const diagnostics = await LSP . diagnostics ( )
146- const normalizedFilePath = Filesystem . normalizePath ( filePath )
147- const issues = diagnostics [ normalizedFilePath ] ?? [ ]
148- const errors = issues . filter ( ( item ) => item . severity === 1 )
149- if ( errors . length > 0 ) {
150- const limited = errors . slice ( 0 , MAX_DIAGNOSTICS_PER_FILE )
151- const suffix =
152- errors . length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${ errors . length - MAX_DIAGNOSTICS_PER_FILE } more` : ""
153- output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${ filePath } ">\n${ limited . map ( LSP . Diagnostic . pretty ) . join ( "\n" ) } ${ suffix } \n</diagnostics>`
154- }
157+ ctx . metadata ( {
158+ metadata : {
159+ diff,
160+ filediff,
161+ diagnostics : { } ,
162+ } ,
163+ } )
164+
165+ let output = "Edit applied successfully."
166+ yield * lsp . touchFile ( filePath , true )
167+ const diagnostics = yield * lsp . diagnostics ( )
168+ const normalizedFilePath = Filesystem . normalizePath ( filePath )
169+ const issues = diagnostics [ normalizedFilePath ] ?? [ ]
170+ const errors = issues . filter ( ( item ) => item . severity === 1 )
171+ if ( errors . length > 0 ) {
172+ const limited = errors . slice ( 0 , MAX_DIAGNOSTICS_PER_FILE )
173+ const suffix =
174+ errors . length > MAX_DIAGNOSTICS_PER_FILE
175+ ? `\n... and ${ errors . length - MAX_DIAGNOSTICS_PER_FILE } more`
176+ : ""
177+ output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${ filePath } ">\n${ limited . map ( LSP . Diagnostic . pretty ) . join ( "\n" ) } ${ suffix } \n</diagnostics>`
178+ }
155179
156- return {
157- metadata : {
158- diagnostics,
159- diff,
160- filediff,
161- } ,
162- title : `${ path . relative ( Instance . worktree , filePath ) } ` ,
163- output,
180+ return {
181+ metadata : {
182+ diagnostics,
183+ diff,
184+ filediff,
185+ } ,
186+ title : `${ path . relative ( Instance . worktree , filePath ) } ` ,
187+ output,
188+ }
189+ } ) . pipe ( Effect . orDie , Effect . runPromise ) ,
164190 }
165- } ,
166- } )
191+ } ) ,
192+ )
167193
168194export type Replacer = ( content : string , find : string ) => Generator < string , void , unknown >
169195
0 commit comments