feat(opencode): fff search tools (#27802)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
This commit is contained in:
Dmitriy Kovalenko
2026-06-06 06:42:31 -07:00
committed by GitHub
parent 4814ab3a3d
commit 7d3d80f840
28 changed files with 1256 additions and 138 deletions
+15 -34
View File
@@ -1,9 +1,8 @@
import path from "path"
import { Effect, Option, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
@@ -19,9 +18,9 @@ export const Parameters = Schema.Struct({
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* FSUtil.Service
const reference = yield* Reference.Service
const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -52,36 +51,18 @@ export const GlobTool = Tool.define(
})
const limit = 100
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime =
info?.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const files = yield* searchSvc.glob({
cwd: search,
pattern: params.pattern,
limit,
signal: ctx.abort,
})
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((file) => file.path))
if (truncated) {
if (files.files.length === 0) output.push("No files found")
if (files.files.length > 0) {
output.push(...files.files)
if (files.truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@@ -92,8 +73,8 @@ export const GlobTool = Tool.define(
return {
title: path.relative(ins.worktree, search),
metadata: {
count: files.length,
truncated,
count: files.files.length,
truncated: files.truncated,
},
output: output.join("\n"),
}
+1 -1
View File
@@ -1,6 +1,6 @@
- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Returns matching file paths
- Use this tool when you need to find files by name patterns
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
+21 -33
View File
@@ -1,9 +1,8 @@
import path from "path"
import { Schema } from "effect"
import { Effect, Option } from "effect"
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool"
@@ -25,7 +24,7 @@ export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service
const searchSvc = yield* Search.Service
const reference = yield* Reference.Service
return {
@@ -69,7 +68,7 @@ export const GrepTool = Tool.define(
const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
const result = yield* rg.search({
const result = yield* searchSvc.search({
cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
@@ -83,38 +82,15 @@ export const GrepTool = Tool.define(
line: item.line_number,
text: item.lines.text,
}))
const times = new Map(
(yield* Effect.forEach(
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
)
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
matches.sort((a, b) => b.mtime - a.mtime)
const limit = 100
const truncated = matches.length > limit
const final = truncated ? matches.slice(0, limit) : matches
const truncated = rows.length > limit
const final = truncated ? rows.slice(0, limit) : rows
if (final.length === 0) return empty
const total = matches.length
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
const total = rows.length
const hasMore = truncated || result.hasNextPage
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
let current = ""
for (const match of final) {
@@ -135,11 +111,23 @@ export const GrepTool = Tool.define(
)
}
if (result.hasNextPage) {
output.push("")
output.push(
`(Results truncated. Consider using a more specific path or pattern.)`,
)
}
if (result.partial) {
output.push("")
output.push("(Some paths were inaccessible and skipped)")
}
if (result.regexFallbackError) {
output.push("")
output.push(`(Regex fallback: ${result.regexFallbackError})`)
}
return {
title: params.pattern,
metadata: {
+1 -1
View File
@@ -2,7 +2,7 @@
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time
- Returns file paths and line numbers with matching lines
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
+4 -1
View File
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { Search } from "@opencode-ai/core/filesystem/search"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { Reference } from "@/reference/reference"
@@ -65,7 +66,7 @@ type Metadata = {
export const ReadTool = Tool.define<
typeof Parameters,
Metadata,
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Scope.Scope
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope
>(
"read",
Effect.gen(function* () {
@@ -73,6 +74,7 @@ export const ReadTool = Tool.define<
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const reference = yield* Reference.Service
const search = yield* Search.Service
const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@@ -117,6 +119,7 @@ export const ReadTool = Tool.define<
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
yield* search.open({ file: filepath }).pipe(Effect.ignore)
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
})
+3 -3
View File
@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
@@ -101,7 +101,7 @@ export const layer: Layer.Layer<
| EventV2Bridge.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Search.Service
| Format.Service
| Truncate.Service
| RuntimeFlags.Service
@@ -386,7 +386,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer),
)
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
+3 -3
View File
@@ -2,7 +2,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
@@ -15,7 +15,7 @@ export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -36,7 +36,7 @@ export const SkillTool = Tool.define(
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),