refactor(core): unify filesystem search service (#31566)
This commit is contained in:
@@ -17,13 +17,13 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||
target?: string,
|
||||
options?: Options,
|
||||
) {
|
||||
if (!target) return
|
||||
if (!target) return false
|
||||
|
||||
if (options?.bypass) return
|
||||
if (options?.bypass) return false
|
||||
|
||||
const ins = yield* InstanceState.context
|
||||
const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
if (containsPath(full, ins)) return
|
||||
if (containsPath(full, ins)) return false
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
@@ -41,6 +41,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||
parentDir: dir,
|
||||
},
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
@@ -18,8 +18,7 @@ export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
@@ -48,18 +47,14 @@ export const GlobTool = Tool.define(
|
||||
})
|
||||
|
||||
const limit = 100
|
||||
const files = yield* searchSvc.glob({
|
||||
cwd: search,
|
||||
pattern: params.pattern,
|
||||
limit,
|
||||
signal: ctx.abort,
|
||||
})
|
||||
const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit })
|
||||
const truncated = files.length === limit
|
||||
|
||||
const output = []
|
||||
if (files.files.length === 0) output.push("No files found")
|
||||
if (files.files.length > 0) {
|
||||
output.push(...files.files)
|
||||
if (files.truncated) {
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((file) => path.resolve(search, file.path)))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
@@ -70,8 +65,8 @@ export const GlobTool = Tool.define(
|
||||
return {
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: files.files.length,
|
||||
truncated: files.truncated,
|
||||
count: files.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
@@ -23,8 +21,7 @@ export const GrepTool = Tool.define(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
@@ -63,30 +60,27 @@ export const GrepTool = Tool.define(
|
||||
const search = FSUtil.resolve(requested)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
|
||||
const result = yield* searchSvc.search({
|
||||
const result = yield* ripgrep.grep({
|
||||
cwd,
|
||||
pattern: params.pattern,
|
||||
glob: params.include ? [params.include] : undefined,
|
||||
file,
|
||||
signal: ctx.abort,
|
||||
include: params.include,
|
||||
limit: 100,
|
||||
})
|
||||
if (result.items.length === 0) return empty
|
||||
if (result.length === 0) return empty
|
||||
|
||||
const rows = result.items.map((item) => ({
|
||||
path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
|
||||
line: item.line_number,
|
||||
text: item.lines.text,
|
||||
const rows = result.map((item) => ({
|
||||
path: path.resolve(cwd, item.entry.path),
|
||||
line: item.line,
|
||||
text: item.text,
|
||||
}))
|
||||
|
||||
const limit = 100
|
||||
const truncated = rows.length > limit
|
||||
const final = truncated ? rows.slice(0, limit) : rows
|
||||
const truncated = rows.length === limit
|
||||
const final = rows
|
||||
if (final.length === 0) return empty
|
||||
|
||||
const total = rows.length
|
||||
const hasMore = truncated || result.hasNextPage
|
||||
const hasMore = truncated || result.length === limit
|
||||
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
|
||||
|
||||
let current = ""
|
||||
@@ -96,31 +90,12 @@ export const GrepTool = Tool.define(
|
||||
current = match.path
|
||||
output.push(`${match.path}:`)
|
||||
}
|
||||
const text =
|
||||
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
||||
output.push(` Line ${match.line}: ${text}`)
|
||||
output.push(` Line ${match.line}: ${match.text}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
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})`)
|
||||
output.push("(Results truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
@@ -65,14 +64,13 @@ type Metadata = {
|
||||
export const ReadTool = Tool.define<
|
||||
typeof Parameters,
|
||||
Metadata,
|
||||
FSUtil.Service | Instruction.Service | LSP.Service | Search.Service | Scope.Scope
|
||||
FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
|
||||
>(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const search = yield* Search.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||
@@ -117,7 +115,6 @@ 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))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { PlanExitTool } from "./plan"
|
||||
import { Session } from "@/session/session"
|
||||
import { QuestionTool } from "./question"
|
||||
@@ -36,7 +36,6 @@ 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 { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
@@ -81,30 +80,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
| Config.Service
|
||||
| Plugin.Service
|
||||
| Question.Service
|
||||
| Todo.Service
|
||||
| Agent.Service
|
||||
| Skill.Service
|
||||
| Session.Service
|
||||
| BackgroundJob.Service
|
||||
| Provider.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| FSUtil.Service
|
||||
| EventV2Bridge.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Search.Service
|
||||
| Format.Service
|
||||
| Truncate.Service
|
||||
| RuntimeFlags.Service
|
||||
| Database.Service
|
||||
> = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -358,7 +334,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
)
|
||||
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
||||
@@ -440,7 +415,7 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [
|
||||
Config.node,
|
||||
Plugin.node,
|
||||
Question.node,
|
||||
@@ -456,8 +431,6 @@ export const node = LayerNode.make(layer, [
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
Ripgrep.node,
|
||||
Search.node,
|
||||
Format.node,
|
||||
Truncate.node,
|
||||
RuntimeFlags.node,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./skill.txt"
|
||||
@@ -15,7 +14,7 @@ export const SkillTool = Tool.define(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -35,14 +34,14 @@ export const SkillTool = Tool.define(
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
const limit = 10
|
||||
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),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||
)
|
||||
const files = yield* ripgrep.find({
|
||||
cwd: dir,
|
||||
pattern: "!**/SKILL.md",
|
||||
hidden: true,
|
||||
follow: false,
|
||||
signal: ctx.abort,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
@@ -57,7 +56,7 @@ export const SkillTool = Tool.define(
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
files.map((file) => `<file>${path.resolve(dir, file.path)}</file>`).join("\n"),
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
|
||||
Reference in New Issue
Block a user