Compare commits

...
Author SHA1 Message Date
LukeParkerDev 9d5ab28f76 align 2026-04-30 10:21:48 +10:00
LukeParkerDev 442ab7f969 Merge remote-tracking branch 'upstream/dev' into nxl/runtime-aware-search-service 2026-04-30 10:06:06 +10:00
LukeParkerDev f6b1ff77fa bump fff-bun to stop segfaults 2026-04-24 09:00:59 +10:00
Shoubhit DashandGitHub 948c9624a7 Merge branch 'dev' into nxl/runtime-aware-search-service 2026-04-23 16:04:22 +05:30
Shoubhit Dash 690afb5209 Merge remote-tracking branch 'origin/dev' into nxl/runtime-aware-search-service
# Conflicts:
#	packages/opencode/src/tool/read.ts
2026-04-23 12:56:47 +05:30
Shoubhit DashandGitHub d651f37cc4 Merge branch 'dev' into nxl/runtime-aware-search-service 2026-04-22 22:26:46 +05:30
Shoubhit Dash 85e07cec38 feat(search): track file reads for frecency 2026-04-22 22:20:22 +05:30
Shoubhit Dash e6bc596114 fix(file): restore empty query fallback 2026-04-22 22:20:15 +05:30
Shoubhit Dash afa8c69d5b refactor(file): use search service for file queries 2026-04-22 22:19:25 +05:30
Shoubhit Dash e1a908cd1a refactor(search): route tools and routes through search 2026-04-22 22:18:49 +05:30
Shoubhit Dash e874d2c62e feat(search): add runtime-aware search service 2026-04-22 22:18:32 +05:30
Shoubhit Dash 732fb1b945 build(search): add fff-bun runtime shims 2026-04-22 22:18:22 +05:30
opencode-agent[bot] 69b7f3b8db chore: generate 2026-04-22 16:29:44 +00:00
JackandGitHub d61096b60b docs: add MiMo V2.5 to Go pages (#23876) 2026-04-23 00:28:20 +08:00
Steven T. CramerandGitHub a7fafe4c7b fix(project): use git common dir for bare repo project cache (#19054) 2026-04-22 12:02:10 -04:00
opencode-agent[bot] e300209db9 chore: generate 2026-04-22 15:09:51 +00:00
Shoubhit DashandGitHub 85ec11d5d2 fix(session): improve session compaction (#23870) 2026-04-22 20:37:32 +05:30
opencode-agent[bot] 266e965572 chore: generate 2026-04-22 11:07:04 +00:00
Shoubhit DashandGitHub 2a480a9e06 fix(tui): fail fast on invalid session startup (#23837) 2026-04-22 16:35:13 +05:30
Brendan AllanandGitHub 88c5f6bb19 fix: consolidate project avatar source logic (#23819) 2026-04-22 17:09:00 +08:00
opencode-agent[bot] 6aa475fcac chore: generate 2026-04-22 08:18:44 +00:00
Luke ParkerandGitHub 0595c28904 test: fix cross-spawn stderr race on Windows CI (#23808) 2026-04-22 08:17:35 +00:00
opencode-agent[bot] bfb954e711 chore: generate 2026-04-22 08:06:06 +00:00
Luke ParkerandGitHub bb696485b6 fix: preserve BOM in text tool round-trips (#23797) 2026-04-22 08:03:34 +00:00
opencode-agent[bot] ed3d364ebd chore: update nix node_modules hashes 2026-04-22 06:13:39 +00:00
Brendan AllanandGitHub a45d9a9b0a fix(app): improve icon override handling in project edit dialog (#23768) 2026-04-22 13:36:20 +08:00
Luke ParkerandGitHub 69e2f3b7ba chore: bump Bun to 1.3.13 (#23791) 2026-04-22 05:18:51 +00:00
Aiden ClineandGitHub 97f3c746f3 feat: update codex plugin to support 5.5 (#23789) 2026-04-22 00:35:48 -04:00
22 changed files with 922 additions and 116 deletions
+45
View File
@@ -380,6 +380,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.6.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
@@ -1193,6 +1194,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.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IiLrUeVL+6oeHlzyBGp6ACrb4j7vl66/GXtkoSASVtXJGt5J3IH9m9OvQTwwBxrQtPSgb0tI7TvdYPX0RGH9Qg=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-COd27jhKVKIsLT3krQ2DrOkgMYJ5jPSnRLrbarwtYe5jVbayKR6N4wUhtGF3VXWnNoYTNstq2uBYcHphOp5AkQ=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-E2UWCgpBwCPWaupHnn7RAmHjyCyEmKwruaMyigTjI2tg6gXYvoAIjNR+TGsKkTqGl0B1BmP0sD8Uh65K5koxTQ=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-t9K3NzYkBxK2UYp6Xy7Lac/RAL+cWa+78HcRNI693k0U9c6G65J2/cVn5SVOiJ1EcnEnC3MCuPsCEmj/9d3Pqw=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tNSYpZok9HpXZoAlTEWbJF0ZQtZKZNmpyU2WfVRa3WhsPZ/ej+YUuPEPI83OYRqxy4F0OVtGYCqj7dos/AVSLg=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GHJhJ3P7cGth4F0VTyoe3maFQT0cW+RbMm7R5XKkNZZW2rNtR+4jo+neAU4gchYH6jv7ajCB5HQdNBcou8zxKA=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+wmwbeoAvkce2ZduY1FSjzMnmE4LNJDpXa1q++mTz+/LsUvjPBl1dSchvXxuyLzKAqhigHpJ1k9Xull0iIDUjQ=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-OOEXudgFtkXS5iF+6JoawCMpK76xg0NodUhsc2bhTKXJP8DjfQhU/ARpxoPFiX7uwAy1/xaPjbaT5dfP16TaCg=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.6.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.6.4", "@ff-labs/fff-bin-darwin-x64": "0.6.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.6.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.6.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.6.4", "@ff-labs/fff-bin-linux-x64-musl": "0.6.4", "@ff-labs/fff-bin-win32-arm64": "0.6.4", "@ff-labs/fff-bin-win32-x64": "0.6.4" }, "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff-demo": "examples/search.ts", "fff-grep": "examples/grep.ts" } }, "sha512-f9k0W1zTbYX0kA+duWobeXjvnllPr9B1DyleXq/ZSz/9Hvz8Gf85V/Kad7Fi2WXenuscacdq8Asivuy4wKbFlQ=="],
"@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=="],
@@ -1639,6 +1658,30 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA=="],
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
@@ -2725,6 +2768,8 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"bun": ["bun@1.3.13", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.13", "@oven/bun-darwin-x64": "1.3.13", "@oven/bun-darwin-x64-baseline": "1.3.13", "@oven/bun-linux-aarch64": "1.3.13", "@oven/bun-linux-aarch64-musl": "1.3.13", "@oven/bun-linux-x64": "1.3.13", "@oven/bun-linux-x64-baseline": "1.3.13", "@oven/bun-linux-x64-musl": "1.3.13", "@oven/bun-linux-x64-musl-baseline": "1.3.13", "@oven/bun-windows-aarch64": "1.3.13", "@oven/bun-windows-x64": "1.3.13", "@oven/bun-windows-x64-baseline": "1.3.13" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
+6
View File
@@ -29,6 +29,11 @@
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
"#fff": {
"bun": "./src/file/fff.bun.ts",
"node": "./src/file/fff.node.ts",
"default": "./src/file/fff.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
@@ -102,6 +107,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.6.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
+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 -2
View File
@@ -8,7 +8,7 @@ import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { Search } from "@/file/search"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
@@ -57,7 +57,7 @@ export const AppLayer = Layer.mergeAll(
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
Search.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
+85
View File
@@ -0,0 +1,85 @@
import {
FileFinder,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type InitOptions,
type SearchResult,
} from "@ff-labs/fff-bun"
export type Result<T> = { 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 type File = FileItem
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
waitForScan(timeout?: number): Result<boolean>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
waitForScan: (timeout) => pick.waitForScan(timeout),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(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"
+85
View File
@@ -0,0 +1,85 @@
export type Result<T> = { 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
}
export interface File {
relativePath: string
fileName: string
modified: number
}
export interface Search {
items: File[]
scores: unknown[]
totalMatched: number
totalFiles: 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
waitForScan(timeout?: number): Result<boolean>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return false
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable" }
}
export * as Fff from "./fff.node"
+38 -17
View File
@@ -13,7 +13,7 @@ import { Global } from "@opencode-ai/core/global"
import { Instance } from "../project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { Search } from "./search"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
@@ -337,7 +337,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const searchSvc = yield* Search.Service
const git = yield* Git.Service
const scope = yield* Scope.Scope
@@ -379,7 +379,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
const files = yield* searchSvc.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -506,6 +506,7 @@ export const layer = Layer.effect(
using _ = log.time("read", { file })
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
const trackOpen = searchSvc.open({ cwd: ctx.directory, file }).pipe(Effect.ignore)
if (!Instance.containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
@@ -513,21 +514,23 @@ export const layer = Layer.effect(
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
if (exists) {
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
content: Buffer.from(bytes).toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64" as const,
}
if (!exists) return { type: "text" as const, content: "" }
yield* trackOpen
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
content: Buffer.from(bytes).toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64" as const,
}
return { type: "text" as const, content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
if (isBinaryByExtension(file) && !knownText) {
yield* trackOpen
return { type: "binary" as const, content: "" }
}
const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" }
@@ -538,6 +541,7 @@ export const layer = Layer.effect(
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
if (encode) {
yield* trackOpen
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
@@ -558,6 +562,7 @@ export const layer = Layer.effect(
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
yield* trackOpen
const original = yield* git.show(ctx.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
@@ -565,9 +570,11 @@ export const layer = Layer.effect(
})
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
}
yield* trackOpen
return { type: "text" as const, content }
}
yield* trackOpen
return { type: "text" as const, content }
})
@@ -619,14 +626,28 @@ export const layer = Layer.effect(
dirs?: boolean
type?: "file" | "directory"
}) {
yield* ensure()
const { cache } = yield* InstanceState.get(state)
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
if (query && kind === "file") {
const files = yield* searchSvc.file({
cwd: Instance.directory,
query,
limit,
}).pipe(Effect.orDie)
if (files === undefined) {
log.info("search", { query, kind, mode: "cache" })
} else {
log.info("search", { query, kind, results: files.length, mode: "fff" })
return files
}
}
yield* ensure()
const { cache } = yield* InstanceState.get(state)
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
@@ -650,7 +671,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
+473
View File
@@ -0,0 +1,473 @@
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Glob } from "@opencode-ai/core/util/glob"
import { Context, Deferred, Effect, Layer, Option } from "effect"
import * as Stream from "effect/Stream"
import z from "zod"
import * as InstanceState from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export const Match = z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
})
export type Item = z.infer<typeof Match>
export interface Result {
readonly items: Ripgrep.Item[]
readonly partial: boolean
readonly engine: "fff" | "ripgrep"
readonly regexFallbackError?: string
}
export interface FileInput {
readonly cwd: string
readonly query: string
readonly limit?: number
readonly current?: string
}
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[]
}
interface State {
readonly pick: Map<string, Fff.Picker>
readonly wait: Map<string, Deferred.Deferred<Fff.Picker, Error>>
readonly recent: Query[]
}
export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, unknown>
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, unknown>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, unknown>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
function key(dir: string) {
return Buffer.from(dir).toString("base64url")
}
function normalize(text: string) {
return text.replaceAll("\\", "/")
}
function blocked(rel: string) {
return normalize(rel).split("/").includes(".git")
}
function basename(file: string) {
return normalize(file).split("/").at(-1) ?? file
}
function allow(glob: string[] | undefined, rel: string, file: string) {
if (!glob?.length) return true
const include = glob.filter((item) => !item.startsWith("!"))
const exclude = glob.filter((item) => item.startsWith("!")).map((item) => item.slice(1))
if (include.length > 0 && !include.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
if (exclude.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
return true
}
function include(pattern: string) {
const value = pattern.trim().replaceAll("\\", "/")
if (!value) return "*"
const flat = value.replaceAll("**/", "").replaceAll("/**", "/")
const idx = flat.lastIndexOf("/")
if (idx < 0) return flat
const dir = flat.slice(0, idx + 1)
const glob = flat.slice(idx + 1)
if (!glob) return dir
return `${dir} ${glob}`
}
function remember(state: State, dir: string, text: string, files: string[]) {
if (!files.length) return
const next = Array.from(new Set(files.map(AppFileSystem.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)),
}
}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Ripgrep.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Search.state")(() =>
Effect.gen(function* () {
const next = {
pick: new Map<string, Fff.Picker>(),
wait: new Map<string, Deferred.Deferred<Fff.Picker, Error>>(),
recent: [] as Query[],
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const pick of next.pick.values()) pick.destroy()
}),
)
return next
}),
),
)
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
const out = yield* rg.search(input)
return {
items: out.items,
partial: out.partial,
engine: "ripgrep" as const,
}
})
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
if (!Fff.available()) return undefined
const dir = AppFileSystem.resolve(cwd)
const current = yield* InstanceState.get(state)
const existing = current.pick.get(dir)
if (existing) return existing
const pending = current.wait.get(dir)
if (pending) return yield* Deferred.await(pending)
const gate = yield* Deferred.make<Fff.Picker, Error>()
current.wait.set(dir, gate)
try {
yield* fs.ensureDir(root)
const id = key(dir)
const made = yield* Effect.sync(() =>
Fff.create({
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
aiMode: true,
}),
)
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 ready = yield* Effect.sync(() => pick.waitForScan(5_000))
if (!ready.ok) {
pick.destroy()
log.warn("fff scan failed", { dir, error: ready.error })
const err = new Error(ready.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
if (!ready.value) {
pick.destroy()
const err = new Error("fff scan timed out")
log.warn("fff scan timed out", { dir })
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const git = yield* Effect.sync(() => pick.refreshGitStatus())
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
current.pick.set(dir, pick)
yield* Deferred.succeed(gate, pick)
return pick
} finally {
if (current.wait.get(dir) === gate) current.wait.delete(dir)
}
})
const files: Interface["files"] = (input) => rg.files(input)
const tree: Interface["tree"] = (input) => rg.tree(input)
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
const query = input.query.trim()
if (!query) return []
const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed<Fff.Picker | undefined>(undefined)))
if (!pick) return undefined
const dir = AppFileSystem.resolve(input.cwd)
const out = yield* Effect.sync(() =>
pick.fileSearch(query, {
currentFile: input.current
? path.isAbsolute(input.current)
? input.current
: path.join(dir, input.current)
: undefined,
pageIndex: 0,
pageSize: Math.max(input.limit ?? 100, 100),
}),
)
if (!out.ok) {
log.warn("fff file search failed", { dir, query, error: out.error })
return undefined
}
const rows: string[] = Array.from(
new Set(
out.value.items.flatMap((item, idx): string[] => {
const score = out.value.scores[idx]
if (!score || score.total <= 0) return []
return [normalize(item.relativePath)]
}),
),
)
const current = yield* InstanceState.get(state)
remember(
current,
dir,
query,
rows.map((row) => path.join(dir, row)),
)
return rows.slice(0, input.limit ?? 100)
})
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).pipe(Effect.catch(() => Effect.succeed<Fff.Picker | undefined>(undefined)))
if (!pick) return yield* rip(input)
const dir = AppFileSystem.resolve(input.cwd)
const rows: Item[] = []
const seen = new Set<string>()
let cursor: Fff.Cursor = null
let regexFallbackError: string | undefined
while (input.limit === undefined || rows.length < input.limit) {
input.signal?.throwIfAborted()
const out = yield* Effect.sync(() =>
pick.grep(input.pattern, {
mode: "regex",
cursor,
maxMatchesPerFile: input.limit ?? 0,
timeBudgetMs: 1_500,
}),
)
if (!out.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: out.error })
return yield* rip(input)
}
regexFallbackError = regexFallbackError ?? out.value.regexFallbackError
for (const hit of out.value.items) {
const rel = normalize(hit.relativePath)
if (!allow(input.glob, rel, normalize(hit.fileName))) continue
const id = `${rel}:${hit.lineNumber}:${hit.byteOffset}`
if (seen.has(id)) continue
seen.add(id)
rows.push(item(hit))
if (input.limit !== undefined && rows.length >= input.limit) break
}
if (!out.value.nextCursor) break
cursor = out.value.nextCursor
}
if (!rows.length && input.glob?.length) return yield* rip(input)
const current = yield* InstanceState.get(state)
remember(
current,
dir,
input.pattern,
Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))),
)
return {
items: rows,
partial: false,
engine: "fff" as const,
regexFallbackError,
}
})
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
input.signal?.throwIfAborted()
const dir = AppFileSystem.resolve(input.cwd)
const limit = input.limit ?? 100
const pick = yield* picker(dir).pipe(Effect.catch(() => Effect.succeed<Fff.Picker | undefined>(undefined)))
if (pick) {
const out = yield* Effect.sync(() =>
pick.fileSearch(include(input.pattern), {
currentFile: path.join(dir, ".opencode"),
pageIndex: 0,
pageSize: Math.max(limit * 4, 200),
}),
)
if (out.ok) {
const rows: string[] = Array.from(
new Map(
out.value.items
.filter((item) => !blocked(item.relativePath))
.filter(
(item) =>
Glob.match(input.pattern, item.relativePath) || Glob.match(input.pattern, basename(item.relativePath)),
)
.map((item) => [normalize(item.relativePath), item.modified] as const),
).entries(),
)
.sort((a, b) => b[1] - a[1])
.map(([file]) => file)
if (rows.length > 0) {
const current = yield* InstanceState.get(state)
remember(
current,
dir,
input.pattern,
rows.map((row) => path.join(dir, row)),
)
return {
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
truncated: rows.length > limit,
}
}
} else {
log.warn("fff glob failed", { dir, pattern: input.pattern, error: out.error })
}
}
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 current = yield* InstanceState.get(state)
const file = input.cwd
? AppFileSystem.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
: AppFileSystem.resolve(input.file)
const idx = current.recent.findIndex((item) => item.files.includes(file))
if (idx < 0) return
const row = current.recent[idx]
current.recent.splice(idx, 1)
const pick = current.pick.get(row.dir)
if (!pick) return
const out = yield* Effect.sync(() => pick.trackQuery(row.text, file))
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 })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(AppFileSystem.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"
+1
View File
@@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
])
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
@@ -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/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.SearchMatch.zod.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,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 { AppFileSystem } from "@opencode-ai/core/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"
@@ -18,8 +17,8 @@ export const Parameters = Schema.Struct({
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,
@@ -46,36 +45,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.)`,
@@ -86,8 +67,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 -32
View File
@@ -1,9 +1,8 @@
import path from "path"
import { Schema } from "effect"
import { Effect, Option } from "effect"
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/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"
@@ -24,7 +23,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,
@@ -64,7 +63,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,
@@ -80,37 +79,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 = ""
@@ -137,6 +112,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: {
+3
View File
@@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { Search } from "../file/search"
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
const DEFAULT_READ_LIMIT = 2000
@@ -40,6 +41,7 @@ export const ReadTool = Tool.define(
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const search = yield* Search.Service
const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@@ -84,6 +86,7 @@ export const ReadTool = Tool.define(
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
yield* search.open({ file: filepath }).pipe(Effect.ignore)
yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
})
+9 -9
View File
@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/search"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { Question } from "../question"
@@ -81,13 +81,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* () {
@@ -339,7 +339,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),
),
)
+3 -3
View File
@@ -2,7 +2,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Ripgrep } from "../file/ripgrep"
import { Search } from "../file/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,
@@ -39,7 +39,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),
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { Fff } from "#fff"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Search } from "../../src/file/search"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const it = testEffect(Layer.mergeAll(Search.defaultLayer, CrossSpawnSpawner.defaultLayer))
function db(dir: string) {
const id = Buffer.from(AppFileSystem.resolve(dir)).toString("base64url")
return {
frecency: path.join(Global.Path.cache, "fff", `${id}.frecency.mdb`),
history: path.join(Global.Path.cache, "fff", `${id}.history.mdb`),
}
}
describe("file.search", () => {
it.live("uses fff for Bun-backed grep", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
yield* Effect.promise(() => Bun.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", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
yield* Effect.promise(() => Bun.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 paging grep results without an explicit limit", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
yield* Effect.promise(() =>
Bun.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", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
yield* Effect.promise(() => Bun.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("records query history when a searched file is opened", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
yield* Effect.promise(() => Bun.write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n"))
yield* Effect.promise(() => Bun.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")
yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
yield* Effect.promise(() => Instance.disposeAll())
const picker = Fff.create({
basePath: dir,
frecencyDbPath: db(dir).frecency,
historyDbPath: db(dir).history,
aiMode: true,
})
expect(picker.ok).toBe(true)
if (!picker.ok) return
const history = picker.value.getHistoricalQuery(0)
picker.value.destroy()
expect(history.ok).toBe(true)
if (!history.ok) return
expect(history.value).toBe("alpha target two")
}),
),
)
})
@@ -39,7 +39,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 "../../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 "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate"
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/core/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,
),
+2
View File
@@ -5,6 +5,7 @@ import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp"
import { Search } from "../../src/file/search"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -40,6 +41,7 @@ const it = testEffect(
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Search.defaultLayer,
Truncate.defaultLayer,
),
)