refactor(search): route tools and routes through search

This commit is contained in:
Shoubhit Dash
2026-04-22 22:18:49 +05:30
parent e874d2c62e
commit e1a908cd1a
11 changed files with 66 additions and 104 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Search } from "@/file/search"
import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -79,7 +79,7 @@ const FileTreeCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
const tree = await AppRuntime.runPromise(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
})
},
@@ -1,7 +1,7 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { Search } from "../../../file/search"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -23,7 +23,7 @@ const TreeCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
Search.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
)
process.stdout.write(tree + EOL)
})
@@ -51,8 +51,8 @@ const FilesCommand = cmd({
await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg
const search = yield* Search.Service
return yield* search
.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
@@ -90,7 +90,7 @@ const SearchCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
Search.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern,
@@ -2,7 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Search } from "@/file/search"
import { LSP } from "@/lsp"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
@@ -14,14 +14,14 @@ export const FileRoutes = lazy(() =>
"/find",
describeRoute({
summary: "Find text",
description: "Search for text patterns across files in the project using ripgrep.",
description: "Search for text patterns across files in the project.",
operationId: "find.text",
responses: {
200: {
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
schema: resolver(Search.Match.array()),
},
},
},
@@ -36,7 +36,7 @@ export const FileRoutes = lazy(() =>
async (c) =>
jsonRequest("FileRoutes.findText", c, function* () {
const pattern = c.req.valid("query").pattern
const svc = yield* Ripgrep.Service
const svc = yield* Search.Service
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
return result.items
}),
+15 -34
View File
@@ -1,10 +1,9 @@
import path from "path"
import z from "zod"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Effect } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
@@ -12,8 +11,8 @@ import * as Tool from "./tool"
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -48,36 +47,18 @@ export const GlobTool = Tool.define(
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
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.)`,
@@ -88,8 +69,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"),
}
+12 -31
View File
@@ -1,9 +1,9 @@
import path from "path"
import z from "zod"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool"
@@ -14,7 +14,7 @@ export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -58,7 +58,7 @@ export const GrepTool = Tool.define(
kind: info?.type === "Directory" ? "directory" : "file",
})
const result = yield* rg.search({
const result = yield* searchSvc.search({
cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
@@ -74,37 +74,13 @@ 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 total = rows.length
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
let current = ""
@@ -131,6 +107,11 @@ export const GrepTool = Tool.define(
output.push("(Some paths were inaccessible and skipped)")
}
if (result.regexFallbackError) {
output.push("")
output.push(`(Regex fallback: ${result.regexFallbackError})`)
}
return {
title: params.pattern,
metadata: {
+17 -17
View File
@@ -33,7 +33,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/search"
import { Format } from "../format"
import { InstanceState } from "@/effect"
import { Question } from "../question"
@@ -80,13 +80,13 @@ export const layer: Layer.Layer<
| Provider.Service
| LSP.Service
| Instruction.Service
| AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Format.Service
| Truncate.Service
| AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Search.Service
| Format.Service
| Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -328,12 +328,12 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)
+3 -3
View File
@@ -3,7 +3,7 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/search"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
@@ -16,7 +16,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,
@@ -40,7 +40,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),
@@ -39,7 +39,7 @@ import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool"
import { Log } from "../../src/util"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -175,7 +175,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
@@ -53,7 +53,7 @@ import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { Format } from "../../src/format"
void Log.init({ print: false })
@@ -128,7 +128,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
+2 -2
View File
@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Truncate } from "../../src/tool"
import { Agent } from "../../src/agent/agent"
@@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Search.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
+2 -2
View File
@@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { testEffect } from "../lib/effect"
@@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Search.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),