feat: support reference file mentions

This commit is contained in:
Shoubhit Dash
2026-05-09 13:58:13 +05:30
parent b2baddcd37
commit 8318b686cb
11 changed files with 370 additions and 109 deletions
+3 -23
View File
@@ -1,4 +1,5 @@
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import z from "zod"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
@@ -27,9 +28,6 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@/util/schema"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
@@ -303,25 +301,7 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
function referencePath(value: string) {
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
return path.isAbsolute(value)
? value
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
}
function resolveReference(reference: ReferenceEntry): ResolvedReference {
if (typeof reference === "string") {
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
return { kind: "local", path: referencePath(reference) }
}
return { kind: "git", repository: reference }
}
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
return { kind: "git", repository: reference.repository, branch: reference.branch }
}
function referencePrompt(name: string, reference: ResolvedReference) {
function referencePrompt(name: string, reference: ConfigReference.Resolved) {
if (reference.kind === "local") {
return [
PROMPT_SCOUT,
@@ -343,7 +323,7 @@ export const layer = Layer.effect(
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
if (agents[name]) continue
const resolved = resolveReference(reference)
const resolved = ConfigReference.resolve(reference, ctx)
const localPath = resolved.kind === "local" ? resolved.path : undefined
agents[name] = {
name,
+90
View File
@@ -1,8 +1,11 @@
export * as ConfigReference from "./reference"
import { Schema } from "effect"
import { Global } from "@opencode-ai/core/global"
import { zod } from "@/util/effect-zod"
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
import { withStatics } from "@/util/schema"
import path from "path"
const Git = Schema.Struct({
repository: Schema.String.annotate({
@@ -25,3 +28,90 @@ export const Info = Schema.Record(Schema.String, Entry)
.annotate({ identifier: "ReferenceConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export type Entry = Schema.Schema.Type<typeof Entry>
export type Resolved = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
export const URL_PROTOCOL = "opencode-reference:"
type Context = {
directory: string
worktree: string
}
function referencePath(value: string, ctx: Context) {
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
}
function cleanSubpath(value: string) {
return value.replace(/\\/g, "/").replace(/^\/+/, "")
}
function safeJoin(root: string, subpath: string) {
const filepath = path.resolve(root, cleanSubpath(subpath))
const relative = path.relative(root, filepath)
if (relative === "") return filepath
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return
return filepath
}
export function resolve(reference: Entry, ctx: Context): Resolved {
if (typeof reference === "string") {
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
return { kind: "local", path: referencePath(reference, ctx) }
}
return { kind: "git", repository: reference }
}
if ("path" in reference) return { kind: "local", path: referencePath(reference.path, ctx) }
return { kind: "git", repository: reference.repository, branch: reference.branch }
}
export function parseFilePath(value: string, references: Info | undefined) {
const names = Object.keys(references ?? {}).toSorted((a, b) => b.length - a.length)
for (const name of names) {
if (value.startsWith(`${name}:/`)) return { name, path: cleanSubpath(value.slice(name.length + 2)) }
if (value.startsWith(`${name}/`)) return { name, path: cleanSubpath(value.slice(name.length + 1)) }
}
}
export function formatFilePath(name: string, subpath: string) {
const cleaned = cleanSubpath(subpath)
return `${name}:/${cleaned}`
}
export function resolveFilePath(input: { value: string; references: Info | undefined; ctx: Context }) {
const parsed = parseFilePath(input.value, input.references)
if (!parsed) return
const entry = input.references?.[parsed.name]
if (!entry) return
const resolved = resolve(entry, input.ctx)
const root =
resolved.kind === "local"
? resolved.path
: (() => {
const reference = parseRepositoryReference(resolved.repository)
if (!reference) return
return repositoryCachePath(reference)
})()
if (!root) return
const filepath = safeJoin(root, parsed.path)
if (!filepath) return
return { ...parsed, filepath, reference: resolved, root }
}
export function fileUrl(name: string, subpath: string) {
return `opencode-reference://${encodeURIComponent(name)}/${cleanSubpath(subpath)
.split("/")
.map(encodeURIComponent)
.join("/")}`
}
export function filePathFromUrl(url: URL) {
if (url.protocol !== URL_PROTOCOL) return
return formatFilePath(decodeURIComponent(url.hostname), decodeURIComponent(url.pathname.slice(1)))
}
+87 -44
View File
@@ -1,4 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { ConfigReference } from "@/config/reference"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -278,6 +279,14 @@ const mime: Record<string, string> = {
}
type Entry = { files: string[]; dirs: string[] }
type CachedEntry = { entry: Entry; time: number }
export type SearchInput = {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
references?: ConfigReference.Info
}
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
@@ -314,6 +323,23 @@ const sortHiddenLast = (items: string[], prefer: boolean) => {
return [...visible, ...hiddenItems]
}
function searchEntry(entry: Entry, input: SearchInput) {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return entry.files.slice(0, limit)
return sortHiddenLast(entry.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items = kind === "file" ? entry.files : kind === "directory" ? entry.dirs : [...entry.files, ...entry.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
return kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
}
interface State {
cache: Entry
}
@@ -323,12 +349,7 @@ export interface Interface {
readonly status: () => Effect.Effect<Info[]>
readonly read: (file: string) => Effect.Effect<Content>
readonly list: (dir?: string) => Effect.Effect<Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
readonly search: (input: SearchInput) => Effect.Effect<string[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
@@ -340,6 +361,7 @@ export const layer = Layer.effect(
const rg = yield* Ripgrep.Service
const git = yield* Git.Service
const scope = yield* Scope.Scope
const referenceCache = new Map<string, CachedEntry>()
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
@@ -349,6 +371,38 @@ export const layer = Layer.effect(
),
)
const scanDirectory = Effect.fn("File.scanDirectory")(function* (cwd: string) {
const files = yield* rg.files({ cwd }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const seen = new Set<string>()
const entry: Entry = { files: [], dirs: [] }
for (const file of files) {
entry.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
entry.dirs.push(dir + "/")
}
}
return entry
})
const scanReference = Effect.fn("File.scanReference")(function* (cwd: string) {
const cached = referenceCache.get(cwd)
if (cached && Date.now() - cached.time < 30_000) return cached.entry
const entry = yield* scanDirectory(cwd)
referenceCache.set(cwd, { entry, time: Date.now() })
return entry
})
const scan = Effect.fn("File.scan")(function* () {
const ctx = yield* InstanceState.context
if (ctx.directory === path.parse(ctx.directory).root) return
@@ -379,24 +433,9 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const seen = new Set<string>()
for (const file of files) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
const scanned = yield* scanDirectory(ctx.directory)
next.files = scanned.files
next.dirs = scanned.dirs
}
const s = yield* InstanceState.get(state)
@@ -613,32 +652,36 @@ export const layer = Layer.effect(
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
const searchReference = Effect.fn("File.searchReference")(function* (input: SearchInput) {
const ctx = yield* InstanceState.context
const parsed = ConfigReference.parseFilePath(input.query.trim(), input.references)
if (!parsed) return
const root = ConfigReference.resolveFilePath({
value: ConfigReference.formatFilePath(parsed.name, ""),
references: input.references,
ctx,
})
if (!root) return []
if (!(yield* appFs.isDir(root.filepath).pipe(Effect.orElseSucceed(() => false)))) return []
const entry = yield* scanReference(root.filepath).pipe(Effect.orElseSucceed(() => ({ files: [], dirs: [] })))
return searchEntry(entry, { ...input, query: parsed.path }).map((item) =>
ConfigReference.formatFilePath(parsed.name, item),
)
})
const search = Effect.fn("File.search")(function* (input: SearchInput) {
const reference = yield* searchReference(input)
if (reference) return reference
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 })
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return cache.files.slice(0, limit)
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
const output = searchEntry(cache, input)
log.info("search", { query, kind, results: output.length })
return output
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "@/config/config"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { LSP } from "@/lsp/lsp"
@@ -70,12 +71,14 @@ export const FileRoutes = lazy(() =>
async (c) =>
jsonRequest("FileRoutes.findFile", c, function* () {
const query = c.req.valid("query")
const config = yield* Config.Service
const svc = yield* File.Service
return yield* svc.search({
query: query.query,
limit: query.limit ?? 10,
dirs: query.dirs !== "false",
type: query.type,
references: (yield* config.get()).reference,
})
}),
)
@@ -1,4 +1,5 @@
import * as InstanceState from "@/effect/instance-state"
import { Config } from "@/config/config"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Effect } from "effect"
@@ -8,6 +9,7 @@ import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const config = yield* Config.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
@@ -24,6 +26,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
references: (yield* config.get()).reference,
})
})
+30 -6
View File
@@ -32,6 +32,7 @@ import { Command } from "../command"
import { pathToFileURL, fileURLToPath } from "url"
import { Config } from "@/config/config"
import { ConfigMarkdown } from "@/config/markdown"
import { ConfigReference } from "@/config/reference"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/core/util/error"
import { SessionProcessor } from "./processor"
@@ -1072,9 +1073,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the
]
}
break
case "file:": {
case "file:":
case ConfigReference.URL_PROTOCOL: {
log.info("file", { mime: part.mime })
const filepath = fileURLToPath(part.url)
const referenceFile = ConfigReference.filePathFromUrl(url)
const resolvedReference = referenceFile
? ConfigReference.resolveFilePath({
value: referenceFile,
references: (yield* config.get()).reference,
ctx: yield* InstanceState.context,
})
: undefined
if (url.protocol === ConfigReference.URL_PROTOCOL && !resolvedReference) {
return [
{
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `Reference file not found: ${part.filename ?? part.url}`,
},
]
}
const filepath = resolvedReference?.filepath ?? fileURLToPath(part.url)
const resolvedPart = resolvedReference
? { ...part, url: `${pathToFileURL(filepath).href}${url.search}` }
: part
const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
const { read } = yield* registry.named()
@@ -1099,7 +1123,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let limit: number | undefined
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
if (range.start != null) {
const filePathURI = part.url.split("?")[0]
const filePathURI = resolvedPart.url.split("?")[0]
let start = parseInt(range.start)
let end = range.end ? parseInt(range.end) : undefined
if (start === end) {
@@ -1152,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})),
)
} else {
pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })
pieces.push({ ...resolvedPart, mime, messageID: info.id, sessionID: input.sessionID })
}
} else {
const error = Cause.squash(exit.cause)
@@ -1209,7 +1233,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
synthetic: true,
text: exit.value.output,
},
{ ...part, mime, messageID: info.id, sessionID: input.sessionID },
{ ...resolvedPart, mime, messageID: info.id, sessionID: input.sessionID },
]
}
@@ -1231,7 +1255,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
mime,
filename: part.filename!,
source: part.source,
source: resolvedPart.source,
},
]
}