diff --git a/bun.lock b/bun.lock index c38ffaf318c1..dcfe52f1d7d8 100644 --- a/bun.lock +++ b/bun.lock @@ -265,6 +265,7 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@ff-labs/fff-bun": "0.9.3", "@lydell/node-pty": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", @@ -1446,6 +1447,24 @@ "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="], + + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="], + + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="], + + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="], + + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="], + + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="], + + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="], + + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], diff --git a/bunfig.toml b/bunfig.toml index 6a042e150a61..283e9fbfe7f0 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"] [test] root = "./do-not-run-tests-from-root" diff --git a/packages/core/package.json b/packages/core/package.json index b0d5f7b0ac94..e720d9357c0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,11 @@ "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" + }, + "#fff": { + "bun": "./src/filesystem/fff.bun.ts", + "node": "./src/filesystem/fff.node.ts", + "default": "./src/filesystem/fff.bun.ts" } }, "devDependencies": { @@ -81,6 +86,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", + "@ff-labs/fff-bun": "0.9.3", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", diff --git a/packages/core/src/filesystem/fff.bun.ts b/packages/core/src/filesystem/fff.bun.ts new file mode 100644 index 000000000000..0ce2593eb33e --- /dev/null +++ b/packages/core/src/filesystem/fff.bun.ts @@ -0,0 +1,136 @@ +import { + FileFinder, + type DirItem, + type DirSearchResult, + type FileItem, + type GrepCursor, + type GrepMatch, + type GrepResult, + type InitOptions, + type MixedItem, + type MixedSearchResult, + type SearchResult, +} from "@ff-labs/fff-bun" + +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export type Init = InitOptions + +export interface Search { + items: FileItem[] + scores: SearchResult["scores"] + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: DirItem[] + scores: DirSearchResult["scores"] + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: MixedItem[] + scores: MixedSearchResult["scores"] + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type File = FileItem +export type Directory = DirItem +export type Mixed = MixedItem +export type Cursor = GrepCursor | null +export type Hit = GrepMatch + +export interface Grep { + items: GrepResult["items"] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return FileFinder.isAvailable() +} + +export function create(opts: Init): Result { + const made = FileFinder.create(opts) + if (!made.ok) return made + const pick = made.value + return { + ok: true, + value: { + destroy: () => pick.destroy(), + isScanning: () => pick.isScanning(), + waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs), + refreshGitStatus: () => pick.refreshGitStatus(), + fileSearch: (query, next) => pick.fileSearch(query, next), + glob: (pattern, next) => pick.glob(pattern, next), + directorySearch: (query, next) => pick.directorySearch(query, next), + mixedSearch: (query, next) => pick.mixedSearch(query, next), + grep: (query, next) => pick.grep(query, next), + trackQuery: (query, file) => pick.trackQuery(query, file), + getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset), + }, + } +} + +export * as Fff from "./fff.bun" diff --git a/packages/core/src/filesystem/fff.node.ts b/packages/core/src/filesystem/fff.node.ts new file mode 100644 index 000000000000..464c2853d7ea --- /dev/null +++ b/packages/core/src/filesystem/fff.node.ts @@ -0,0 +1,138 @@ +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export interface Init { + basePath: string + frecencyDbPath?: string + historyDbPath?: string + useUnsafeNoLock?: boolean + disableMmapCache?: boolean + disableContentIndexing?: boolean + disableWatch?: boolean + aiMode?: boolean + logFilePath?: string + logLevel?: "trace" | "debug" | "info" | "warn" | "error" + enableFsRootScanning?: boolean + enableHomeDirScanning?: boolean +} + +export interface File { + relativePath: string + fileName: string + modified: number +} + +export interface Directory { + relativePath: string + dirName: string + maxAccessFrecency: number +} + +export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory } + +export interface Search { + items: File[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: Directory[] + scores: Array<{ total: number }> + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: Mixed[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type Cursor = null + +export interface Hit { + relativePath: string + fileName: string + lineNumber: number + byteOffset: number + lineContent: string + matchRanges: [number, number][] + contextBefore?: string[] + contextAfter?: string[] +} + +export interface Grep { + items: Hit[] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return false +} + +export function create(_opts: Init): Result { + return { ok: false, error: "fff unavailable on node runtime" } +} + +export * as Fff from "./fff.node" diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts new file mode 100644 index 000000000000..aebd9b98d6b0 --- /dev/null +++ b/packages/core/src/filesystem/search.ts @@ -0,0 +1,549 @@ +import path from "path" +import { Context, Deferred, Effect, Layer, Option, Stream } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { FSUtil } from "../fs-util" +import { Glob } from "../util/glob" +import { Global } from "../global" +import * as Log from "../util/log" +import { serviceUse } from "../effect/service-use" +import { makeRuntime } from "../effect/runtime" +import { Fff } from "#fff" +import { Ripgrep } from "./ripgrep" + +const log = Log.create({ service: "file.search" }) +const root = path.join(Global.Path.cache, "fff") + +export type Item = Ripgrep.Item +export type SearchError = PlatformError | globalThis.Error + +export interface Result { + readonly items: Item[] + readonly partial: boolean + readonly hasNextPage: boolean + readonly engine: "fff" | "ripgrep" + readonly regexFallbackError?: string +} + +export interface FileInput { + readonly cwd: string + readonly query: string + readonly limit?: number + readonly current?: string + readonly kind?: "file" | "directory" | "all" +} + +export interface GlobInput { + readonly cwd: string + readonly pattern: string + readonly limit?: number + readonly signal?: AbortSignal +} + +interface Query { + readonly dir: string + readonly text: string + readonly files: string[] +} + +// A created picker plus its cached scan-readiness gate. The picker is created +// (and its native background scan kicked off) eagerly; `ready` is only awaited +// when the picker is actually used. +interface Picker { + readonly pick: Fff.Picker + readonly ready: Effect.Effect +} + +interface State { + readonly pick: Map + readonly wait: Map> + readonly recent: Query[] +} + +export interface Interface { + readonly files: Ripgrep.Interface["files"] + readonly tree: Ripgrep.Interface["tree"] + readonly search: (input: Ripgrep.SearchInput) => Effect.Effect + readonly file: (input: FileInput) => Effect.Effect + readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError> + readonly open: (input: { cwd?: string; file: string }) => Effect.Effect + readonly warm: (cwd: string) => Effect.Effect + // Destroy the picker for a directory and drop its cached state. Called when a + // directory's instance is disposed so fff's native watcher thread is torn + // down instead of leaking until process exit. + readonly release: (cwd: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Search") {} + +export const use = serviceUse(Service) + +function key(dir: string) { + return Buffer.from(dir).toString("base64url") +} + +function fffSync(action: string, run: () => A) { + return Effect.try({ + try: run, + catch: (cause) => new Error(`fff ${action} failed`, { cause }), + }) +} + +function normalize(text: string) { + return text.replaceAll("\\", "/") +} + +// fff supports glob narrowing for any search out of the box +function fffGlobbedQuery(query: string, glob?: string | string[]) { + if (query && glob) { + const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob + return `${resolvedGlob} ${query}` + } + + return query ?? glob +} + +function remember(state: State, dir: string, text: string, files: string[]) { + if (!files.length) return + const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64) + if (!next.length) return + const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text) + if (idx >= 0) state.recent.splice(idx, 1) + state.recent.unshift({ dir, text, files: next }) + if (state.recent.length > 32) state.recent.length = 32 +} + +function item(hit: Fff.Hit): Item { + const line = Buffer.from(hit.lineContent) + return { + path: { text: normalize(hit.relativePath) }, + lines: { text: hit.lineContent }, + line_number: hit.lineNumber, + absolute_offset: hit.byteOffset, + submatches: hit.matchRanges + .map(([start, end]) => { + const text = line.subarray(start, end).toString("utf8") + if (!text) return undefined + return { + match: { text }, + start, + end, + } + }) + .filter((row): row is Item["submatches"][number] => Boolean(row)), + } +} + +function collectPaths( + out: { items: T[]; scores: Array<{ total: number }> }, + toPath: (item: T) => string, + opts?: { includeZeroScore?: boolean }, +): string[] { + return Array.from( + new Set( + out.items.flatMap((item, idx): string[] => { + const score = out.scores[idx] + if (!score || (!opts?.includeZeroScore && score.total <= 0)) return [] + const text = toPath(item) + if (!text) return [] + return [text] + }), + ), + ) +} + +function searchFff( + pick: Fff.Picker, + kind: "file" | "directory" | "all", + query: string, + opts: { currentFile?: string; pageIndex?: number; pageSize?: number }, +): Fff.Result { + if (kind === "directory") { + const out = pick.directorySearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + } + } + if (kind === "all") { + const out = pick.mixedSearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }), + } + } + const out = pick.fileSearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + } +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const rg = yield* Ripgrep.Service + + const state: State = { + pick: new Map(), + wait: new Map>(), + recent: [] as Query[], + } + + yield* fs.ensureDir(root).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => + Effect.forEach( + state.pick.values(), + (entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore), + { discard: true }, + ), + ) + + const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) { + const out = yield* rg.search(input) + return { + items: out.items, + partial: out.partial, + hasNextPage: false, + engine: "ripgrep" as const, + } + }) + + // Lazy, shared scan-wait for a picker. Preserves the original behavior: if + // the scan does not finish within the budget the picker is destroyed and + // dropped from the cache so callers fall back to ripgrep (and the next + // request recreates a fresh picker). + const scanReady = (dir: string, pick: Fff.Picker) => + Effect.gen(function* () { + const scanned = yield* Effect.tryPromise({ + try: () => pick.waitForScan(5_000), + catch: (cause) => new Error("fff waitForScan failed", { cause }), + }) + if (!scanned.ok || !scanned.value) { + yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore) + state.pick.delete(dir) + log.warn("fff scan not ready", { dir }) + return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error)) + } + + const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus()) + if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error }) + }) + + // Create (or return) the picker for a directory. Creation is synchronous + // and does not await the scan; the native background scan starts as soon as + // the picker exists. The `wait` gate dedupes concurrent creation. + const acquire = Effect.fn("Search.acquire")(function* (cwd: string) { + const available = yield* fffSync("check availability", () => Fff.available()).pipe( + Effect.catch((error) => { + log.warn("fff availability check failed", { error }) + return Effect.succeed(false) + }), + ) + if (!available) return undefined + + const dir = FSUtil.resolve(cwd) + const existing = state.pick.get(dir) + if (existing) return existing + + const pending = state.wait.get(dir) + if (pending) return yield* Deferred.await(pending) + + const gate = yield* Deferred.make() + state.wait.set(dir, gate) + return yield* Effect.gen(function* () { + const id = key(dir) + const isFirstPicker = state.pick.size === 0 + const made = yield* fffSync("create picker", () => + Fff.create({ + basePath: dir, + frecencyDbPath: path.join(root, `${id}.frecency.mdb`), + historyDbPath: path.join(root, `${id}.history.mdb`), + // fff uses a bit different log version, also with spans so keep + // them in the same folder for debuggability + logFilePath: path.join(Global.Path.log, "fff.log"), + logLevel: Log.getLevel().toLowerCase() as Lowercase, + aiMode: true, + // only the first toolcall picker can accumulate resources to index + // home directory, if the user specifically opened opencode at the + // $HOME level or asked it to search there on purpose, otherwise fallback + enableHomeDirScanning: isFirstPicker, + // on unix system it is 99.9% that you do not need to search for the + // content at the / so make fff fail creation and fallback to rg + enableFsRootScanning: isFirstPicker && process.platform === "win32", + }), + ) + if (!made.ok) { + log.warn("fff init failed", { dir, error: made.error }) + const err = new Error(made.error) + yield* Deferred.fail(gate, err) + return yield* Effect.fail(err) + } + + const pick = made.value + const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) } + state.pick.set(dir, entry) + yield* Deferred.succeed(gate, entry) + return entry + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (state.wait.get(dir) === gate) state.wait.delete(dir) + yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore) + }), + ), + ) + }) + + // Resolve a usable, scanned picker for a directory, or undefined when fff is + // unavailable or the scan did not become ready. + const picker = Effect.fn("Search.picker")(function* (cwd: string) { + const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!entry) return undefined + const ready = yield* entry.ready.pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + if (!ready) return undefined + return entry.pick + }) + + const files: Interface["files"] = (input) => rg.files(input) + const tree: Interface["tree"] = (input) => rg.tree(input) + + // in 99% of use cases user that is opened opencode at certain directory will + // conduct a file search in this direcotry, it could be switched later but + // mostly always we will need a file picker for cwd + // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated + const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) { + yield* acquire(cwd).pipe(Effect.ignore) + }) + + // Tear down the picker for a directory. fff pickers own a native background + // watcher thread that otherwise lives until the runtime scope closes (i.e. + // process exit), so disposing the instance that warmed it must destroy it + // here or the thread leaks against a directory that may already be gone. + const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) { + const dir = FSUtil.resolve(cwd) + + const pending = state.wait.get(dir) + if (pending) { + state.wait.delete(dir) + yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore) + } + + const entry = state.pick.get(dir) + if (entry) { + state.pick.delete(dir) + yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore) + } + + const remaining = state.recent.filter((item) => item.dir !== dir) + state.recent.splice(0, state.recent.length, ...remaining) + }) + + const file: Interface["file"] = Effect.fn("Search.file")(function* (input) { + const query = input.query.trim() + const kind = input.kind ?? "file" + + const pick = yield* picker(input.cwd) + if (!pick) return undefined + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + const fffResult = yield* fffSync(`${kind} search`, () => + searchFff(pick, kind, query, { + pageIndex: 0, + currentFile: input.current, // supports both relative and absolute (relative preferred) + pageSize: limit, + }), + ).pipe( + Effect.catch((error) => { + log.warn(`fff ${kind} search failed`, { dir, query, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!fffResult) return undefined + if (!fffResult.ok) { + log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) + return undefined + } + + const rows = fffResult.value + remember( + state, + dir, + query, + rows.map((row) => path.join(dir, row)), + ) + return rows.slice(0, limit) + }) + + const search: Interface["search"] = Effect.fn("Search.search")(function* (input) { + input.signal?.throwIfAborted() + if (input.file?.length) return yield* rip(input) + + const pick = yield* picker(input.cwd) + if (!pick) return yield* rip(input) + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + + const fffGrep = yield* fffSync("grep", () => + pick.grep(fffGlobbedQuery(input.pattern, input.glob), { + mode: "regex", + pageSize: limit, + timeBudgetMs: 1_500, + }), + ).pipe( + Effect.catch((error) => { + log.warn("fff grep failed", { dir, pattern: input.pattern, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!fffGrep) return yield* rip(input) + if (!fffGrep.ok) { + log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error }) + return yield* rip(input) + } + + const rows: Item[] = fffGrep.value.items.map(item) + const regexFallbackError = fffGrep.value.regexFallbackError + + remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text))))) + + return { + items: rows, + partial: false, + hasNextPage: !!fffGrep.value.nextCursor, + engine: "fff" as const, + regexFallbackError, + } + }) + + const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) { + input.signal?.throwIfAborted() + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + const pick = yield* picker(dir) + + if (pick) { + const fffGlob = yield* fffSync("glob file search", () => + pick.glob(normalize(input.pattern), { + pageIndex: 0, + pageSize: limit, + }), + ).pipe( + Effect.catch((error) => { + log.warn("fff glob failed", { dir, pattern: input.pattern, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + + if (fffGlob?.ok) { + const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath)))) + + remember( + state, + dir, + input.pattern, + rows.map((row) => path.join(dir, row)), + ) + + return { + files: rows.slice(0, limit).map((row) => path.join(dir, row)), + truncated: fffGlob.value.totalMatched > rows.length, + } + } else if (fffGlob) { + log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error }) + // fall through to the fallback + } + } + + const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe( + Stream.take(limit + 1), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + const truncated = rows.length > limit + if (truncated) rows.length = limit + + const output = yield* Effect.forEach( + rows, + Effect.fnUntraced(function* (file) { + const full = path.join(dir, file) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + const time = + info?.mtime.pipe( + Option.map((item) => item.getTime()), + Option.getOrElse(() => 0), + ) ?? 0 + return { file: full, time } + }), + { concurrency: 16 }, + ) + output.sort((a, b) => b.time - a.time) + return { + files: output.map((item) => item.file), + truncated, + } + }) + + const open: Interface["open"] = Effect.fn("Search.open")(function* (input) { + const file = input.cwd + ? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file)) + : FSUtil.resolve(input.file) + const idx = state.recent.findIndex((item) => item.files.includes(file)) + if (idx < 0) return + + const row = state.recent[idx] + state.recent.splice(idx, 1) + const entry = state.pick.get(row.dir) + if (!entry) return + + const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe( + Effect.catch((error) => { + log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!out) return + if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error }) + }) + + return Service.of({ files, tree, search, file, glob, open, warm, release }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(FSUtil.defaultLayer), +) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export function tree(input: Ripgrep.TreeInput) { + return runPromise((svc) => svc.tree(input)) +} + +export function search(input: Ripgrep.SearchInput) { + return runPromise((svc) => svc.search(input)) +} + +export function file(input: FileInput) { + return runPromise((svc) => svc.file(input)) +} + +export function glob(input: GlobInput) { + return runPromise((svc) => svc.glob(input)) +} + +export function open(input: { cwd?: string; file: string }) { + return runPromise((svc) => svc.open(input)) +} + +export * as Search from "./search" diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index 3b5249cdc3e8..c395ac017552 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -58,6 +58,9 @@ let logpath = "" export function file() { return logpath } +export function getLevel(): Level { + return level +} let write = (msg: any) => { process.stderr.write(msg) return msg.length diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts new file mode 100644 index 000000000000..4b1d3ccd03d3 --- /dev/null +++ b/packages/core/test/filesystem/search.test.ts @@ -0,0 +1,156 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Effect } from "effect" +import { Fff } from "#fff" +import { Search } from "@opencode-ai/core/filesystem/search" +import { testEffect } from "../lib/effect" + +const it = testEffect(Search.defaultLayer) + +const tmpdir = (init?: (dir: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))), + (dir) => + Effect.promise(() => + fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ).pipe(Effect.ignore), + ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) + +const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) + +describe("file.search", () => { + it.live("uses fff for Bun-backed grep", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe("src/match.ts") + }), + ) + + it.live("keeps fuzzy file abbreviation matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "README.md"), "hello\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 }) + + expect(results).toContain("README.md") + }), + ) + + it.live("keeps empty file query candidates", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "README.md"), "hello\n") + yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" }) + + expect(results).toContain("README.md") + expect(results).toContain("src/") + expect(results).not.toContain("") + }), + ) + + it.live("keeps paging grep results without an explicit limit", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write( + path.join(dir, "matches.txt"), + Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""), + ) + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle" }) + + expect(result.items).toHaveLength(150) + }), + ) + + it.live("uses byte ranges for UTF-8 grep submatches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "unicode.txt"), "éneedle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 }) + + expect(result.items[0]?.submatches[0]?.match.text).toBe("needle") + }), + ) + + it.live("post-filters fff grep include matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "needle\n") + yield* write(path.join(dir, "src", "match.txt"), "needle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"]) + }), + ) + + it.live("keeps fff grep include no-match results", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "needle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items).toEqual([]) + }), + ) + + it.live("post-filters fff glob matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n") + yield* write(path.join(dir, "src", "match.txt"), "hello\n") + + const search = yield* Search.Service + const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 }) + + expect(result.files).toEqual([path.join(dir, "src", "match.ts")]) + }), + ) + + it.live("tracks an opened file against its originating query", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n") + yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 }) + expect(results).toContain("alpha-target-two.ts") + + // open() records the query->file association in fff's history db via the + // live picker. It must resolve a remembered file and run without error. + yield* search.open({ cwd: dir, file: "alpha-target-two.ts" }) + }), + ) +}) diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts new file mode 100644 index 000000000000..30834fe18ae6 --- /dev/null +++ b/packages/opencode/script/bench-search.ts @@ -0,0 +1,116 @@ +import { Effect } from "effect" +import { Fff } from "@opencode-ai/core/filesystem/fff.bun" +import { AppRuntime } from "@/effect/app-runtime" +import { Search } from "@opencode-ai/core/filesystem/search" +import { InstanceStore } from "@/project/instance-store" + +const dir = process.cwd() + +const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"] +const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"] +const GLOB_QUERIES = ["**/*.test.ts"] + +const FILE_LIMIT = 100 +const GREP_LIMIT = 50 +const GLOB_LIMIT = 50 + +const run = (effect: Effect.Effect) => + AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)), + ) as Promise + +// --- raw Fff picker --- +const t0 = performance.now() +const made = Fff.create({ basePath: dir, aiMode: true }) +if (!made.ok) { + console.error("Fff.create failed:", made.error) + process.exit(1) +} +const picker = made.value +console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`) + +const tw = performance.now() +await picker.waitForScan(2_500) +console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`) + +// warmup grep to let the content index build +const tWarmup = performance.now() +picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 }) +console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- raw picker (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`) +} + +picker.destroy() + +// --- Ripgrep service (via Search with file:["."] to force rg path) --- +console.log() +console.log("--- Ripgrep (via Search service) ---") + +// warmup +await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] }))) + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] }))) + console.log( + `[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`, + ) +} + +// --- Search service: init breakdown --- +console.log() + +// 1) runtime + InstanceState + picker create + scan poll +const tRuntime = performance.now() +await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 }))) +console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`) + +// 2) grep warmup (content index cold-start inside the Search service picker) +const tGrepWarmup = performance.now() +await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 }))) +console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- Search service (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT }))) + console.log( + `[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`, + ) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT }))) + console.log( + `[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`, + ) +} + +for (const q of GLOB_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT }))) + console.log( + `[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`, + ) +} + +process.exit(0) + diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 173264671b64..afbcf69ad58f 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -2,7 +2,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" @@ -68,7 +68,7 @@ const FileTreeCommand = effectCmd({ default: process.cwd(), }), handler: Effect.fn("Cli.debug.file.tree")(function* (args) { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) console.log(JSON.stringify(tree, null, 2)) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 4f6907db85da..190c2d53fc95 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { InstanceRef } from "@/effect/instance-ref" @@ -22,7 +22,7 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) }), }) @@ -47,8 +47,8 @@ const FilesCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.files")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const rg = yield* Ripgrep.Service - const files = yield* rg + const search = yield* Search.Service + const files = yield* search .files({ cwd: ctx.directory, glob: args.glob ? [args.glob] : undefined, @@ -85,7 +85,7 @@ const SearchCommand = effectCmd({ const ctx = yield* InstanceRef if (!ctx) return const results = yield* Effect.orDie( - Ripgrep.Service.use((svc) => + Search.Service.use((svc) => svc.search({ cwd: ctx.directory, pattern: args.pattern, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 0df1ba2f2782..16fe1a88cf01 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -345,21 +345,12 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] - // Add file options + // Add file options. Trust the order returned by fff (frecency, fuzzy + // score, filename bonus, etc. are already factored in). if (!result.error && result.data) { - const sortedFiles = result.data.sort((a, b) => { - const aScore = frecency.getFrecency(a) - const bScore = frecency.getFrecency(b) - if (aScore !== bScore) return bScore - aScore - const aDepth = a.split("/").length - const bDepth = b.split("/").length - if (aDepth !== bDepth) return aDepth - bDepth - return a.localeCompare(b) - }) - const width = props.anchor().width - 4 options.push( - ...sortedFiles.map((item): AutocompleteOption => { + ...result.data.map((item): AutocompleteOption => { const { filename, url, part } = createFilePart(item, lineRange) const isDir = item.endsWith("/") @@ -506,45 +497,49 @@ export function Autocomplete(props: { const agentsValue = agents() const referenceAliasesValue = referenceAliases() const commandsValue = commands() + const searchValue = search() - const mixed: AutocompleteOption[] = - store.visible === "@" - ? referenceMatchValue - ? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) - : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] - : [...commandsValue] + // @/... — narrow to the matched reference, files come from fff + // already ranked so there is no re-ranking here. + if (store.visible === "@" && referenceMatchValue) { + return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) + } - const searchValue = search() + // Files come from fff already fuzzy ranked and filtered + // it shouldn't be additionally sorted by fuzzysort as it will loose the results + const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] + const nonFileOptions: AutocompleteOption[] = + store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] if (!searchValue) { - return mixed + return [...nonFileOptions, ...fileOptions] } if (files.loading && prev && prev.length > 0) { return prev } - if (referenceMatchValue) return mixed - - const result = fuzzysort.go(removeLineRange(searchValue), mixed, { - keys: [ - (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), - "description", - (obj) => obj.aliases?.join(" ") ?? "", - ], - limit: 10, - scoreFn: (objResults) => { - const displayResult = objResults[0] - let score = objResults.score - if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) { - score *= 2 - } - const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 - return score * (1 + frecencyScore) - }, - }) + const fuzziedNonFiles = fuzzysort + .go(removeLineRange(searchValue), nonFileOptions, { + keys: [ + (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), + "description", + (obj) => obj.aliases?.join(" ") ?? "", + ], + limit: 10, + scoreFn: (objResults) => { + const displayResult = objResults[0] + let score = objResults.score + if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) { + score *= 2 + } + const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 + return score * (1 + frecencyScore) + }, + }) + .map((arr) => arr.obj) - return result.map((arr) => arr.obj) + return [...fuzziedNonFiles, ...fileOptions].slice(0, 10) }) createEffect(() => { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3b34bcc4d883..d5b163f38443 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -9,6 +9,7 @@ import { Account } from "@/account/account" import { Config } from "@/config/config" import { Git } from "@/git" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" @@ -62,6 +63,7 @@ export const AppLayer = Layer.mergeAll( Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, + Search.defaultLayer, Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 52fe4cc664c3..ccbf6ad49deb 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -5,7 +5,9 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { InstanceState } from "@/effect/instance-state" +import { registerDisposer } from "@/effect/instance-registry" import { ShareNext } from "@/share/share-next" +import { Search } from "@opencode-ai/core/filesystem/search" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" @@ -26,15 +28,25 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const project = yield* Project.Service const reference = yield* Reference.Service + const search = yield* Search.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service + // once we dispose the service - also release all the internal fff resources + const off = registerDisposer((directory) => Effect.runPromise(search.release(directory))) + yield* Effect.addFinalizer(() => Effect.sync(off)) + const run = Effect.gen(function* () { const ctx = yield* InstanceState.context yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) // everything depends on config so eager load it for nice traces yield* config.get() + // in 99% of use cases user that is opened opencode at certain directory will + // conduct a file search in this direcotry, it could be switched later but + // mostly always we will need a file picker for cwd + // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated + yield* search.warm(ctx.directory).pipe(Effect.ignore) // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() // Each service self-manages its own slow work via Effect.forkScoped against @@ -58,6 +70,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Plugin.defaultLayer, Project.defaultLayer, Reference.defaultLayer, + Search.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 331fc789e302..07f4598a6870 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -2,6 +2,7 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Effect, Layer } from "effect" @@ -12,6 +13,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service + const search = yield* Search.Service const locations = yield* LocationServiceMap const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { @@ -29,11 +31,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } }) { + const directory = (yield* InstanceState.context).directory + const limit = ctx.query.limit ?? 10 + const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all") + // Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back + // to the ripgrep-backed FileSystem.find when fff is unavailable. + const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie) + if (fff !== undefined) return fff return (yield* filesystem( FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, - limit: ctx.query.limit ?? 10, + limit, type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined), }), ), @@ -91,4 +100,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer)) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 8dfb741031b1..a88bdc2f334e 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -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"), } diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt index 627da6cae9d7..9c01f3d50f9a 100644 --- a/packages/opencode/src/tool/glob.txt +++ b/packages/opencode/src/tool/glob.txt @@ -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. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 2d161d57a0e8..bf4f0520593f 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -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: { diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695aef..c075da1e6b46 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -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 diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index be6557e3c6c6..7aa12659792a 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -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)) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 68b3245233d1..2e90c9b074d1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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)), diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 7a3c02a5bad8..3570904d1551 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -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), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cb9694b56dbd..1abfeaf5eeab 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -48,7 +48,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" 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 "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" @@ -196,7 +196,7 @@ function makePrompt(input?: { processor?: "blocking" }) { Layer.provide(RepositoryCache.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5b86168ea998..0625ba54db62 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -58,7 +58,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import { FSUtil } from "@opencode-ai/core/fs-util" 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 "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" @@ -142,7 +142,7 @@ function makeHttp() { Layer.provide(RepositoryCache.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 6c5890edf6ce..53206c8f405a 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" 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 { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Truncate } from "@/tool/truncate" @@ -17,6 +17,7 @@ import { RepositoryCache } from "@/reference/repository-cache" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" +import { Filesystem } from "@/util/filesystem" import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" @@ -31,7 +32,7 @@ const toolLayer = (flags: Partial = {}) => Layer.mergeAll( CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, Git.defaultLayer, @@ -40,6 +41,7 @@ const toolLayer = (flags: Partial = {}) => const it = testEffect(toolLayer()) const references = testEffect(toolLayer({ experimentalReferences: true })) +const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const ctx = { sessionID: SessionID.make("ses_test"), @@ -172,7 +174,7 @@ describe("tool.glob", () => { ) expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(cache, "src", "index.ts")) + expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts"))) expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() }), { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 2517da798ed0..5b9736d19bbe 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" @@ -34,7 +34,7 @@ const toolLayer = (flags: Partial = {}) => Layer.mergeAll( CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, Git.defaultLayer, diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fe9cd0680614..da7aabdc9a36 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Search } from "@opencode-ai/core/filesystem/search" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" @@ -59,6 +60,7 @@ const readLayer = (flags: Partial = {}) => Instruction.defaultLayer, LSP.defaultLayer, referenceLayer(flags), + Search.defaultLayer, Truncate.defaultLayer, ) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d536ef2b01fa..3b2a34d061d7 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { Format } from "@/format" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" @@ -69,7 +69,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) => Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Truncate.defaultLayer), ) .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))