refactor(search): route tools and routes through search

This commit is contained in:
Shoubhit Dash 2026-04-22 22:18:49 +05:30
commit e1a908cd1a
11 changed files with 66 additions and 104 deletions

View file

@ -1,7 +1,7 @@
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Search } from "@/file/search"
import { File } from "../../../file" import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@ -79,7 +79,7 @@ const FileTreeCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { 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)) console.log(JSON.stringify(tree, null, 2))
}) })
}, },

View file

@ -1,7 +1,7 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime" import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep" import { Search } from "../../../file/search"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@ -23,7 +23,7 @@ const TreeCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise( 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) process.stdout.write(tree + EOL)
}) })
@ -51,8 +51,8 @@ const FilesCommand = cmd({
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise( const files = await AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const rg = yield* Ripgrep.Service const search = yield* Search.Service
return yield* rg return yield* search
.files({ .files({
cwd: Instance.directory, cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined, glob: args.glob ? [args.glob] : undefined,
@ -90,7 +90,7 @@ const SearchCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise( const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => Search.Service.use((svc) =>
svc.search({ svc.search({
cwd: Instance.directory, cwd: Instance.directory,
pattern: args.pattern, pattern: args.pattern,

View file

@ -2,7 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { File } from "@/file" import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep" import { Search } from "@/file/search"
import { LSP } from "@/lsp" import { LSP } from "@/lsp"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
@ -14,14 +14,14 @@ export const FileRoutes = lazy(() =>
"/find", "/find",
describeRoute({ describeRoute({
summary: "Find text", 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", operationId: "find.text",
responses: { responses: {
200: { 200: {
description: "Matches", description: "Matches",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()), schema: resolver(Search.Match.array()),
}, },
}, },
}, },
@ -36,7 +36,7 @@ export const FileRoutes = lazy(() =>
async (c) => async (c) =>
jsonRequest("FileRoutes.findText", c, function* () { jsonRequest("FileRoutes.findText", c, function* () {
const pattern = c.req.valid("query").pattern 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 }) const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
return result.items return result.items
}), }),

View file

@ -1,10 +1,9 @@
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Effect, Option } from "effect" import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool" import * as Tool from "./tool"
@ -12,8 +11,8 @@ import * as Tool from "./tool"
export const GlobTool = Tool.define( export const GlobTool = Tool.define(
"glob", "glob",
Effect.gen(function* () { Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const searchSvc = yield* Search.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -48,36 +47,18 @@ export const GlobTool = Tool.define(
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100 const limit = 100
let truncated = false const files = yield* searchSvc.glob({
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe( cwd: search,
Stream.mapEffect((file) => pattern: params.pattern,
Effect.gen(function* () { limit,
const full = path.resolve(search, file) signal: ctx.abort,
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 output = [] const output = []
if (files.length === 0) output.push("No files found") if (files.files.length === 0) output.push("No files found")
if (files.length > 0) { if (files.files.length > 0) {
output.push(...files.map((file) => file.path)) output.push(...files.files)
if (truncated) { if (files.truncated) {
output.push("") output.push("")
output.push( output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, `(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 { return {
title: path.relative(ins.worktree, search), title: path.relative(ins.worktree, search),
metadata: { metadata: {
count: files.length, count: files.files.length,
truncated, truncated: files.truncated,
}, },
output: output.join("\n"), output: output.join("\n"),
} }

View file

@ -1,9 +1,9 @@
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Effect, Option } from "effect" import { Effect } from "effect"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool" import * as Tool from "./tool"
@ -14,7 +14,7 @@ export const GrepTool = Tool.define(
"grep", "grep",
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service const searchSvc = yield* Search.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -58,7 +58,7 @@ export const GrepTool = Tool.define(
kind: info?.type === "Directory" ? "directory" : "file", kind: info?.type === "Directory" ? "directory" : "file",
}) })
const result = yield* rg.search({ const result = yield* searchSvc.search({
cwd, cwd,
pattern: params.pattern, pattern: params.pattern,
glob: params.include ? [params.include] : undefined, glob: params.include ? [params.include] : undefined,
@ -74,37 +74,13 @@ export const GrepTool = Tool.define(
line: item.line_number, line: item.line_number,
text: item.lines.text, 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 limit = 100
const truncated = matches.length > limit const truncated = rows.length > limit
const final = truncated ? matches.slice(0, limit) : matches const final = truncated ? rows.slice(0, limit) : rows
if (final.length === 0) return empty if (final.length === 0) return empty
const total = matches.length const total = rows.length
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`] const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
let current = "" let current = ""
@ -131,6 +107,11 @@ export const GrepTool = Tool.define(
output.push("(Some paths were inaccessible and skipped)") output.push("(Some paths were inaccessible and skipped)")
} }
if (result.regexFallbackError) {
output.push("")
output.push(`(Regex fallback: ${result.regexFallbackError})`)
}
return { return {
title: params.pattern, title: params.pattern,
metadata: { metadata: {

View file

@ -33,7 +33,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Search } from "../file/search"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { Question } from "../question" import { Question } from "../question"
@ -80,13 +80,13 @@ export const layer: Layer.Layer<
| Provider.Service | Provider.Service
| LSP.Service | LSP.Service
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| Bus.Service | Bus.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Search.Service
| Format.Service | Format.Service
| Truncate.Service | Truncate.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -328,12 +328,12 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer), Layer.provide(Truncate.defaultLayer),
), ),
) )

View file

@ -3,7 +3,7 @@ import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { Ripgrep } from "../file/ripgrep" import { Search } from "../file/search"
import { Skill } from "../skill" import { Skill } from "../skill"
import * as Tool from "./tool" import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt" import DESCRIPTION from "./skill.txt"
@ -16,7 +16,7 @@ export const SkillTool = Tool.define(
"skill", "skill",
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service const searchSvc = yield* Search.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -40,7 +40,7 @@ export const SkillTool = Tool.define(
const dir = path.dirname(info.location) const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href const base = pathToFileURL(dir).href
const limit = 10 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.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)), Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit), Stream.take(limit),

View file

@ -39,7 +39,7 @@ import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool" import { Truncate } from "../../src/tool"
import { Log } from "../../src/util" import { Log } from "../../src/util"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -175,7 +175,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),

View file

@ -53,7 +53,7 @@ import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool" import { Truncate } from "../../src/tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { Format } from "../../src/format"
void Log.init({ print: false }) void Log.init({ print: false })
@ -128,7 +128,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),

View file

@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob" import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Truncate } from "../../src/tool" import { Truncate } from "../../src/tool"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Ripgrep.defaultLayer, Search.defaultLayer,
Truncate.defaultLayer, Truncate.defaultLayer,
Agent.defaultLayer, Agent.defaultLayer,
), ),

View file

@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool" import { Truncate } from "../../src/tool"
import { Agent } from "../../src/agent/agent" 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 { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Ripgrep.defaultLayer, Search.defaultLayer,
Truncate.defaultLayer, Truncate.defaultLayer,
Agent.defaultLayer, Agent.defaultLayer,
), ),