Visitar URL original
fix(core): tail reads from the end of a file and suggest close file n… · anomalyco/opencode@7d5b722 · GitHub
Skip to content

Commit 7d5b722

Browse files
fix(core): tail reads from the end of a file and suggest close file names (#62147)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
1 parent eedb7fb commit 7d5b722

5 files changed

Lines changed: 560 additions & 110 deletions

File tree

‎packages/core/src/tool/plugin/read.ts‎

Lines changed: 104 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export * as ReadTool from "./read.js"
22

33
import type { Context } from "@opencode/plugin/effect/plugin"
4-
import { basename, dirname, join } from "path"
4+
import { basename, dirname, extname, isAbsolute, join, relative } from "path"
55
import { ToolFailure } from "@opencode/ai/effect"
66
import { Effect, Schema } from "effect"
77
import { FSUtil } from "@opencode/util/fs-util"
@@ -27,6 +27,7 @@ const LocationInput = Schema.Struct({
2727
}),
2828
})
2929
export const Input = LocationInput
30+
type Missing = { input: string; requested: FileAccess.Target; context: FileAccess.Invocation }
3031
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
3132

3233
export 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+
247312
function 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

Comments
 (0)