refactor(core): unify filesystem search service (#31566)
This commit is contained in:
parent
ce4e658e3f
commit
a0409e64d8
57 changed files with 962 additions and 2852 deletions
|
|
@ -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}` })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue