refactor(core): unify filesystem search service (#31566)

This commit is contained in:
Dax 2026-06-09 20:38:02 -04:00 committed by GitHub
commit a0409e64d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 962 additions and 2852 deletions

View file

@ -2,7 +2,11 @@ export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { LocationSearch } from "../location-search"
import path from "path"
import { FileSystem } from "../filesystem"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
@ -10,38 +14,30 @@ import { Tools } from "./tools"
export const name = "glob"
export const Input = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
limit: FileSystem.GlobInput.fields.limit.annotate({
description: "Maximum results to return",
}),
})
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
export const Output = Schema.Array(FileSystem.Entry)
type ModelOutput = typeof Output.Encoded
/** Format raw Location search results into the concise line-oriented output models expect. */
/** Format raw search results into the concise line-oriented output models expect. */
export const toModelOutput = (output: ModelOutput) => {
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
if (output.truncated) {
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
return lines.join("\n")
}
/**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal.
*/
/** Glob leaf that defaults its filesystem root to the active Location. */
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const search = yield* LocationSearch.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
@ -50,8 +46,13 @@ export const layer = Layer.effectDiscard(
description:
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) }))),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -67,7 +68,22 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.files(input)
const cwd = path.resolve(location.directory, input.path ?? ".")
return yield* ripgrep.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
}).pipe(
Effect.map((result) =>
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
)
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
),

View file

@ -2,61 +2,58 @@ export * as GrepTool from "./grep"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "grep"
export const Input = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
pattern: FileSystem.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
include: FileSystem.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
limit: LocationSearch.GrepInput.fields.limit.annotate({
description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
limit: FileSystem.GrepInput.fields.limit.annotate({
description: "Maximum matches to return",
}),
})
type Output = typeof LocationSearch.GrepResult.Encoded
export const Output = Schema.Array(FileSystem.Match)
type ModelOutput = typeof Output.Encoded
/** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Output) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
/** Format raw search matches into the familiar concise model output. */
export const toModelOutput = (output: ModelOutput) => {
const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
let current = ""
for (const match of output.items) {
if (current !== match.resource) {
for (const match of output) {
if (current !== match.entry.path) {
if (current) lines.push("")
current = match.resource
lines.push(`${match.resource}:`)
current = match.entry.path
lines.push(`${match.entry.path}:`)
}
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
lines.push(` Line ${match.line}: ${match.text}`)
}
if (output.truncated) {
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
return lines.join("\n")
}
/**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution.
*/
/** Grep leaf that defaults its filesystem root to the active Location. */
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const search = yield* LocationSearch.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
@ -65,8 +62,18 @@ export const layer = Layer.effectDiscard(
description:
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -74,7 +81,7 @@ export const layer = Layer.effectDiscard(
resources: [input.pattern],
save: ["*"],
metadata: {
root: input.path ?? ".",
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
@ -83,15 +90,35 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.grep(input)
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
return yield* ripgrep.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
pattern: input.pattern,
file: info?.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
}).pipe(
Effect.map((result) =>
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(info?.type === "Directory" ? target : path.dirname(target), match.entry.path),
),
),
}),
}),
),
),
)
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
: `Unable to grep for ${input.pattern}`
return new ToolFailure({ message })
}),
Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` })),
),
}),
})

View file

@ -276,8 +276,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new FileSystem.Entry({
path: RelativePath.make(item.name),
uri: pathToFileURL(target).href,
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
})