104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
export * as GrepTool from "./grep"
|
|
|
|
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
|
import { Effect, Layer, Schema } from "effect"
|
|
import { FileSystem } from "../filesystem"
|
|
import { LocationSearch } from "../location-search"
|
|
import { Ripgrep } from "../ripgrep"
|
|
import { PermissionV2 } from "../permission"
|
|
import { Tool } from "./tool"
|
|
import { Tools } from "./tools"
|
|
|
|
export const name = "grep"
|
|
|
|
export const Input = Schema.Struct({
|
|
pattern: LocationSearch.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.",
|
|
}),
|
|
include: LocationSearch.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})`,
|
|
}),
|
|
})
|
|
|
|
type Output = typeof LocationSearch.GrepResult.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`]
|
|
let current = ""
|
|
for (const match of output.items) {
|
|
if (current !== match.resource) {
|
|
if (current) lines.push("")
|
|
current = match.resource
|
|
lines.push(`${match.resource}:`)
|
|
}
|
|
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
|
|
}
|
|
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.
|
|
*/
|
|
export const layer = Layer.effectDiscard(
|
|
Effect.gen(function* () {
|
|
const tools = yield* Tools.Service
|
|
const filesystem = yield* FileSystem.Service
|
|
const search = yield* LocationSearch.Service
|
|
const permission = yield* PermissionV2.Service
|
|
|
|
yield* tools
|
|
.register({
|
|
[name]: Tool.make({
|
|
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 }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
|
execute: (input, context) =>
|
|
Effect.gen(function* () {
|
|
const root = yield* filesystem.resolveRoot(input)
|
|
yield* permission.assert({
|
|
action: name,
|
|
resources: [input.pattern],
|
|
save: ["*"],
|
|
metadata: {
|
|
root: root.resource,
|
|
path: input.path,
|
|
include: input.include,
|
|
limit: input.limit,
|
|
},
|
|
sessionID: context.sessionID,
|
|
agent: context.agent,
|
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
|
})
|
|
return yield* search.grep(input)
|
|
}).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 })
|
|
}),
|
|
),
|
|
}),
|
|
})
|
|
.pipe(Effect.orDie)
|
|
}),
|
|
)
|