11export * as ReadTool from "./read.js"
22
33import type { Context } from "@opencode/plugin/effect/plugin"
4- import { basename , dirname , join } from "path"
4+ import { basename , dirname , extname , isAbsolute , join , relative } from "path"
55import { ToolFailure } from "@opencode/ai/effect"
66import { Effect , Schema } from "effect"
77import { FSUtil } from "@opencode/util/fs-util"
@@ -27,6 +27,7 @@ const LocationInput = Schema.Struct({
2727 } ) ,
2828} )
2929export const Input = LocationInput
30+ type Missing = { input : string ; requested : FileAccess . Target ; context : FileAccess . Invocation }
3031const Output = Schema . Union ( [ ReadToolFileSystem . FileContent , ReadToolFileSystem . TextPage , ReadToolFileSystem . ListPage ] )
3132
3233export const Plugin = {
@@ -39,6 +40,7 @@ export const Plugin = {
3940 const sessionInstructions = yield * SessionInstructions . Service
4041 const fs = yield * FSUtil . Service
4142 const location = yield * Location . Service
43+ const environment = yield * Environment . Service
4244
4345 yield * ctx . tool
4446 . transform ( ( editor ) =>
@@ -107,15 +109,15 @@ export const Plugin = {
107109 const alternate = yield * alternatePath ( requested . absolute ) . pipe (
108110 Effect . orElseSucceed ( ( ) => undefined ) ,
109111 )
110- if ( ! alternate ) return yield * missing ( input . path , requested . absolute )
112+ if ( ! alternate ) return yield * missing ( { input : input . path , requested, context } )
111113 const target = yield * access . authorizeRead ( alternate , context , { siblingOf : requested } )
112114 const content = yield * read ( target ) . pipe (
113115 Effect . catchIf (
114116 ( error ) => error instanceof Environment . NotFound ,
115- ( ) => missing ( input . path , requested . absolute ) ,
117+ ( ) => missing ( { input : input . path , requested, context } ) ,
116118 ) ,
117119 )
118- if ( content . type === "list-page" ) return yield * missing ( input . path , requested . absolute )
120+ if ( content . type === "list-page" ) return yield * missing ( { input : input . path , requested, context } )
119121 return {
120122 content,
121123 target,
@@ -162,8 +164,8 @@ export const Plugin = {
162164 const message =
163165 error instanceof ReadToolFileSystem . BinaryFileError ||
164166 error instanceof ReadToolFileSystem . MediaIngestLimitError ||
165- error instanceof ReadToolFileSystem . OffsetOutOfRangeError ||
166- error instanceof ReadToolFileSystem . PathKindError
167+ error instanceof ReadToolFileSystem . PathKindError ||
168+ error instanceof ReadToolFileSystem . DirectoryOffsetError
167169 ? error . message
168170 : `Unable to read ${ input . path } `
169171 return new ToolFailure ( { message, error } )
@@ -175,11 +177,6 @@ export const Plugin = {
175177 . pipe ( Effect . orDie )
176178
177179 const alternatePath = Effect . fn ( "ReadTool.alternatePath" ) ( function * ( absolute : string ) {
178- const canonical = ( name : string ) =>
179- name
180- . normalize ( "NFC" )
181- . replace ( / [ \u00a0 \u202f ] / g, " " )
182- . replace ( / [ \u2018 \u2019 ] / g, "'" )
183180 const base = canonical ( basename ( absolute ) )
184181 const matches = ( yield * reader . list ( AbsolutePath . make ( dirname ( absolute ) ) ) ) . filter (
185182 ( entry ) => entry . type === "file" && canonical ( entry . name ) === base ,
@@ -196,54 +193,122 @@ export const Plugin = {
196193 Effect . orElseSucceed ( ( ) => ( { type : "unknown" as const } ) ) ,
197194 )
198195
199- const similar = ( names : readonly string [ ] , name : string ) => {
200- const base = name . toLowerCase ( )
201- return names
202- . filter ( ( entry ) => {
203- const candidate = entry . toLowerCase ( )
204- return candidate . includes ( base ) || base . includes ( candidate )
205- } )
206- . slice ( 0 , 3 )
207- }
196+ // A path written from the wrong root, such as /Users/me/src/a.ts for a location at /Users/me/repo,
197+ // usually means <location>/src/a.ts, so check that one path and nothing else.
198+ const reroot = Effect . fn ( "ReadTool.reroot" ) ( function * ( miss : Missing ) {
199+ const root = location . directory
200+ if ( FSUtil . contains ( root , miss . requested . absolute ) || ! FSUtil . contains ( dirname ( root ) , miss . requested . absolute ) )
201+ return [ ]
202+ const target = yield * access . resolve ( { path : join ( root , relative ( dirname ( root ) , miss . requested . absolute ) ) } )
203+ if ( ! ( yield * Environment . exists ( environment . files , target . absolute ) ) ) return [ ]
204+ const readable = yield * permission . allows ( {
205+ action : "read" ,
206+ resources : [ target . resource ] ,
207+ save : [ "*" ] ,
208+ ...Permission . invocation ( miss . context ) ,
209+ } )
210+ if ( ! readable ) return [ ]
211+ return [ isAbsolute ( miss . input ) || miss . input . startsWith ( "~/" ) ? target . absolute : target . resource ]
212+ } )
213+
214+ const didYouMean = ( suggestions : string [ ] ) =>
215+ suggestions . length === 0 ? [ ] : [ `Did you mean one of these?\n${ suggestions . slice ( 0 , 3 ) . join ( "\n" ) } ` ]
208216
209217 // A missing folder usually means a removed worktree, a moved directory, or a relative path
210218 // resolved from the wrong place, so name the first missing folder and its nearest existing parent.
211- const missingFolder = Effect . fn ( "ReadTool.missingFolder" ) ( function * ( input : string , folder : string ) {
219+ const missingFolder = Effect . fn ( "ReadTool.missingFolder" ) ( function * (
220+ input : string ,
221+ folder : string ,
222+ moved : string [ ] ,
223+ ) {
212224 let child = folder
213225 let parent = dirname ( folder )
214226 while ( parent !== child ) {
215227 const found = yield * listing ( parent )
216228 if ( found . type === "unknown" ) break
217229 if ( found . type === "found" ) {
218- const suggestions = similar ( found . names , basename ( child ) ) . map ( ( entry ) => join ( parent , entry ) )
230+ const suggestions = [ ... moved , ... similar ( found . names , basename ( child ) ) . map ( ( entry ) => join ( parent , entry ) ) ]
219231 const message = [
220232 `File not found: ${ input } ` ,
221233 `The folder ${ child } does not exist. The nearest folder that does is ${ parent } .` ,
222- ...( suggestions . length === 0 ? [ ] : [ `Did you mean one of these?\n ${ suggestions . join ( "\n" ) } ` ] ) ,
234+ ...didYouMean ( suggestions ) ,
223235 ] . join ( "\n\n" )
224236 return yield * new ToolFailure ( { message } )
225237 }
226238 child = parent
227239 parent = dirname ( parent )
228240 }
229- return yield * new ToolFailure ( { message : `File not found: ${ input } ` } )
241+ return yield * new ToolFailure ( { message : [ `File not found: ${ input } ` , ... didYouMean ( moved ) ] . join ( "\n\n" ) } )
230242 } )
231243
232- const missing = Effect . fn ( "ReadTool.missing" ) ( function * ( input : string , absolute : string ) {
233- const folder = dirname ( absolute )
244+ const missing = Effect . fn ( "ReadTool.missing" ) ( function * ( miss : Missing ) {
245+ const moved = yield * reroot ( miss ) . pipe ( Effect . orElseSucceed ( ( ) => [ ] ) )
246+ const folder = dirname ( miss . requested . absolute )
234247 const found = yield * listing ( folder )
235- if ( found . type === "missing" ) return yield * missingFolder ( input , folder )
236- const suggestions =
237- found . type === "found" ? similar ( found . names , basename ( input ) ) . map ( ( entry ) => join ( dirname ( input ) , entry ) ) : [ ]
238- const message =
239- suggestions . length === 0
240- ? `File not found: ${ input } `
241- : `File not found: ${ input } \n\nDid you mean one of these?\n${ suggestions . join ( "\n" ) } `
242- return yield * new ToolFailure ( { message } )
248+ if ( found . type === "missing" ) return yield * missingFolder ( miss . input , folder , moved )
249+ const suggestions = [
250+ ...moved ,
251+ ...( found . type === "found"
252+ ? similar ( found . names , basename ( miss . input ) ) . map ( ( entry ) => join ( dirname ( miss . input ) , entry ) )
253+ : [ ] ) ,
254+ ]
255+ return yield * new ToolFailure ( {
256+ message : [ `File not found: ${ miss . input } ` , ...didYouMean ( suggestions ) ] . join ( "\n\n" ) ,
257+ } )
243258 } )
244259 } ) ,
245260}
246261
262+ // Ranks a folder's entries against a missing name: same name ignoring case, then same stem with
263+ // another extension, then one containing the other, then a typo in the stem.
264+ const similar = ( names : readonly string [ ] , requested : string ) => {
265+ const name = requested . toLowerCase ( )
266+ const wanted = stem ( name )
267+ const typos = wanted . length < 3 ? 0 : wanted . length < 5 ? 1 : 2
268+ const tier = ( candidate : string ) => {
269+ if ( candidate === name ) return 1
270+ // Names that only differ in look-alike characters are what alternatePath recovers or deliberately skips.
271+ if ( canonical ( candidate ) === canonical ( name ) ) return
272+ const found = stem ( candidate )
273+ if ( found === wanted ) return 2
274+ if (
275+ Math . min ( found . length , wanted . length ) >= 3 &&
276+ ( candidate . includes ( name ) || name . includes ( candidate ) || found . includes ( wanted ) || wanted . includes ( found ) )
277+ )
278+ return 3
279+ // Folders can be huge, so skip the distance when the lengths alone rule out a typo.
280+ if ( typos > 0 && Math . abs ( found . length - wanted . length ) <= typos && editDistance ( found , wanted ) <= typos ) return 4
281+ }
282+ return names
283+ . filter ( ( entry ) => entry !== requested )
284+ . flatMap ( ( entry ) => {
285+ const rank = tier ( entry . toLowerCase ( ) )
286+ return rank === undefined ? [ ] : [ { entry, rank, distance : editDistance ( entry . toLowerCase ( ) , name ) } ]
287+ } )
288+ . sort ( ( a , b ) => a . rank - b . rank || a . distance - b . distance || a . entry . localeCompare ( b . entry ) )
289+ . slice ( 0 , 3 )
290+ . map ( ( match ) => match . entry )
291+ }
292+
293+ const canonical = ( name : string ) =>
294+ name
295+ . normalize ( "NFC" )
296+ . replace ( / [ \u00a0 \u202f ] / g, " " )
297+ . replace ( / [ \u2018 \u2019 ] / g, "'" )
298+
299+ const stem = ( name : string ) => name . slice ( 0 , name . length - extname ( name ) . length ) || name
300+
301+ const editDistance = ( a : string , b : string ) => {
302+ let previous = Array . from ( { length : b . length + 1 } , ( _ , index ) => index )
303+ for ( let i = 1 ; i <= a . length ; i ++ ) {
304+ const current = [ i ]
305+ for ( let j = 1 ; j <= b . length ; j ++ )
306+ current [ j ] = Math . min ( previous [ j ] + 1 , current [ j - 1 ] + 1 , previous [ j - 1 ] + ( a [ i - 1 ] === b [ j - 1 ] ? 0 : 1 ) )
307+ previous = current
308+ }
309+ return previous [ b . length ]
310+ }
311+
247312function parseSkillUri ( value : string ) {
248313 const trimmed = value . slice ( "skill://" . length ) . replace ( / \/ + $ / , "" )
249314 const slash = trimmed . indexOf ( "/" )
@@ -284,8 +349,12 @@ export const toModelContent = (path: string, offset: number | undefined, output:
284349 // Pages already join selected lines; a trailing newline represents a selected blank line.
285350 const text = output . type === "file" ? output . content . replace ( / \n $ / , "" ) : output . content
286351 const lines = output . content === "" ? [ ] : text . split ( "\n" )
352+ // A page that starts before the requested offset is the file's tail, returned for an offset past the end.
353+ const total = start + lines . length - 1
354+ const pastEnd = output . type === "text-page" && offset !== undefined && start < offset
287355 const content = [
288- lines . length === 0 ? `Read file ${ path } , 0 lines` : `Read file ${ path } , lines ${ start } -${ start + lines . length - 1 } ` ,
356+ ( lines . length === 0 ? `Read file ${ path } , 0 lines` : `Read file ${ path } , lines ${ start } -${ total } ` ) +
357+ ( pastEnd ? ` (offset ${ offset } is past the end; the file has ${ total } ${ total === 1 ? "line" : "lines" } )` : "" ) ,
289358 ]
290359 lines . forEach ( ( line , index ) => content . push ( `${ start + index } : ${ line } ` ) )
291360 if ( output . type === "text-page" && output . truncated && output . next !== undefined )
0 commit comments