From 8318b686cbab66afb95dc2f53daed9b99b086252 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 13:58:13 +0530 Subject: [PATCH 1/5] feat: support reference file mentions --- packages/app/src/components/prompt-input.tsx | 7 + .../prompt-input/build-request-parts.test.ts | 27 ++++ .../prompt-input/build-request-parts.ts | 79 ++++++----- packages/opencode/src/agent/agent.ts | 26 +--- packages/opencode/src/config/reference.ts | 90 ++++++++++++ packages/opencode/src/file/index.ts | 131 ++++++++++++------ .../src/server/routes/instance/file.ts | 3 + .../routes/instance/httpapi/handlers/file.ts | 3 + packages/opencode/src/session/prompt.ts | 36 ++++- packages/opencode/test/file/index.test.ts | 25 +++- packages/opencode/test/session/prompt.test.ts | 52 +++++++ 11 files changed, 370 insertions(+), 109 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2417fa98e2..2b3482cb7e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -564,6 +564,9 @@ export const PromptInput: Component = (props) => { .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) + const referenceAgentNames = createMemo(() => + sync.data.agent.filter((agent) => agent.options.reference !== undefined).map((agent) => agent.name), + ) const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { @@ -589,6 +592,9 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: async (query) => { const agents = agentList() + const reference = referenceAgentNames().find( + (name) => query.startsWith(`${name}:/`) || query.startsWith(`${name}/`), + ) const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) @@ -597,6 +603,7 @@ export const PromptInput: Component = (props) => { const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) + if (reference) return fileOptions return [...agents, ...pinned, ...fileOptions] }, key: atKey, diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 06c3773310..e6f7b988c2 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -124,6 +124,33 @@ describe("buildRequestParts", () => { expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true) }) + test("builds reference file URLs for configured repo paths", () => { + const result = buildRequestParts({ + prompt: [{ type: "file", path: "effect:/src/Effect.ts", content: "@effect:/src/Effect.ts", start: 0, end: 22 }], + context: [ + { + key: "ctx:reference-comment", + type: "file", + path: "src/review.ts", + comment: "Compare with @effect:/src/Context.ts.", + }, + ], + images: [], + text: "@effect:/src/Effect.ts", + messageID: "msg_reference", + sessionID: "ses_reference", + sessionDirectory: "/repo", + }) + + const files = result.requestParts.filter((part) => part.type === "file") + expect(files.some((part) => part.type === "file" && part.url === "opencode-reference://effect/src/Effect.ts")).toBe( + true, + ) + expect( + files.some((part) => part.type === "file" && part.url === "opencode-reference://effect/src/Context.ts"), + ).toBe(true) + }) + test("handles Windows paths correctly (simulated on macOS)", () => { const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 98771aedd1..dc3d4498c4 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -41,6 +41,22 @@ const fileQuery = (selection: FileSelection | undefined) => const mention = /(^|[\s([{"'])@(\S+)/g +const referencePath = (value: string) => { + const match = value.match(/^([^:/\\]+):\/(.*)$/) + if (!match) return + if (/^[A-Za-z]$/.test(match[1]!)) return + return { name: match[1]!, path: match[2] ?? "" } +} + +const referenceUrl = (value: string, selection?: FileSelection) => { + const reference = referencePath(value) + if (!reference) return + return `opencode-reference://${encodeURIComponent(reference.name)}/${reference.path + .split("/") + .map(encodeURIComponent) + .join("/")}${fileQuery(selection)}` +} + const parseCommentMentions = (comment: string) => { return Array.from(comment.matchAll(mention)).flatMap((match) => { const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "") @@ -97,25 +113,34 @@ export function buildRequestParts(input: BuildRequestPartsInput) { }, ] - const files = input.prompt.filter(isFileAttachment).map((attachment) => { - const path = absolute(input.sessionDirectory, attachment.path) + const filePart = (file: string, selection?: FileSelection, source?: FileAttachmentPart) => { + const url = referenceUrl(file, selection) + const filepath = url ? file : absolute(input.sessionDirectory, file) return { id: Identifier.ascending("part"), type: "file", mime: "text/plain", - url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, - filename: getFilename(attachment.path), - source: { - type: "file", - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path, - }, + url: url ?? `file://${encodeFilePath(filepath)}${fileQuery(selection)}`, + filename: getFilename(file), + ...(source + ? { + source: { + type: "file" as const, + text: { + value: source.content, + start: source.start, + end: source.end, + }, + path: filepath, + }, + } + : {}), } satisfies PromptRequestPart - }) + } + + const files = input.prompt + .filter(isFileAttachment) + .map((attachment) => filePart(attachment.path, attachment.selection, attachment)) const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { return { @@ -133,34 +158,20 @@ export function buildRequestParts(input: BuildRequestPartsInput) { const used = new Set(files.map((part) => part.url)) const context = input.context.flatMap((item) => { const path = absolute(input.sessionDirectory, item.path) - const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` + const url = referenceUrl(item.path, item.selection) ?? `file://${encodeFilePath(path)}${fileQuery(item.selection)}` const comment = item.comment?.trim() if (!comment && used.has(url)) return [] used.add(url) - const filePart = { - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(item.path), - } satisfies PromptRequestPart + const contextFilePart = filePart(item.path, item.selection) - if (!comment) return [filePart] + if (!comment) return [contextFilePart] const mentions = parseCommentMentions(comment).flatMap((path) => { - const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}` + const url = referenceUrl(path) ?? `file://${encodeFilePath(absolute(input.sessionDirectory, path))}` if (used.has(url)) return [] used.add(url) - return [ - { - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(path), - } satisfies PromptRequestPart, - ] + return [filePart(path)] }) return [ @@ -177,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { origin: item.commentOrigin, }), } satisfies PromptRequestPart, - filePart, + contextFilePart, ...mentions, ] }) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8584682412..615d16d48f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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[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, diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1..22020f2e8b 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -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 + +export type Entry = Schema.Schema.Type +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))) +} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a..085485c8aa 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -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 = { } 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 readonly read: (file: string) => Effect.Effect readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect + readonly search: (input: SearchInput) => Effect.Effect } export class Service extends Context.Service()("@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() const state = yield* InstanceState.make( 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() + 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() - 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 diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index d0e9ee6186..e784a34f0e 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -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, }) }), ) 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 98ee5968e0..8318cdd786 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -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, }) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c43836..bfb917afae 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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, }, ] } diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cdd2e211c2..0662998487 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import { Effect } from "effect" import path from "path" import fs from "fs/promises" +import { ConfigReference } from "@/config/reference" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" @@ -19,8 +20,7 @@ const run = (eff: Effect.Effect) => const status = () => run(File.Service.use((svc) => svc.status())) const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) -const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => - run(File.Service.use((svc) => svc.search(input))) +const search = (input: File.SearchInput) => run(File.Service.use((svc) => svc.search(input))) describe("file/index Filesystem patterns", () => { describe("read() - text content", () => { @@ -829,6 +829,27 @@ describe("file/index Filesystem patterns", () => { }, }) }) + + test("searches reference files only after reference path prefix", async () => { + await using tmp = await setupSearchableRepo() + await using docs = await tmpdir() + await fs.writeFile(path.join(docs.path, "guide.md"), "guide", "utf-8") + await fs.writeFile(path.join(docs.path, "..guide.md"), "hidden guide", "utf-8") + + const references = { docs: { path: docs.path } } satisfies ConfigReference.Info + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + await init() + + expect(await search({ query: "guide", type: "file", references })).not.toContain("docs:/guide.md") + expect(await search({ query: "docs:/guide", type: "file", references })).toContain("docs:/guide.md") + expect(await search({ query: "docs:/..guide", type: "file", references })).toEqual(["docs:/..guide.md"]) + expect(await search({ query: "docs/", type: "file", references })).toContain("docs:/guide.md") + }, + }) + }) }) describe("read() - diff/patch", () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b3..ee4b2d30c5 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -3,6 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" +import fs from "fs/promises" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -417,6 +418,57 @@ it.live("prompt emits v2 prompted and synthetic events", () => ), ) +it.live("prompt resolves configured reference file URLs", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "reference-docs"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(dir, "reference-docs", "guide.md"), "reference guide")) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "read @docs:/guide.md" }, + { + type: "file", + mime: "text/plain", + filename: "guide.md", + url: "opencode-reference://docs/guide.md", + source: { + type: "file", + path: "docs:/guide.md", + text: { value: "@docs:/guide.md", start: 5, end: 20 }, + }, + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("reference guide") }), + ]), + ) + }), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + reference: { + docs: { path: "./reference-docs" }, + }, + }), + }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { From e515c0c62c6135c3917c53951abeb0e60217eb44 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 14:08:53 +0530 Subject: [PATCH 2/5] fix(tui): preserve reference file mention urls --- .../src/cli/cmd/run/footer.prompt.tsx | 23 +++++++++++++++++-- .../cmd/tui/component/prompt/autocomplete.tsx | 22 +++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 8cd4fbfcf5..742012bff0 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -12,6 +12,7 @@ import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import * as Locale from "@/util/locale" +import { ConfigReference } from "@/config/reference" import { createPromptHistory, isExitCommand, @@ -283,6 +284,19 @@ export function createPromptState(input: PromptInput): PromptState { }, })) }) + const referenceNames = createMemo(() => + input + .agents() + .filter((item) => item.options.reference !== undefined) + .map((item) => item.name) + .toSorted((a, b) => b.length - a.length), + ) + const referenceFilePath = (value: string) => { + for (const name of referenceNames()) { + if (value.startsWith(`${name}:/`)) return { name, path: value.slice(name.length + 2).replace(/\\/g, "/") } + if (value.startsWith(`${name}/`)) return { name, path: value.slice(name.length + 1).replace(/\\/g, "/") } + } + } const resources = createMemo(() => { return input.resources().map((item) => ({ kind: "mention", @@ -331,7 +345,10 @@ export function createPromptState(input: PromptInput): PromptState { return a.localeCompare(b) }) .map((item): Auto => { - const url = pathToFileURL(path.resolve(input.directory, item)) + const reference = referenceFilePath(item) + const url = reference + ? new URL(ConfigReference.fileUrl(reference.name, reference.path)) + : pathToFileURL(path.resolve(input.directory, item)) let filename = item if (next.line && !item.endsWith("/")) { filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}` @@ -366,7 +383,9 @@ export function createPromptState(input: PromptInput): PromptState { }, { initialValue: [] as Auto[] }, ) - const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()]) + const mentionOptions = createMemo(() => + referenceFilePath(removeLineRange(query())) ? files() : [...agents(), ...files(), ...resources()], + ) const slashOptions = createMemo(() => { const builtins = [ { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption, 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 7f390f0eb6..70e9810578 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,6 +18,7 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" +import { ConfigReference } from "@/config/reference" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -225,8 +226,10 @@ export function Autocomplete(props: { function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") - const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) - const urlObj = pathToFileURL(fullPath) + const reference = ConfigReference.parseFilePath(item, sync.data.config.reference) + const urlObj = reference + ? new URL(ConfigReference.fileUrl(reference.name, reference.path)) + : pathToFileURL(path.isAbsolute(item) ? item : path.join(baseDir, item)) const filename = lineRange && !item.endsWith("/") ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` @@ -261,6 +264,9 @@ export function Autocomplete(props: { } function normalizeMentionPath(filePath: string) { + const reference = ConfigReference.parseFilePath(filePath, sync.data.config.reference) + if (reference) return ConfigReference.formatFilePath(reference.name, reference.path) + const baseDir = sync.path.directory || process.cwd() const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -430,11 +436,15 @@ export function Autocomplete(props: { const filesValue = files() const agentsValue = agents() const commandsValue = commands() + const searchValue = search() + const reference = ConfigReference.parseFilePath(removeLineRange(searchValue), sync.data.config.reference) const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] - - const searchValue = search() + store.visible === "@" + ? reference + ? filesValue || [] + : [...agentsValue, ...(filesValue || []), ...mcpResources()] + : [...commandsValue] if (!searchValue) { return mixed @@ -506,7 +516,7 @@ export function Autocomplete(props: { const currentCursorOffset = input.cursorOffset const displayText = selected.display.trimEnd() - const path = displayText.startsWith("@") ? displayText.slice(1) : displayText + const path = selected.path ?? (displayText.startsWith("@") ? displayText.slice(1) : displayText) input.cursorOffset = store.index const startCursor = input.logicalCursor From 32f4f4ba97b43d43ccc85c1a983fd54ecaf9608e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 14:25:07 +0530 Subject: [PATCH 3/5] Revert "fix(tui): preserve reference file mention urls" This reverts commit e515c0c62c6135c3917c53951abeb0e60217eb44. --- .../src/cli/cmd/run/footer.prompt.tsx | 23 ++----------------- .../cmd/tui/component/prompt/autocomplete.tsx | 22 +++++------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 742012bff0..8cd4fbfcf5 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -12,7 +12,6 @@ import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import * as Locale from "@/util/locale" -import { ConfigReference } from "@/config/reference" import { createPromptHistory, isExitCommand, @@ -284,19 +283,6 @@ export function createPromptState(input: PromptInput): PromptState { }, })) }) - const referenceNames = createMemo(() => - input - .agents() - .filter((item) => item.options.reference !== undefined) - .map((item) => item.name) - .toSorted((a, b) => b.length - a.length), - ) - const referenceFilePath = (value: string) => { - for (const name of referenceNames()) { - if (value.startsWith(`${name}:/`)) return { name, path: value.slice(name.length + 2).replace(/\\/g, "/") } - if (value.startsWith(`${name}/`)) return { name, path: value.slice(name.length + 1).replace(/\\/g, "/") } - } - } const resources = createMemo(() => { return input.resources().map((item) => ({ kind: "mention", @@ -345,10 +331,7 @@ export function createPromptState(input: PromptInput): PromptState { return a.localeCompare(b) }) .map((item): Auto => { - const reference = referenceFilePath(item) - const url = reference - ? new URL(ConfigReference.fileUrl(reference.name, reference.path)) - : pathToFileURL(path.resolve(input.directory, item)) + const url = pathToFileURL(path.resolve(input.directory, item)) let filename = item if (next.line && !item.endsWith("/")) { filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}` @@ -383,9 +366,7 @@ export function createPromptState(input: PromptInput): PromptState { }, { initialValue: [] as Auto[] }, ) - const mentionOptions = createMemo(() => - referenceFilePath(removeLineRange(query())) ? files() : [...agents(), ...files(), ...resources()], - ) + const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()]) const slashOptions = createMemo(() => { const builtins = [ { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption, 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 70e9810578..7f390f0eb6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,7 +18,6 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" -import { ConfigReference } from "@/config/reference" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -226,10 +225,8 @@ export function Autocomplete(props: { function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") - const reference = ConfigReference.parseFilePath(item, sync.data.config.reference) - const urlObj = reference - ? new URL(ConfigReference.fileUrl(reference.name, reference.path)) - : pathToFileURL(path.isAbsolute(item) ? item : path.join(baseDir, item)) + const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) + const urlObj = pathToFileURL(fullPath) const filename = lineRange && !item.endsWith("/") ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` @@ -264,9 +261,6 @@ export function Autocomplete(props: { } function normalizeMentionPath(filePath: string) { - const reference = ConfigReference.parseFilePath(filePath, sync.data.config.reference) - if (reference) return ConfigReference.formatFilePath(reference.name, reference.path) - const baseDir = sync.path.directory || process.cwd() const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -436,15 +430,11 @@ export function Autocomplete(props: { const filesValue = files() const agentsValue = agents() const commandsValue = commands() - const searchValue = search() - const reference = ConfigReference.parseFilePath(removeLineRange(searchValue), sync.data.config.reference) const mixed: AutocompleteOption[] = - store.visible === "@" - ? reference - ? filesValue || [] - : [...agentsValue, ...(filesValue || []), ...mcpResources()] - : [...commandsValue] + store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + + const searchValue = search() if (!searchValue) { return mixed @@ -516,7 +506,7 @@ export function Autocomplete(props: { const currentCursorOffset = input.cursorOffset const displayText = selected.display.trimEnd() - const path = selected.path ?? (displayText.startsWith("@") ? displayText.slice(1) : displayText) + const path = displayText.startsWith("@") ? displayText.slice(1) : displayText input.cursorOffset = store.index const startCursor = input.logicalCursor From 53a50d6f3cdbb1f6dc0fcc845d07d088f25e2922 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 14:25:07 +0530 Subject: [PATCH 4/5] Revert "feat: support reference file mentions" This reverts commit 8318b686cbab66afb95dc2f53daed9b99b086252. --- packages/app/src/components/prompt-input.tsx | 7 - .../prompt-input/build-request-parts.test.ts | 27 ---- .../prompt-input/build-request-parts.ts | 79 +++++------ packages/opencode/src/agent/agent.ts | 26 +++- packages/opencode/src/config/reference.ts | 90 ------------ packages/opencode/src/file/index.ts | 131 ++++++------------ .../src/server/routes/instance/file.ts | 3 - .../routes/instance/httpapi/handlers/file.ts | 3 - packages/opencode/src/session/prompt.ts | 36 +---- packages/opencode/test/file/index.test.ts | 25 +--- packages/opencode/test/session/prompt.test.ts | 52 ------- 11 files changed, 109 insertions(+), 370 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2b3482cb7e..2417fa98e2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -564,9 +564,6 @@ export const PromptInput: Component = (props) => { .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const referenceAgentNames = createMemo(() => - sync.data.agent.filter((agent) => agent.options.reference !== undefined).map((agent) => agent.name), - ) const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { @@ -592,9 +589,6 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: async (query) => { const agents = agentList() - const reference = referenceAgentNames().find( - (name) => query.startsWith(`${name}:/`) || query.startsWith(`${name}/`), - ) const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) @@ -603,7 +597,6 @@ export const PromptInput: Component = (props) => { const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - if (reference) return fileOptions return [...agents, ...pinned, ...fileOptions] }, key: atKey, diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index e6f7b988c2..06c3773310 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -124,33 +124,6 @@ describe("buildRequestParts", () => { expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true) }) - test("builds reference file URLs for configured repo paths", () => { - const result = buildRequestParts({ - prompt: [{ type: "file", path: "effect:/src/Effect.ts", content: "@effect:/src/Effect.ts", start: 0, end: 22 }], - context: [ - { - key: "ctx:reference-comment", - type: "file", - path: "src/review.ts", - comment: "Compare with @effect:/src/Context.ts.", - }, - ], - images: [], - text: "@effect:/src/Effect.ts", - messageID: "msg_reference", - sessionID: "ses_reference", - sessionDirectory: "/repo", - }) - - const files = result.requestParts.filter((part) => part.type === "file") - expect(files.some((part) => part.type === "file" && part.url === "opencode-reference://effect/src/Effect.ts")).toBe( - true, - ) - expect( - files.some((part) => part.type === "file" && part.url === "opencode-reference://effect/src/Context.ts"), - ).toBe(true) - }) - test("handles Windows paths correctly (simulated on macOS)", () => { const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index dc3d4498c4..98771aedd1 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -41,22 +41,6 @@ const fileQuery = (selection: FileSelection | undefined) => const mention = /(^|[\s([{"'])@(\S+)/g -const referencePath = (value: string) => { - const match = value.match(/^([^:/\\]+):\/(.*)$/) - if (!match) return - if (/^[A-Za-z]$/.test(match[1]!)) return - return { name: match[1]!, path: match[2] ?? "" } -} - -const referenceUrl = (value: string, selection?: FileSelection) => { - const reference = referencePath(value) - if (!reference) return - return `opencode-reference://${encodeURIComponent(reference.name)}/${reference.path - .split("/") - .map(encodeURIComponent) - .join("/")}${fileQuery(selection)}` -} - const parseCommentMentions = (comment: string) => { return Array.from(comment.matchAll(mention)).flatMap((match) => { const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "") @@ -113,34 +97,25 @@ export function buildRequestParts(input: BuildRequestPartsInput) { }, ] - const filePart = (file: string, selection?: FileSelection, source?: FileAttachmentPart) => { - const url = referenceUrl(file, selection) - const filepath = url ? file : absolute(input.sessionDirectory, file) + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) return { id: Identifier.ascending("part"), type: "file", mime: "text/plain", - url: url ?? `file://${encodeFilePath(filepath)}${fileQuery(selection)}`, - filename: getFilename(file), - ...(source - ? { - source: { - type: "file" as const, - text: { - value: source.content, - start: source.start, - end: source.end, - }, - path: filepath, - }, - } - : {}), + url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, } satisfies PromptRequestPart - } - - const files = input.prompt - .filter(isFileAttachment) - .map((attachment) => filePart(attachment.path, attachment.selection, attachment)) + }) const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { return { @@ -158,20 +133,34 @@ export function buildRequestParts(input: BuildRequestPartsInput) { const used = new Set(files.map((part) => part.url)) const context = input.context.flatMap((item) => { const path = absolute(input.sessionDirectory, item.path) - const url = referenceUrl(item.path, item.selection) ?? `file://${encodeFilePath(path)}${fileQuery(item.selection)}` + const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` const comment = item.comment?.trim() if (!comment && used.has(url)) return [] used.add(url) - const contextFilePart = filePart(item.path, item.selection) + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart - if (!comment) return [contextFilePart] + if (!comment) return [filePart] const mentions = parseCommentMentions(comment).flatMap((path) => { - const url = referenceUrl(path) ?? `file://${encodeFilePath(absolute(input.sessionDirectory, path))}` + const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}` if (used.has(url)) return [] used.add(url) - return [filePart(path)] + return [ + { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(path), + } satisfies PromptRequestPart, + ] }) return [ @@ -188,7 +177,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { origin: item.commentOrigin, }), } satisfies PromptRequestPart, - contextFilePart, + filePart, ...mentions, ] }) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 615d16d48f..8584682412 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,4 @@ 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" @@ -28,6 +27,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@/util/effect-zod" import { withStatics, type DeepMutable } from "@/util/schema" +type ReferenceEntry = NonNullable[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), @@ -301,7 +303,25 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePrompt(name: string, reference: ConfigReference.Resolved) { + 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) { if (reference.kind === "local") { return [ PROMPT_SCOUT, @@ -323,7 +343,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 = ConfigReference.resolve(reference, ctx) + const resolved = resolveReference(reference) const localPath = resolved.kind === "local" ? resolved.path : undefined agents[name] = { name, diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index 22020f2e8b..eea3d998c1 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,11 +1,8 @@ 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({ @@ -28,90 +25,3 @@ export const Info = Schema.Record(Schema.String, Entry) .annotate({ identifier: "ReferenceConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Info = Schema.Schema.Type - -export type Entry = Schema.Schema.Type -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))) -} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 085485c8aa..4dd6a3ae7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import { ConfigReference } from "@/config/reference" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -279,14 +278,6 @@ const mime: Record = { } 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() @@ -323,23 +314,6 @@ 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 } @@ -349,7 +323,12 @@ export interface Interface { readonly status: () => Effect.Effect readonly read: (file: string) => Effect.Effect readonly list: (dir?: string) => Effect.Effect - readonly search: (input: SearchInput) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/File") {} @@ -361,7 +340,6 @@ export const layer = Layer.effect( const rg = yield* Ripgrep.Service const git = yield* Git.Service const scope = yield* Scope.Scope - const referenceCache = new Map() const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -371,38 +349,6 @@ 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() - 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 @@ -433,9 +379,24 @@ export const layer = Layer.effect( next.dirs = Array.from(dirs).toSorted() } else { - const scanned = yield* scanDirectory(ctx.directory) - next.files = scanned.files - next.dirs = scanned.dirs + const files = yield* rg.files({ cwd: ctx.directory }).pipe( + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + const seen = new Set() + 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 s = yield* InstanceState.get(state) @@ -652,36 +613,32 @@ export const layer = Layer.effect( }) }) - 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 - + const search = Effect.fn("File.search")(function* (input: { + query: string + limit?: number + 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 }) - const output = searchEntry(cache, input) + + 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 log.info("search", { query, kind, results: output.length }) return output diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index e784a34f0e..d0e9ee6186 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -1,7 +1,6 @@ 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" @@ -71,14 +70,12 @@ 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, }) }), ) 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 8318cdd786..98ee5968e0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,5 +1,4 @@ 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" @@ -9,7 +8,6 @@ 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 } }) { @@ -26,7 +24,6 @@ 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, }) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bfb917afae..fef8c43836 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,7 +32,6 @@ 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" @@ -1073,32 +1072,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] } break - case "file:": - case ConfigReference.URL_PROTOCOL: { + case "file:": { log.info("file", { mime: part.mime }) - 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 filepath = fileURLToPath(part.url) const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const { read } = yield* registry.named() @@ -1123,7 +1099,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 = resolvedPart.url.split("?")[0] + const filePathURI = part.url.split("?")[0] let start = parseInt(range.start) let end = range.end ? parseInt(range.end) : undefined if (start === end) { @@ -1176,7 +1152,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), ) } else { - pieces.push({ ...resolvedPart, mime, messageID: info.id, sessionID: input.sessionID }) + pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID }) } } else { const error = Cause.squash(exit.cause) @@ -1233,7 +1209,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the synthetic: true, text: exit.value.output, }, - { ...resolvedPart, mime, messageID: info.id, sessionID: input.sessionID }, + { ...part, mime, messageID: info.id, sessionID: input.sessionID }, ] } @@ -1255,7 +1231,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: resolvedPart.source, + source: part.source, }, ] } diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 0662998487..cdd2e211c2 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -3,7 +3,6 @@ import { $ } from "bun" import { Effect } from "effect" import path from "path" import fs from "fs/promises" -import { ConfigReference } from "@/config/reference" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" @@ -20,7 +19,8 @@ const run = (eff: Effect.Effect) => const status = () => run(File.Service.use((svc) => svc.status())) const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) -const search = (input: File.SearchInput) => run(File.Service.use((svc) => svc.search(input))) +const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => + run(File.Service.use((svc) => svc.search(input))) describe("file/index Filesystem patterns", () => { describe("read() - text content", () => { @@ -829,27 +829,6 @@ describe("file/index Filesystem patterns", () => { }, }) }) - - test("searches reference files only after reference path prefix", async () => { - await using tmp = await setupSearchableRepo() - await using docs = await tmpdir() - await fs.writeFile(path.join(docs.path, "guide.md"), "guide", "utf-8") - await fs.writeFile(path.join(docs.path, "..guide.md"), "hidden guide", "utf-8") - - const references = { docs: { path: docs.path } } satisfies ConfigReference.Info - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - expect(await search({ query: "guide", type: "file", references })).not.toContain("docs:/guide.md") - expect(await search({ query: "docs:/guide", type: "file", references })).toContain("docs:/guide.md") - expect(await search({ query: "docs:/..guide", type: "file", references })).toEqual(["docs:/..guide.md"]) - expect(await search({ query: "docs/", type: "file", references })).toContain("docs:/guide.md") - }, - }) - }) }) describe("read() - diff/patch", () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ee4b2d30c5..3b0009d2b3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -3,7 +3,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" -import fs from "fs/promises" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -418,57 +417,6 @@ it.live("prompt emits v2 prompted and synthetic events", () => ), ) -it.live("prompt resolves configured reference file URLs", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ dir }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* Effect.promise(() => fs.mkdir(path.join(dir, "reference-docs"), { recursive: true })) - yield* Effect.promise(() => Bun.write(path.join(dir, "reference-docs", "guide.md"), "reference guide")) - - yield* prompt.prompt({ - sessionID: chat.id, - agent: "build", - noReply: true, - parts: [ - { type: "text", text: "read @docs:/guide.md" }, - { - type: "file", - mime: "text/plain", - filename: "guide.md", - url: "opencode-reference://docs/guide.md", - source: { - type: "file", - path: "docs:/guide.md", - text: { value: "@docs:/guide.md", start: 5, end: 20 }, - }, - }, - ], - }) - - const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.layer), - ) - expect(messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), - expect.objectContaining({ type: "synthetic", text: expect.stringContaining("reference guide") }), - ]), - ) - }), - { - git: true, - config: (url) => ({ - ...providerCfg(url), - reference: { - docs: { path: "./reference-docs" }, - }, - }), - }, - ), -) - it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { From fc1cefeb8ef50da17cf44b4ba476c20f0bb0f616 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 14:24:30 +0530 Subject: [PATCH 5/5] fix: route reference mentions through scout --- packages/opencode/src/agent/agent.ts | 43 ++++++------- packages/opencode/src/config/reference.ts | 43 +++++++++++++ packages/opencode/src/session/prompt.ts | 19 +++++- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 61 +++++++++++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8584682412..d896fbd2be 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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[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,14 +323,27 @@ 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.scout.permission = Permission.merge( + agents.scout.permission, + Permission.fromConfig( + localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}, + ), + ) agents[name] = { name, description: resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + ? `Reference alias for Scout using local directory ${resolved.path}` + : `Reference alias for Scout using repository ${resolved.repository}`, permission: Permission.merge( agents.scout.permission, Permission.fromConfig( diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1..51f66a8a8d 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,10 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" +import { Global } from "@opencode-ai/core/global" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" +import path from "path" const Git = Schema.Struct({ repository: Schema.String.annotate({ @@ -25,3 +27,44 @@ export const Info = Schema.Record(Schema.String, Entry) .annotate({ identifier: "ReferenceConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Info = Schema.Schema.Type + +export type Entry = Schema.Schema.Type +export type Resolved = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } + +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) +} + +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 prompt(name: string, reference: Resolved) { + if (reference.kind === "local") { + return [ + `@${name} is a configured Scout reference, not a separate subagent or skill.`, + `Local directory: ${reference.path}`, + `In the task prompt, tell Scout to inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files in the reference.`, + ].join("\n") + } + + return [ + `@${name} is a configured Scout reference, not a separate subagent or skill.`, + `Repository: ${reference.repository}`, + ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + "In the task prompt, tell Scout to clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files in the reference.", + ].join("\n") +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c43836..2e62e71a74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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" @@ -1239,8 +1240,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) + const cfg = yield* config.get() + const reference = Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? cfg.reference?.[part.name] + ? ConfigReference.resolve(cfg.reference[part.name], yield* InstanceState.context) + : undefined + : undefined + const target = reference ? "scout" : part.name + const perm = Permission.evaluate("task", target, ag.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + const referencePrompt = reference + ? ConfigReference.prompt(part.name, reference) + + "\nCall the task tool with subagent: scout. Do not call a subagent or skill named " + + part.name + + "." + : undefined return [ { ...part, messageID: info.id, sessionID: input.sessionID }, { @@ -1249,8 +1263,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the type: "text", synthetic: true, text: + (referencePrompt ? referencePrompt + "\n" : "") + " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + + target + hint, }, ] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c8a91c1de1..3047ed813d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -286,7 +286,9 @@ export const layer: Layer.Layer< }) const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { - const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const items = (yield* agents.list()).filter( + (item) => item.mode !== "primary" && item.options.reference === undefined, + ) const filtered = items.filter( (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b3..739d26bfcd 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -24,6 +24,7 @@ import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" @@ -91,6 +92,21 @@ function withSh(fx: () => Effect.Effect) { ) } +function withScout(fx: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const prev = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return prev + }), + () => fx, + (prev) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = prev + }), + ) +} + function toolPart(parts: MessageV2.Part[]) { return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") } @@ -417,6 +433,51 @@ it.live("prompt emits v2 prompted and synthetic events", () => ), ) +it.live("reference mentions route through scout", () => + provideTmpdirServer( + () => + withScout( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "@effect audit this code" }, + { + type: "agent", + name: "effect", + source: { value: "@effect", start: 0, end: 7 }, + }, + ], + }) + + const synthetic = result.parts.findLast((part) => part.type === "text" && part.synthetic) + expect(synthetic?.type).toBe("text") + if (synthetic?.type !== "text") return + expect(synthetic.text).toContain("@effect is a configured Scout reference") + expect(synthetic.text).toContain("Repository: Effect-TS/effect") + expect(synthetic.text).toContain("subagent: scout") + expect(synthetic.text).toContain("Do not call a subagent or skill named effect") + expect(synthetic.text).not.toContain("subagent: effect") + }), + ), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + reference: { + effect: "Effect-TS/effect", + }, + }), + }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) {