feat: support reference file mentions
This commit is contained in:
parent
b2baddcd37
commit
8318b686cb
11 changed files with 370 additions and 109 deletions
|
|
@ -564,6 +564,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.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 agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
|
|
@ -589,6 +592,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
} = useFilteredList<AtOption>({
|
} = useFilteredList<AtOption>({
|
||||||
items: async (query) => {
|
items: async (query) => {
|
||||||
const agents = agentList()
|
const agents = agentList()
|
||||||
|
const reference = referenceAgentNames().find(
|
||||||
|
(name) => query.startsWith(`${name}:/`) || query.startsWith(`${name}/`),
|
||||||
|
)
|
||||||
const open = recent()
|
const open = recent()
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
|
|
@ -597,6 +603,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
.map((path) => ({ type: "file", path, display: path }))
|
.map((path) => ({ type: "file", path, display: path }))
|
||||||
|
if (reference) return fileOptions
|
||||||
return [...agents, ...pinned, ...fileOptions]
|
return [...agents, ...pinned, ...fileOptions]
|
||||||
},
|
},
|
||||||
key: atKey,
|
key: atKey,
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,33 @@ describe("buildRequestParts", () => {
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
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)", () => {
|
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 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,22 @@ const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
|
|
||||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
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) => {
|
const parseCommentMentions = (comment: string) => {
|
||||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||||
|
|
@ -97,25 +113,34 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
const filePart = (file: string, selection?: FileSelection, source?: FileAttachmentPart) => {
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
const url = referenceUrl(file, selection)
|
||||||
|
const filepath = url ? file : absolute(input.sessionDirectory, file)
|
||||||
return {
|
return {
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
url: url ?? `file://${encodeFilePath(filepath)}${fileQuery(selection)}`,
|
||||||
filename: getFilename(attachment.path),
|
filename: getFilename(file),
|
||||||
source: {
|
...(source
|
||||||
type: "file",
|
? {
|
||||||
text: {
|
source: {
|
||||||
value: attachment.content,
|
type: "file" as const,
|
||||||
start: attachment.start,
|
text: {
|
||||||
end: attachment.end,
|
value: source.content,
|
||||||
},
|
start: source.start,
|
||||||
path,
|
end: source.end,
|
||||||
},
|
},
|
||||||
|
path: filepath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
} satisfies PromptRequestPart
|
} satisfies PromptRequestPart
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const files = input.prompt
|
||||||
|
.filter(isFileAttachment)
|
||||||
|
.map((attachment) => filePart(attachment.path, attachment.selection, attachment))
|
||||||
|
|
||||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -133,34 +158,20 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
const used = new Set(files.map((part) => part.url))
|
const used = new Set(files.map((part) => part.url))
|
||||||
const context = input.context.flatMap((item) => {
|
const context = input.context.flatMap((item) => {
|
||||||
const path = absolute(input.sessionDirectory, item.path)
|
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()
|
const comment = item.comment?.trim()
|
||||||
if (!comment && used.has(url)) return []
|
if (!comment && used.has(url)) return []
|
||||||
used.add(url)
|
used.add(url)
|
||||||
|
|
||||||
const filePart = {
|
const contextFilePart = filePart(item.path, item.selection)
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(item.path),
|
|
||||||
} satisfies PromptRequestPart
|
|
||||||
|
|
||||||
if (!comment) return [filePart]
|
if (!comment) return [contextFilePart]
|
||||||
|
|
||||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
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 []
|
if (used.has(url)) return []
|
||||||
used.add(url)
|
used.add(url)
|
||||||
return [
|
return [filePart(path)]
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(path),
|
|
||||||
} satisfies PromptRequestPart,
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -177,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
origin: item.commentOrigin,
|
origin: item.commentOrigin,
|
||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
contextFilePart,
|
||||||
...mentions,
|
...mentions,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
|
|
@ -27,9 +28,6 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics, type DeepMutable } from "@/util/schema"
|
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({
|
export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
description: Schema.optional(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 ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencePath(value: string) {
|
function referencePrompt(name: string, reference: ConfigReference.Resolved) {
|
||||||
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") {
|
if (reference.kind === "local") {
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
PROMPT_SCOUT,
|
||||||
|
|
@ -343,7 +323,7 @@ export const layer = Layer.effect(
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||||
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
||||||
if (agents[name]) continue
|
if (agents[name]) continue
|
||||||
const resolved = resolveReference(reference)
|
const resolved = ConfigReference.resolve(reference, ctx)
|
||||||
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
||||||
agents[name] = {
|
agents[name] = {
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
export * as ConfigReference from "./reference"
|
export * as ConfigReference from "./reference"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
const Git = Schema.Struct({
|
const Git = Schema.Struct({
|
||||||
repository: Schema.String.annotate({
|
repository: Schema.String.annotate({
|
||||||
|
|
@ -25,3 +28,90 @@ export const Info = Schema.Record(Schema.String, Entry)
|
||||||
.annotate({ identifier: "ReferenceConfig" })
|
.annotate({ identifier: "ReferenceConfig" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
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)))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
|
@ -278,6 +279,14 @@ const mime: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: 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 ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
const name = (file: string) => path.basename(file).toLowerCase()
|
||||||
|
|
@ -314,6 +323,23 @@ const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||||
return [...visible, ...hiddenItems]
|
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 {
|
interface State {
|
||||||
cache: Entry
|
cache: Entry
|
||||||
}
|
}
|
||||||
|
|
@ -323,12 +349,7 @@ export interface Interface {
|
||||||
readonly status: () => Effect.Effect<Info[]>
|
readonly status: () => Effect.Effect<Info[]>
|
||||||
readonly read: (file: string) => Effect.Effect<Content>
|
readonly read: (file: string) => Effect.Effect<Content>
|
||||||
readonly list: (dir?: string) => Effect.Effect<Node[]>
|
readonly list: (dir?: string) => Effect.Effect<Node[]>
|
||||||
readonly search: (input: {
|
readonly search: (input: SearchInput) => Effect.Effect<string[]>
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) => Effect.Effect<string[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
|
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 rg = yield* Ripgrep.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
const referenceCache = new Map<string, CachedEntry>()
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("File.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 scan = Effect.fn("File.scan")(function* () {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
if (ctx.directory === path.parse(ctx.directory).root) return
|
if (ctx.directory === path.parse(ctx.directory).root) return
|
||||||
|
|
@ -379,24 +433,9 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
} else {
|
} else {
|
||||||
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
|
const scanned = yield* scanDirectory(ctx.directory)
|
||||||
Stream.runCollect,
|
next.files = scanned.files
|
||||||
Effect.map((chunk) => [...chunk]),
|
next.dirs = scanned.dirs
|
||||||
)
|
|
||||||
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 s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
|
|
@ -613,32 +652,36 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
const searchReference = Effect.fn("File.searchReference")(function* (input: SearchInput) {
|
||||||
query: string
|
const ctx = yield* InstanceState.context
|
||||||
limit?: number
|
const parsed = ConfigReference.parseFilePath(input.query.trim(), input.references)
|
||||||
dirs?: boolean
|
if (!parsed) return
|
||||||
type?: "file" | "directory"
|
|
||||||
}) {
|
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()
|
yield* ensure()
|
||||||
const { cache } = yield* InstanceState.get(state)
|
const { cache } = yield* InstanceState.get(state)
|
||||||
|
|
||||||
const query = input.query.trim()
|
const query = input.query.trim()
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
log.info("search", { query, kind })
|
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 })
|
log.info("search", { query, kind, results: output.length })
|
||||||
return output
|
return output
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
|
|
@ -70,12 +71,14 @@ export const FileRoutes = lazy(() =>
|
||||||
async (c) =>
|
async (c) =>
|
||||||
jsonRequest("FileRoutes.findFile", c, function* () {
|
jsonRequest("FileRoutes.findFile", c, function* () {
|
||||||
const query = c.req.valid("query")
|
const query = c.req.valid("query")
|
||||||
|
const config = yield* Config.Service
|
||||||
const svc = yield* File.Service
|
const svc = yield* File.Service
|
||||||
return yield* svc.search({
|
return yield* svc.search({
|
||||||
query: query.query,
|
query: query.query,
|
||||||
limit: query.limit ?? 10,
|
limit: query.limit ?? 10,
|
||||||
dirs: query.dirs !== "false",
|
dirs: query.dirs !== "false",
|
||||||
type: query.type,
|
type: query.type,
|
||||||
|
references: (yield* config.get()).reference,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
@ -8,6 +9,7 @@ import { InstanceHttpApi } from "../api"
|
||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const svc = yield* File.Service
|
const svc = yield* File.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
|
||||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
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,
|
limit: ctx.query.limit ?? 10,
|
||||||
dirs: ctx.query.dirs !== "false",
|
dirs: ctx.query.dirs !== "false",
|
||||||
type: ctx.query.type,
|
type: ctx.query.type,
|
||||||
|
references: (yield* config.get()).reference,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { Command } from "../command"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigMarkdown } from "@/config/markdown"
|
import { ConfigMarkdown } from "@/config/markdown"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { SessionProcessor } from "./processor"
|
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
|
break
|
||||||
case "file:": {
|
case "file:":
|
||||||
|
case ConfigReference.URL_PROTOCOL: {
|
||||||
log.info("file", { mime: part.mime })
|
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 mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
|
||||||
|
|
||||||
const { read } = yield* registry.named()
|
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
|
let limit: number | undefined
|
||||||
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
|
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
|
||||||
if (range.start != null) {
|
if (range.start != null) {
|
||||||
const filePathURI = part.url.split("?")[0]
|
const filePathURI = resolvedPart.url.split("?")[0]
|
||||||
let start = parseInt(range.start)
|
let start = parseInt(range.start)
|
||||||
let end = range.end ? parseInt(range.end) : undefined
|
let end = range.end ? parseInt(range.end) : undefined
|
||||||
if (start === end) {
|
if (start === end) {
|
||||||
|
|
@ -1152,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })
|
pieces.push({ ...resolvedPart, mime, messageID: info.id, sessionID: input.sessionID })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = Cause.squash(exit.cause)
|
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,
|
synthetic: true,
|
||||||
text: exit.value.output,
|
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"),
|
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
|
||||||
mime,
|
mime,
|
||||||
filename: part.filename!,
|
filename: part.filename!,
|
||||||
source: part.source,
|
source: resolvedPart.source,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { File } from "../../src/file"
|
import { File } from "../../src/file"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { WithInstance } from "../../src/project/with-instance"
|
import { WithInstance } from "../../src/project/with-instance"
|
||||||
|
|
@ -19,8 +20,7 @@ const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||||
const status = () => run(File.Service.use((svc) => svc.status()))
|
const status = () => run(File.Service.use((svc) => svc.status()))
|
||||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
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 list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
|
||||||
const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
|
const search = (input: File.SearchInput) => run(File.Service.use((svc) => svc.search(input)))
|
||||||
run(File.Service.use((svc) => svc.search(input)))
|
|
||||||
|
|
||||||
describe("file/index Filesystem patterns", () => {
|
describe("file/index Filesystem patterns", () => {
|
||||||
describe("read() - text content", () => {
|
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", () => {
|
describe("read() - diff/patch", () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
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", () =>
|
it.live("static loop returns assistant text through local provider", () =>
|
||||||
provideTmpdirServer(
|
provideTmpdirServer(
|
||||||
Effect.fnUntraced(function* ({ llm }) {
|
Effect.fnUntraced(function* ({ llm }) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue