refactor(core): consolidate references (#31539)

This commit is contained in:
Dax 2026-06-09 12:08:58 -04:00 committed by GitHub
parent 0bb677cef9
commit 6566ede935
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 687 additions and 2730 deletions

View File

@ -0,0 +1,63 @@
export * as ConfigReferencePlugin from "./reference"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
}),
)
}
}
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
}),
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
return typeof entry === "string"
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
: "path" in entry
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}

View File

@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type
export const Info = Schema.Record(Schema.String, Entry)
export type Info = typeof Info.Type
export type NormalizedEntry =
| { readonly kind: "local"; readonly path: string }
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
| { readonly kind: "invalid"; readonly message: string }
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
}
export function normalizeEntry(entry: Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const message = validateAlias(name)
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
}),
)
}

View File

@ -9,7 +9,6 @@ import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
@ -17,7 +16,6 @@ import { ToolOutputStore } from "./tool-output-store"
export const ReadInput = Schema.Struct({
path: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
@ -134,7 +132,6 @@ export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@ -158,7 +155,6 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
real: Schema.String,
root: Schema.String,
resource: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
type: Schema.Literals(["file", "directory"]),
}) {}
@ -239,7 +235,6 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
@ -251,21 +246,12 @@ export const layer = Layer.effect(
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const select = Effect.fnUntraced(function* (reference?: string) {
if (!reference) return { directory: location.directory, root }
const resolved = yield* references.get(reference)
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
const resolve = Effect.fnUntraced(function* (input?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
@ -274,9 +260,9 @@ export const layer = Layer.effect(
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
const selected = { directory: location.directory, root }
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
@ -335,24 +321,24 @@ export const layer = Layer.effect(
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const target = yield* resolve(input.path, input.reference)
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new ReadPath({
type,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
resource: relative,
})
})
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
const target = yield* resolve(input.path, input.reference)
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return {
real: target.real,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
resource: relative,
}
})
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
@ -510,25 +496,24 @@ export const layer = Layer.effect(
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path, input.reference)
const directory = yield* resolve(input.path)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
resource: relative,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path, input.reference)
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
reference: input.reference,
resource: relative,
type,
})
})
@ -644,5 +629,4 @@ export const layer = Layer.effect(
export const locationLayer = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(ProjectReference.locationLayer),
)

View File

@ -22,7 +22,7 @@ import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
import { ProjectReference } from "./project-reference"
import { Reference } from "./reference"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
@ -52,7 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
location,
Policy.locationLayer,
Config.locationLayer,
ProjectReference.locationLayer,
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
CommandV2.locationLayer,

View File

@ -9,8 +9,7 @@ import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
/**
* Location-scoped raw search substrate. Search authority is selected only by
* FileSystem, preserving Location-relative paths and named read
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
* share the same bounded filesystem behavior.
*
@ -106,7 +105,7 @@ export const layer = Layer.effect(
return {
path: RelativePath.make(relative),
canonical,
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
resource: relative,
mtime: info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),

View File

@ -9,6 +9,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
@ -25,6 +26,7 @@ import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
type Plugin = {
id: PluginV2.ID
@ -42,6 +44,7 @@ type Plugin = {
| Config.Service
| ModelsDev.Service
| SkillV2.Service
| Reference.Service
>
}
@ -67,6 +70,7 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const references = yield* Reference.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
@ -85,6 +89,7 @@ export const layer = Layer.effect(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, references),
Effect.provideService(PluginV2.Service, plugin),
),
})
@ -104,6 +109,7 @@ export const layer = Layer.effect(
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@ -124,4 +130,5 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer),
)

View File

@ -1,243 +0,0 @@
export * as ProjectReference from "./project-reference"
import path from "path"
import { Context, Effect, Layer } from "effect"
import { Config } from "./config"
import { ConfigReference } from "./config/reference"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
export type Resolved =
| { readonly name: string; readonly kind: "local"; readonly path: string }
| {
readonly name: string
readonly kind: "git"
readonly repository: string
readonly reference: Repository.RemoteReference
readonly path: string
readonly branch?: string
}
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
type Valid = Exclude<Resolved, { kind: "invalid" }>
export type Mention =
| {
readonly name: string
readonly kind: "reference"
readonly reference: Valid
readonly target?: string
readonly path: string
}
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
| {
readonly name: string
readonly kind: "missing"
readonly target: string
readonly path: string
readonly message: string
}
export interface Interface {
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
type Materializer = {
readonly name: string
readonly repository: string
readonly path: string
readonly run: Effect.Effect<void, RepositoryCache.Error>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const cache = yield* RepositoryCache.Service
const references = resolveAll({
references: ConfigReference.normalize(
Object.assign(
{},
...(yield* config.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.map((document) => document.info.references ?? {}),
),
),
directory: location.project.directory,
home: global.home,
repos: global.repos,
})
const materializers = yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return {
name: reference.name,
repository: reference.repository,
path: reference.path,
run: yield* Effect.cached(
cache
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
.pipe(Effect.asVoid),
),
}
}),
)
yield* Effect.forEach(
materializers,
(materializer) =>
materializer.run.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize project reference", {
name: materializer.name,
repository: materializer.repository,
cause,
}),
),
),
{ concurrency: 4, discard: true },
).pipe(Effect.forkScoped)
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
const normalized = normalizePath(target)
if (!normalized)
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
})
return Service.of({
list: Effect.fn("ProjectReference.list")(function* () {
return references
}),
get: Effect.fn("ProjectReference.get")(function* (name: string) {
return references.find((reference) => reference.name === name)
}),
ensurePath,
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
const normalized = normalizePath(target)
return normalized
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
: false
}),
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
const [name, ...rest] = value.split("/")
const target = rest.length ? rest.join("/") : undefined
const reference = references.find((reference) => reference.name === name)
if (!reference) return
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
if (reference.kind === "git") yield* ensurePath(reference.path)
if (!target) return { name, kind: "reference", reference, path: reference.path }
const resolved = path.resolve(reference.path, target)
if (!FSUtil.contains(reference.path, resolved))
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
if (!(yield* fs.existsSafe(resolved)))
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
return { name, kind: "reference", reference, target, path: resolved }
}),
})
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
const inert: Interface = {
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
}
export function resolveAll(input: {
references: ConfigReference.NormalizedInfo
directory: string
home: string
repos: string
}) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]): Resolved => {
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid",
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
}
})
}
export function resolve(input: {
name: string
reference: ConfigReference.NormalizedEntry
directory: string
home: string
repos: string
}): Resolved {
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
}
const reference = Repository.parse(input.reference.repository)
if (!reference || !Repository.isRemote(reference)) {
return {
name: input.name,
kind: "invalid",
repository: input.reference.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.reference.repository,
reference,
path: Repository.cachePath(input.repos, reference),
branch: input.reference.branch,
}
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}
function uniqueGitReferences(references: Resolved[]) {
const seen = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git" || seen.has(reference.path)) return false
seen.add(reference.path)
return true
})
}
function normalizePath(target?: string) {
if (!target) return
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function contains(parent: string, child: string) {
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
}

View File

@ -0,0 +1,114 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft } from "immer"
import { Global } from "./global"
import { EventV2 } from "./event"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
import { AbsolutePath } from "./schema"
import { State } from "./state"
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
source: Schema.suspend(() => Source),
}) {}
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
type: Schema.Literal("local"),
path: AbsolutePath,
}) {}
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
}) {}
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
type Data = {
sources: Map<string, Source>
}
type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const events = yield* EventV2.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Editor>({
initial: () => ({ sources: new Map() }),
editor: (draft) => ({
add: (name, source) => draft.sources.set(name, castDraft(source)),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (editor) =>
Effect.gen(function* () {
materialized.clear()
const seen = new Map<string, string | undefined>()
for (const [name, source] of editor.list()) {
if (source.type === "local") {
materialized.set(name, new Info({ name, path: source.path, source }))
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
const target = Repository.cachePath(global.repos, repository)
if (seen.has(target) && seen.get(target) !== source.branch) continue
seen.set(target, source.branch)
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* events.publish(Event.Updated, {})
}),
})
return Service.of({
transform: state.transform,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
}),
})
}),
)
export const locationLayer = layer

View File

@ -349,6 +349,5 @@ const toMessage = (input: Admitted) =>
text: input.prompt.text,
files: input.prompt.files,
agents: input.prompt.agents,
references: input.prompt.references,
time: { created: input.timeCreated },
})

View File

@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
references: event.data.prompt.references,
time: { created: event.data.timestamp },
}),
)

View File

@ -37,7 +37,6 @@ export class User extends Schema.Class<User>("Session.Message.User")({
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
references: Prompt.fields.references,
type: Schema.Literal("user"),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,

View File

@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
source: Source.pipe(Schema.optional),
}) {}
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
uri: Schema.String.pipe(Schema.optional),
repository: Schema.String.pipe(Schema.optional),
branch: Schema.String.pipe(Schema.optional),
target: Schema.String.pipe(Schema.optional),
targetUri: Schema.String.pipe(Schema.optional),
problem: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Prompt")({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
}) {
static readonly equivalence = Schema.toEquivalence(Prompt)
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
return new Prompt({
text: input.text,
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.references === undefined ? {} : { references: input.references }),
})
}
}

View File

@ -105,7 +105,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
...(message.references?.length ? { references: message.references } : {}),
},
}),
]

View File

@ -2,8 +2,7 @@
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval. Named project references
* are read-oriented and deliberately are not accepted by mutation tools.
* external_directory approval before edit approval.
*/
export * as EditTool from "./edit"
@ -21,7 +20,7 @@ export const name = "edit"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
}),
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
@ -100,7 +99,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.withPermission(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [

View File

@ -15,9 +15,6 @@ export const Input = Schema.Struct({
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.FilesInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
@ -41,8 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
/**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal.
*
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -55,20 +50,19 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
const root = yield* filesystem.resolveRoot({ path: input.path })
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
limit: input.limit,
},

View File

@ -18,9 +18,6 @@ export const Input = Schema.Struct({
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.GrepInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
@ -56,8 +53,6 @@ export const toModelOutput = (output: Output) => {
/**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution.
*
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -70,7 +65,7 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@ -83,7 +78,6 @@ export const layer = Layer.effectDiscard(
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
include: input.include,
limit: input.limit,

View File

@ -2,8 +2,7 @@
* Model-facing V2 file-write leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval. Named project references
* are read-oriented and deliberately are not accepted by mutation tools.
* external_directory approval before edit approval.
*/
export * as WriteTool from "./write"
@ -21,7 +20,7 @@ export const name = "write"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
}),
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
@ -55,7 +54,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.withPermission(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],

View File

@ -43,7 +43,7 @@ export const Info = Schema.Struct({
}),
skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }),
reference: Schema.optional(ConfigReferenceV1.Info).annotate({
description: "Named git or local directory references that can be mentioned as @alias or @alias/path",
description: "Named git or local directory references",
}),
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
snapshot: Schema.optional(Schema.Boolean).annotate({

View File

@ -7,25 +7,14 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = ProjectReference.Service.of({
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
function provide(
directory: string,
references = inertReferences,
filesystem = FSUtil.defaultLayer,
data = Global.Path.data,
) {
@ -36,7 +25,6 @@ function provide(
filesystem,
Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references),
Global.layerWith({ data }),
),
),
@ -69,7 +57,7 @@ describe("FileSystem", () => {
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
}),
)
@ -258,7 +246,7 @@ describe("FileSystem", () => {
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, inertReferences, filesystem))
}).pipe(provide(directory, filesystem))
}),
)
@ -344,7 +332,7 @@ describe("FileSystem", () => {
next: 3,
})
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
}).pipe(provide(directory, inertReferences, filesystem))
}).pipe(provide(directory, filesystem))
}),
)
@ -380,7 +368,7 @@ describe("FileSystem", () => {
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
expect(maximum).toBe(16)
}).pipe(provide(directory, inertReferences, filesystem))
}).pipe(provide(directory, filesystem))
}),
)
@ -402,9 +390,8 @@ describe("FileSystem", () => {
),
)
test("rejects empty list aliases and page limits over 2000", () => {
test("rejects page limits over 2000", () => {
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
expect(() => decode({ reference: "" })).toThrow()
expect(() => decode({ limit: 2_001 })).toThrow()
})
@ -461,125 +448,4 @@ describe("FileSystem", () => {
),
)
it.live("reads and lists paths relative to a local project reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
const service = yield* FileSystem.Service
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
type: "text",
content: "docs",
})
expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
it.live("materializes Git references before filesystem access", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
const ensured: string[] = []
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
expect(
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
).toMatchObject({ content: "docs" })
expect(ensured).toEqual([docs])
}).pipe(
provide(
directory,
references(
{
sdk: {
name: "sdk",
kind: "git",
repository: "owner/repo",
reference: Repository.parseRemote("owner/repo"),
path: docs,
},
},
(target) => Effect.sync(() => ensured.push(target ?? "")),
),
),
)
}),
)
it.live("rejects unknown, invalid, and escaping project reference paths", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(docs))
const service = yield* FileSystem.Service
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
expect(
Exit.isFailure(
yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(
provide(
directory,
references({
docs: { name: "docs", kind: "local", path: docs },
invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
}),
),
)
}),
)
it.live("rejects aliases when project references are disabled", () =>
withTmp((directory) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
true,
)
}).pipe(provide(directory)),
),
)
it.live("rejects symlink escapes from project references", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
const outside = path.join(directory, "outside.txt")
return Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(outside, "outside")
await fs.symlink(outside, path.join(docs, "link.txt"))
})
expect(
Exit.isFailure(
yield* (yield* FileSystem.Service)
.read({ reference: "docs", path: RelativePath.make("link.txt") })
.pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
})
function references(
entries: Record<string, ProjectReference.Resolved>,
ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath,
containsManagedPath: () => Effect.succeed(false),
})
}

View File

@ -18,7 +18,7 @@ import { Global } from "../src/global"
import { ModelsDev } from "../src/models-dev"
import { Npm } from "../src/npm"
import { Project } from "../src/project"
import { ProjectReference } from "../src/project-reference"
import { Reference } from "../src/reference"
import { LocationSearch } from "../src/location-search"
import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools"
@ -71,7 +71,7 @@ describe("LocationServiceMap", () => {
const update = (directory: string) =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((boot) => boot.wait())
yield* ProjectReference.Service
yield* Reference.Service
yield* LocationSearch.Service
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()

View File

@ -171,7 +171,7 @@ describe("LocationMutation", () => {
),
)
test("keeps project references outside the mutation input API", () => {
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",

View File

@ -8,7 +8,6 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
@ -16,15 +15,12 @@ import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
function provide(directory: string, data = Global.Path.data) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
Global.layerWith({ data }),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
@ -56,7 +52,7 @@ describe("LocationSearch", () => {
const search = yield* LocationSearch.Service
const result = yield* search.grep({ pattern: "FAIL", path: output })
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
}).pipe(provide(directory, inertReferences, data))
}).pipe(provide(directory, data))
}),
)
@ -83,7 +79,7 @@ describe("LocationSearch", () => {
),
)
it.live("searches files under a relative subdirectory and named local reference", () =>
it.live("searches files under a relative subdirectory", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
@ -98,11 +94,7 @@ describe("LocationSearch", () => {
expect(
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
).toEqual([RelativePath.make("src/active.ts")])
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}).pipe(provide(directory))
}),
)
@ -264,13 +256,3 @@ describe("LocationSearch", () => {
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
})
})
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}

View File

@ -1,299 +0,0 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Layer, Schema } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigReference } from "@opencode-ai/core/config/reference"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
describe("ProjectReference", () => {
it.live("uses the broad experimental flag unless references are explicitly configured", () =>
withEnv(
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined },
Effect.sync(() => {
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
}),
).pipe(
Effect.flatMap(() =>
withEnv(
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
Effect.sync(() => {
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
}),
),
),
),
)
it.live("normalizes aliases and resolves relative local paths from the project root", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const nested = path.join(project, "packages", "app")
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
const references = ProjectReference.resolveAll({
references: ConfigReference.normalize({
docs: { path: "./docs" },
home: "~/notes",
sdk: { repository: "owner/repo", branch: "main" },
shorthand: "owner/other",
invalid: "not-a-repo",
"bad/name": "owner/repo",
}),
directory: project,
home: path.join(tmp.path, "home"),
repos: path.join(tmp.path, "repos"),
})
expect(references).toMatchObject([
{ name: "docs", kind: "local", path: path.join(project, "docs") },
{ name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
{ name: "sdk", kind: "git", branch: "main" },
{ name: "shorthand", kind: "git" },
{ name: "invalid", kind: "invalid", repository: "not-a-repo" },
{ name: "bad/name", kind: "invalid" },
])
}),
),
)
it.live("marks same-cache references with different branches invalid", () =>
Effect.sync(() => {
const references = ProjectReference.resolveAll({
references: ConfigReference.normalize({
main: { repository: "owner/repo", branch: "main" },
dev: { repository: "github.com/owner/repo", branch: "dev" },
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
}),
directory: "/project",
home: "/home",
repos: "/repos",
})
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
}),
)
it.live("merges config aliases and exposes mention and managed-path operations", () =>
withoutReferences(
withTmp((tmp) => {
const calls: RepositoryCache.EnsureInput[] = []
const project = path.join(tmp.path, "project")
const nested = path.join(project, "packages", "app")
const docs = path.join(project, "docs")
const repos = path.join(tmp.path, "repos")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(nested, { recursive: true })
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
yield* withReferences(
Effect.gen(function* () {
const references = yield* ProjectReference.Service
const git = path.join(repos, "github.com", "owner", "repo")
expect(yield* references.list()).toMatchObject([
{ name: "docs", kind: "local", path: docs },
{ name: "sdk", kind: "git", path: git },
])
expect(yield* references.resolveMention("docs/README.md")).toMatchObject({
name: "docs",
kind: "reference",
target: "README.md",
path: path.join(docs, "README.md"),
})
expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({
name: "docs",
kind: "missing",
})
expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({
name: "docs",
kind: "invalid",
})
expect(yield* references.resolveMention("unknown")).toBeUndefined()
expect(yield* references.resolveMention("sdk")).toMatchObject({
name: "sdk",
kind: "reference",
path: git,
})
expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true)
expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false)
yield* references.ensurePath()
expect(calls).toHaveLength(1)
}).pipe(
Effect.provide(
testLayer({
directory: nested,
project,
repos,
documents: [
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }),
],
ensure: (input) => Effect.sync(() => result(repos, calls, input)),
}),
),
),
)
})
}),
),
)
it.live("is inert while the runtime flag is disabled", () =>
withoutReferences(
withTmp((tmp) => {
const calls: RepositoryCache.EnsureInput[] = []
return Effect.gen(function* () {
const references = yield* ProjectReference.Service
expect(yield* references.list()).toEqual([])
expect(yield* references.get("sdk")).toBeUndefined()
expect(yield* references.resolveMention("sdk")).toBeUndefined()
expect(
yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")),
).toBe(false)
yield* references.ensurePath()
expect(calls).toEqual([])
}).pipe(
Effect.provide(
testLayer({
directory: tmp.path,
project: tmp.path,
repos: path.join(tmp.path, "repos"),
documents: [document({ sdk: "owner/repo" })],
ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)),
}),
),
)
}),
),
)
it.live("starts Git materialization in the background without blocking the location layer", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
yield* withReferences(
Effect.gen(function* () {
expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1)
yield* Deferred.await(started).pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.die(new Error("refresh did not start")),
}),
)
}).pipe(
Effect.provide(
testLayer({
directory: tmp.path,
project: tmp.path,
repos: path.join(tmp.path, "repos"),
documents: [document({ sdk: "owner/repo" })],
ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
),
),
)
}),
),
)
})
function document(references: ConfigReference.Info) {
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
}
function result(
repos: string,
calls: RepositoryCache.EnsureInput[],
input: RepositoryCache.EnsureInput,
): RepositoryCache.Result {
calls.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(repos, input.reference),
status: "cached",
branch: input.branch,
}
}
function testLayer(input: {
directory: string
project: string
repos: string
documents: Config.Document[]
ensure: RepositoryCache.Interface["ensure"]
}) {
return ProjectReference.layer.pipe(
Layer.provide(
Layer.mergeAll(
FSUtil.defaultLayer,
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(input.directory) },
{ projectDirectory: AbsolutePath.make(input.project) },
),
),
),
Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
),
),
)
}
function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
body,
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
}
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
}
function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
}
function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
() => body,
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}

View File

@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const scope = yield* Scope.make()
const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope))
const path = AbsolutePath.make("/docs")
yield* update((editor) => editor.add("docs", new Reference.LocalSource({ type: "local", path })))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }),
])
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}).pipe(
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
)
it.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const update = yield* references.transform()
const repository = Repository.parseRemote("owner/repo")
const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" })
yield* update((editor) => editor.add("sdk", source))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), source }),
])
}).pipe(
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
)
})

View File

@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@ -17,7 +17,6 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.
describe("toLLMMessages", () => {
test("maps every top-level V2 Session message type", () => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
const messages = toLLMMessages(
[
new SessionMessage.AgentSwitched({
@ -44,7 +43,6 @@ describe("toLLMMessages", () => {
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
references: [reference],
time: { created },
}),
new SessionMessage.Synthetic({
@ -84,7 +82,7 @@ describe("toLLMMessages", () => {
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
],
metadata: { agents: [{ name: "build" }], references: [reference] },
metadata: { agents: [{ name: "build" }] },
}),
)
expect(messages.slice(2).map((message) => message.content)).toEqual([

View File

@ -43,12 +43,10 @@ const filesystem = Layer.succeed(
Effect.sync(() => {
resolutions.push(input)
const relative = input.path ?? RelativePath.make(".")
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
return new FileSystem.RootTarget({
real: `/project/${relative}`,
root: "/project",
resource,
reference: input.reference,
resource: relative,
type: "directory",
})
}),
@ -122,10 +120,10 @@ describe("GlobTool", () => {
action: "glob",
resources: ["**/*.ts"],
save: ["*"],
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
metadata: { root: "src", path: "src", limit: 12 },
},
])
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
}),
)
@ -169,39 +167,6 @@ describe("GlobTool", () => {
}),
)
it.effect("searches named references with root and reference metadata", () =>
Effect.gen(function* () {
reset()
result = new LocationSearch.FilesResult({
items: [
new LocationSearch.File({
path: RelativePath.make("guide.md"),
canonical: "/project/docs/guide.md",
resource: "docs:guide.md",
mtime: 1,
}),
],
truncated: false,
partial: false,
})
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",
resources: ["*.md"],
save: ["*"],
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
},
])
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
}),
)
it.effect("formats bounded and partial results without discarding structured output", () =>
Effect.sync(() => {
const output = new LocationSearch.FilesResult({

View File

@ -9,7 +9,6 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@ -39,8 +38,7 @@ const filesystem = Layer.succeed(
new FileSystem.RootTarget({
real: `/project/${input.path ?? "."}`,
root: "/project",
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
reference: input.reference,
resource: input.path ?? ".",
type: "directory",
}),
),
@ -115,23 +113,12 @@ const reset = () => {
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
}
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}
function provideLive(directory: string, projectReferences = references({})) {
function provideLive(directory: string) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
@ -170,29 +157,13 @@ describe("GrepTool", () => {
action: "grep",
resources: ["needle"],
save: ["*"],
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
},
])
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
}),
)
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
Effect.gen(function* () {
reset()
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
expect(assertions[0]).toMatchObject({
resources: ["guide"],
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
})
expect(searches).toEqual([
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
])
}),
)
it.effect("does not search when permission is denied", () =>
Effect.gen(function* () {
reset()
@ -248,7 +219,7 @@ describe("GrepTool", () => {
}),
)
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
runtimeIt.live("greps active Location files with include globs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@ -259,23 +230,15 @@ describe("GrepTool", () => {
reset()
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "src"))
await fs.mkdir(docs)
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
})
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
type: "text",
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
})
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
type: "text",
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
})
}).pipe(
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
)
}).pipe(Effect.provide(provideLive(tmp.path)))
}),
),
)

View File

@ -35,7 +35,7 @@ const filesystem = Layer.succeed(
? Effect.succeed(
new FileSystem.ReadPath({
type: resolvedType,
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
resource: input.path,
}),
)
: Effect.die(resolveFailure),
@ -457,20 +457,6 @@ describe("ReadTool", () => {
}),
)
it.effect("authorizes project references with their canonical identity", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
}),
)
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service

View File

@ -1,48 +0,0 @@
export * as ConfigReference from "./reference"
import { ConfigReferenceV1 } from "@opencode-ai/core/v1/config/reference"
export type NormalizedEntry =
| {
kind: "local"
path: string
}
| {
kind: "git"
repository: string
branch?: string
}
| {
kind: "invalid"
message: string
}
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) {
return "Reference alias must not contain /, whitespace, comma, or backtick"
}
}
export function normalizeEntry(entry: ConfigReferenceV1.Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) {
return { kind: "local", path: entry }
}
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: ConfigReferenceV1.Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const aliasError = validateAlias(name)
return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const
}),
)
}

View File

@ -42,7 +42,6 @@ import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Installation } from "@/installation"
@ -98,7 +97,6 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Reference.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Installation.defaultLayer,

View File

@ -11,7 +11,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { Service } from "./bootstrap-service"
import { Reference } from "@/reference/reference"
export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service"
@ -27,7 +26,6 @@ export const layer = Layer.effect(
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
const project = yield* Project.Service
const reference = yield* Reference.Service
const search = yield* Search.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
@ -52,7 +50,7 @@ export const layer = Layer.effect(
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
[reference, lsp, shareNext, format, vcs, snapshot, project],
[lsp, shareNext, format, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.withSpan("InstanceBootstrap.init"))
@ -69,7 +67,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
LSP.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
Reference.defaultLayer,
Search.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,

View File

@ -1,237 +0,0 @@
import path from "path"
import { Effect, Context, Layer, Scope } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
export type Resolved =
| {
name: string
kind: "local"
path: string
}
| {
name: string
kind: "git"
repository: string
reference: RemoteReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository?: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: Materializer[]
}
type Materializer = { path: string; run: Effect.Effect<void> }
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly ensure: (target?: string) => Effect.Effect<void>
readonly contains: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
export function referencePath(input: { directory: string; worktree: string; value: string }) {
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
return path.isAbsolute(input.value)
? input.value
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
}
function resolveGit(
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
): Resolved {
const parsed = parseRepositoryReference(input.repository)
if (!parsed || parsed.protocol === "file:") {
return {
name: input.name,
kind: "invalid",
repository: input.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.repository,
reference: parsed,
path: repositoryCachePath(parsed),
...("branch" in input ? { branch: input.branch } : {}),
}
}
function branchLabel(branch: string | undefined) {
return branch ?? "default branch"
}
function normalizedTarget(target?: string) {
if (!target) return
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function containsReferencePath(referencePath: string, target: string) {
return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
function uniqueGitReferences(references: Resolved[]) {
const seenPath = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
}
function materializeReference(cache: RepositoryCache.Interface, reference: Extract<Resolved, { kind: "git" }>) {
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
),
)
}
const materializers = Effect.fn("Reference.materializers")(function* (
cache: RepositoryCache.Interface,
references: Resolved[],
) {
return yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) }
}),
{ concurrency: "unbounded" },
)
})
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
if (!input.flags.experimentalReferences) return Effect.void
return Effect.forEach(
input.materializers,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
}
function materializeByPath(materializers: Materializer[], target: string) {
return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void
}
function containsGitReferencePath(references: Resolved[], target: string) {
return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target))
}
export function resolve(input: {
name: string
reference: ConfigReference.NormalizedEntry
directory: string
worktree: string
}): Resolved {
if (input.reference.kind === "invalid") {
return { name: input.name, kind: "invalid", message: input.reference.message }
}
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
}
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
}
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid" as const,
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
}
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: ConfigReference.normalize(cfg.reference ?? {}),
directory: ctx.directory,
worktree: ctx.worktree,
})
const materializeByPath = yield* materializers(cache, references)
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
return { references, materializeAll: materializeAllCached, materializeByPath }
}),
)
return Service.of({
init: Effect.fn("Reference.init")(function* () {
if (!flags.experimentalReferences) return
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
list: Effect.fn("Reference.list")(function* () {
return yield* InstanceState.use(state, (s) => s.references)
}),
get: Effect.fn("Reference.get")(function* (name: string) {
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
}),
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
if (!flags.experimentalReferences) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!flags.experimentalReferences) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as Reference from "./reference"

View File

@ -1,320 +0,0 @@
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
import {
repositoryCachePath,
sameRepositoryReference,
parseRepositoryReference,
parseRemoteRepositoryReference,
validateRepositoryBranch,
InvalidRepositoryBranchError,
InvalidRepositoryReferenceError,
UnsupportedLocalRepositoryError,
type RemoteReference,
} from "@/util/repository"
export type Result = {
repository: string
host: string
remote: string
localPath: string
status: "cached" | "cloned" | "refreshed"
head?: string
branch?: string
}
export type EnsureInput = {
reference: RemoteReference
refresh?: boolean
branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"RepositoryCacheInvalidBranchError",
{
branch: Schema.String,
message: Schema.String,
},
) {}
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
"RepositoryCacheCheckoutFailedError",
{
repository: Schema.String,
branch: Schema.String,
message: Schema.String,
},
) {}
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
localPath: Schema.String,
message: Schema.String,
}) {}
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
"RepositoryCacheOperationError",
{
operation: Schema.String,
path: Schema.String,
message: Schema.String,
},
) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
| CheckoutFailedError
| ResetFailedError
| LockFailedError
| CacheOperationError
export interface Interface {
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
if (input.refresh) return "refreshed" as const
return "cached" as const
}
function resetTarget(input: {
requestedBranch?: string
remoteHead: { code: number; stdout: string }
branch: { code: number; stdout: string }
}) {
if (input.requestedBranch) return `origin/${input.requestedBranch}`
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
}
if (input.branch.code === 0 && input.branch.stdout) {
return `origin/${input.branch.stdout}`
}
return "HEAD"
}
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
error instanceof CheckoutFailedError ||
error instanceof ResetFailedError ||
error instanceof LockFailedError ||
error instanceof CacheOperationError
)
}
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
try {
return parseRemoteRepositoryReference(repository)
} catch (error) {
if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) {
return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message })
}
return yield* new InvalidRepositoryError({
repository,
message: errorMessage(error),
})
}
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
try {
validateRepositoryBranch(branch)
} catch (error) {
if (error instanceof InvalidRepositoryBranchError) {
return yield* new InvalidBranchError({ branch: error.branch, message: error.message })
}
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
}
})
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
input: EnsureInput,
services: {
fs: FSUtil.Interface
git: Git.Interface
},
) {
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
const localPath = repositoryCachePath(input.reference)
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
return yield* Effect.acquireUseRelease(
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe(
Effect.catch((error: unknown) =>
Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })),
),
),
() =>
Effect.gen(function* () {
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "ensure cache directory",
path: localPath,
message: errorMessage(error),
}),
),
),
)
const exists = yield* services.fs.existsSafe(localPath)
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
: undefined
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
if (exists && !reuse) {
yield* services.fs.remove(localPath, { recursive: true }).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "remove stale cache",
path: localPath,
message: errorMessage(error),
}),
),
),
)
}
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const clone = yield* services.git.run(
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
{ cwd: path.dirname(localPath) },
)
if (clone.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
})
}
}
if (status === "refreshed") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
})
}
if (input.branch) {
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
cwd: localPath,
})
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: input.branch,
message:
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
})
}
}
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
const target = resetTarget({
requestedBranch: input.branch,
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
branch: { code: branch.exitCode, stdout: branch.text().trim() },
})
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
})
}
}
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
const branch = yield* services.git.branch(localPath)
const headText = head.exitCode === 0 ? head.text().trim() : undefined
return {
repository,
host: input.reference.host,
remote,
localPath,
status,
head: headText,
branch,
} satisfies Result
}),
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
)
})
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
return yield* ensureWithServices(input, { fs, git })
}),
})
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export * as RepositoryCache from "./repository-cache"

View File

@ -17,7 +17,6 @@ import { ProjectCopyApi } from "./groups/project-copy"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
import { ReferenceApi } from "./groups/reference"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
@ -61,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProviderApi)
.addHttpApi(ReferenceApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)

View File

@ -1,60 +0,0 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const ReferenceDescriptor = Schema.Union([
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("local"),
path: Schema.String,
}),
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("git"),
repository: Schema.String,
path: Schema.String,
branch: Schema.optional(Schema.String),
}),
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("invalid"),
repository: Schema.optional(Schema.String),
message: Schema.String,
}),
]).annotate({ identifier: "ReferenceDescriptor" })
export const ReferenceApi = HttpApi.make("reference")
.add(
HttpApiGroup.make("reference")
.add(
HttpApiEndpoint.get("list", "/reference", {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ReferenceDescriptor), "Resolved configured references"),
}).annotateMerge(
OpenApi.annotations({
identifier: "reference.list",
summary: "List configured references",
description: "List configured references resolved in the current workspace.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "reference",
description: "Configured reference routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

View File

@ -1,27 +0,0 @@
import { Reference } from "@/reference/reference"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const referenceHandlers = HttpApiBuilder.group(InstanceHttpApi, "reference", (handlers) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
return handlers.handle("list", () =>
reference.list().pipe(
Effect.map((references) =>
references.map((item) => {
if (item.kind !== "git") return item
return {
name: item.name,
kind: item.kind,
repository: item.repository,
path: item.path,
...(item.branch !== undefined ? { branch: item.branch } : {}),
}
}),
),
),
)
}),
)

View File

@ -35,7 +35,6 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Reference } from "@/reference/reference"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { LLM } from "@/session/llm"
@ -86,7 +85,6 @@ import { projectCopyHandlers } from "./handlers/project-copy"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { referenceHandlers } from "./handlers/reference"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
@ -149,7 +147,6 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
projectCopyHandlers,
ptyHandlers,
questionHandlers,
referenceHandlers,
permissionHandlers,
providerHandlers,
sessionHandlers,
@ -237,7 +234,6 @@ export function createRoutes(
Provider.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
Reference.defaultLayer,
Ripgrep.defaultLayer,
RuntimeFlags.defaultLayer,
Session.defaultLayer,

View File

@ -52,12 +52,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
import { Reference } from "@/reference/reference"
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt"
import * as DateTime from "effect/DateTime"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { LLMEvent } from "@opencode-ai/llm"
@ -122,7 +120,6 @@ export const layer = Layer.effect(
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const references = yield* Reference.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const database = yield* Database.Service
@ -140,46 +137,10 @@ export const layer = Layer.effect(
yield* state.cancel(sessionID)
})
const resolveReferenceParts = Effect.fnUntraced(function* (template: string) {
const parts: Types.DeepMutable<PromptInput["parts"]> = []
const seen = new Set<string>()
yield* Effect.forEach(
ConfigMarkdown.files(template),
Effect.fnUntraced(function* (match) {
const name = match[1]
if (!name) return
const alias = name.split("/")[0]
if (!alias || seen.has(alias)) return
const reference = yield* references.get(alias)
if (!reference) return
seen.add(alias)
const start = match.index ?? 0
const source = { value: match[0], start, end: start + match[0].length }
if (reference.kind === "invalid") {
parts.push(referenceTextPart({ reference, source }))
return
}
yield* references.ensure(reference.path)
parts.push({
type: "file",
url: pathToFileURL(reference.path).href,
filename: alias,
mime: "application/x-directory",
source: { type: "file", text: source, path: alias },
})
}),
{ concurrency: 1, discard: true },
)
return parts
})
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context
const parts: Types.DeepMutable<PromptInput["parts"]> = [
{ type: "text", text: template },
...(yield* resolveReferenceParts(template)),
]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
@ -191,10 +152,6 @@ export const layer = Layer.effect(
if (seen.has(name)) return
seen.add(name)
const slash = name.indexOf("/")
const alias = slash === -1 ? name : name.slice(0, slash)
if (yield* references.get(alias)) return
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
: path.resolve(ctx.worktree, name)
@ -1019,22 +976,7 @@ export const layer = Layer.effect(
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
})
const submittedParts: Types.DeepMutable<PromptInput["parts"]> = [...input.parts]
const attachedReferences = new Set(
input.parts.flatMap((part) =>
part.type === "file" && part.mime === "application/x-directory" ? [part.url] : [],
),
)
for (const part of input.parts) {
if (part.type !== "text" || part.synthetic) continue
for (const reference of yield* resolveReferenceParts(part.text)) {
if (reference.type === "file" && attachedReferences.has(reference.url)) continue
if (reference.type === "file") attachedReferences.add(reference.url)
submittedParts.push(reference)
}
}
const resolvedParts = yield* Effect.forEach(submittedParts, resolvePart, { concurrency: "unbounded" }).pipe(
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
Effect.map((x) => x.flat().map(assign)),
)
@ -1092,26 +1034,6 @@ export const layer = Layer.effect(
if (part.type === "text") {
if (part.synthetic) result.synthetic.push(part.text)
else result.text.push(part.text)
const reference = referencePromptMetadata(part.metadata?.reference)
if (reference) {
result.references.push(
new ReferenceAttachment({
name: reference.name,
kind: reference.kind,
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
repository: reference.repository,
branch: reference.branch,
target: reference.target,
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
problem: reference.problem,
source: new Source({
start: reference.source.start,
end: reference.source.end,
text: reference.source.value,
}),
}),
)
}
}
if (part.type === "file") {
result.files.push(
@ -1149,7 +1071,6 @@ export const layer = Layer.effect(
text: [] as string[],
files: [] as FileAttachment[],
agents: [] as AgentAttachment[],
references: [] as ReferenceAttachment[],
synthetic: [] as string[],
},
)
@ -1164,7 +1085,6 @@ export const layer = Layer.effect(
text: nextPrompt.text.join("\n"),
files: nextPrompt.files,
agents: nextPrompt.agents,
references: nextPrompt.references,
}),
})
}
@ -1642,7 +1562,6 @@ export const defaultLayer = Layer.suspend(() =>
Database.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Reference.defaultLayer,
CrossSpawnSpawner.defaultLayer,
RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer,

View File

@ -1,72 +0,0 @@
import { Option, Schema } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageV2 } from "../message-v2"
import { Reference } from "@/reference/reference"
const Source = Schema.Struct({
value: Schema.String,
start: Schema.Number,
end: Schema.Number,
})
export const ReferencePromptMetadata = Schema.Struct({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
path: Schema.optional(Schema.String),
repository: Schema.optional(Schema.String),
branch: Schema.optional(Schema.String),
target: Schema.optional(Schema.String),
targetPath: Schema.optional(Schema.String),
problem: Schema.optional(Schema.String),
source: Source,
})
export type ReferencePromptMetadata = typeof ReferencePromptMetadata.Type
const decodeReferencePromptMetadata = Schema.decodeUnknownOption(ReferencePromptMetadata)
export function referencePromptMetadata(input: unknown) {
return Option.getOrUndefined(decodeReferencePromptMetadata(input))
}
export function referenceTextPart(input: {
reference: Reference.Resolved
source: ReferencePromptMetadata["source"]
target?: string
targetPath?: string
problem?: string
}): SessionV1.TextPartInput {
const metadata: ReferencePromptMetadata = {
name: input.reference.name,
kind: input.reference.kind,
...(input.reference.kind === "invalid"
? { repository: input.reference.repository }
: { path: input.reference.path }),
...(input.reference.kind === "git"
? { repository: input.reference.repository, branch: input.reference.branch }
: {}),
...(input.target === undefined ? {} : { target: input.target }),
...(input.targetPath ? { targetPath: input.targetPath } : {}),
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
source: input.source,
}
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
return {
type: "text",
synthetic: true,
text: [
`Referenced configured reference ${label}.`,
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
...(metadata.problem
? [`Problem: ${metadata.problem}`]
: ["Inspect the configured reference with Read, Glob, and Grep when useful."]),
].join("\n"),
metadata: { reference: metadata },
}
}
export * as ReferencePrompt from "./reference"

View File

@ -6,7 +6,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
import { Reference } from "@/reference/reference"
export const Parameters = Schema.Struct({
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
@ -19,7 +18,6 @@ export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const reference = yield* Reference.Service
const searchSvc = yield* Search.Service
return {
@ -40,13 +38,12 @@ export const GlobTool = Tool.define(
let search = params.path ?? ins.directory
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
yield* reference.ensure(search)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "File") {
throw new Error(`glob path must be a directory: ${search}`)
}
yield* assertExternalDirectoryEffect(ctx, search, {
bypass: yield* reference.contains(search),
bypass: false,
kind: "directory",
})

View File

@ -6,7 +6,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool"
import { Reference } from "@/reference/reference"
const MAX_LINE_LENGTH = 2000
@ -25,7 +24,6 @@ export const GrepTool = Tool.define(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const searchSvc = yield* Search.Service
const reference = yield* Reference.Service
return {
description: DESCRIPTION,
@ -56,10 +54,9 @@ export const GrepTool = Tool.define(
const requested = path.isAbsolute(params.path ?? ins.directory)
? (params.path ?? ins.directory)
: path.join(ins.directory, params.path ?? ".")
yield* reference.ensure(requested)
const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined)))
yield* assertExternalDirectoryEffect(ctx, requested, {
bypass: yield* reference.contains(requested),
bypass: false,
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
})

View File

@ -10,7 +10,6 @@ import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { Search } from "@opencode-ai/core/filesystem/search"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { Reference } from "@/reference/reference"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@ -66,14 +65,13 @@ type Metadata = {
export const ReadTool = Tool.define<
typeof Parameters,
Metadata,
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope
FSUtil.Service | Instruction.Service | LSP.Service | Search.Service | Scope.Scope
>(
"read",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const reference = yield* Reference.Service
const search = yield* Search.Service
const scope = yield* Scope.Scope
@ -243,7 +241,6 @@ export const ReadTool = Tool.define<
if (process.platform === "win32") {
filepath = FSUtil.normalizePath(filepath)
}
yield* reference.ensure(filepath)
const title = path.relative(instance.worktree, filepath)
const stat = yield* fs.stat(filepath).pipe(
@ -254,7 +251,7 @@ export const ReadTool = Tool.define<
)
yield* assertExternalDirectoryEffect(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.type === "Directory" ? "directory" : "file",
})

View File

@ -46,7 +46,6 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -91,7 +90,6 @@ export const layer: Layer.Layer<
| Session.Service
| BackgroundJob.Service
| Provider.Service
| Reference.Service
| LSP.Service
| Instruction.Service
| FSUtil.Service
@ -350,7 +348,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Session.defaultLayer),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(FSUtil.defaultLayer),

View File

@ -1,310 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Config } from "../../src/config/config"
import { ConfigReference } from "../../src/config/reference"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Git } from "../../src/git"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const it = testEffect(
Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
)
const references = testEffect(
Layer.mergeAll(
FSUtil.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
referenceLayer({ experimentalReferences: true }),
),
)
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
const waitForContent = (
fs: FSUtil.Interface,
file: string,
content: string,
attempts = 50,
): Effect.Effect<void, FSUtil.Error> =>
Effect.gen(function* () {
if ((yield* fs.readFileStringSafe(file)) === content) return
if (attempts <= 0) throw new Error(`timed out waiting for ${file}`)
yield* Effect.sleep("100 millis")
yield* waitForContent(fs, file, content, attempts - 1)
})
describe("reference", () => {
it.live("resolves supported local and git config forms", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const local = Reference.resolve({
name: "docs",
reference: ConfigReference.normalizeEntry({ path: "../docs" }),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const repo = Reference.resolve({
name: "effect",
reference: ConfigReference.normalizeEntry({ repository: "Effect-TS/effect", branch: "main" }),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const localString = Reference.resolve({
name: "notes",
reference: ConfigReference.normalizeEntry("./notes"),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const repoString = Reference.resolve({
name: "repo",
reference: ConfigReference.normalizeEntry("owner/repo"),
directory: path.join(root, "packages", "app"),
worktree: root,
})
expect(local.kind).toBe("local")
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
expect(localString.kind).toBe("local")
if (localString.kind === "local") expect(localString.path).toBe(path.resolve(root, "notes"))
expect(repo.kind).toBe("git")
if (repo.kind === "git") {
expect(repo.repository).toBe("Effect-TS/effect")
expect(repo.branch).toBe("main")
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
}
expect(repoString.kind).toBe("git")
if (repoString.kind === "git") {
expect(repoString.repository).toBe("owner/repo")
expect(repoString.path).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
}
}),
)
it.live("keeps invalid repository references visible without materializing", () =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
const references = yield* reference.list()
const invalid = yield* reference.get("bad")
expect(references.map((item) => item.name)).toEqual(["bad"])
expect(invalid).toMatchObject({
name: "bad",
kind: "invalid",
repository: "not-a-repo",
})
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
}),
{
config: {
reference: {
bad: "not-a-repo",
},
},
},
),
)
it.live("marks same-cache references with different branches invalid", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const references = Reference.resolveAll({
directory: root,
worktree: root,
references: ConfigReference.normalize({
main: { repository: "owner/repo", branch: "main" },
dev: { repository: "github.com/owner/repo", branch: "dev" },
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
}),
})
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
expect(references[1]?.kind).toBe("invalid")
if (references[1]?.kind === "invalid") {
expect(references[1].message).toContain("conflicts with @main")
expect(references[1].message).toContain("@dev requests dev")
}
}),
)
it.live("represents invalid aliases as invalid references", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const references = Reference.resolveAll({
directory: root,
worktree: root,
references: ConfigReference.normalize({
"bad/name": "owner/repo",
}),
})
expect(references).toEqual([
{
name: "bad/name",
kind: "invalid",
message: "Reference alias must not contain /, whitespace, comma, or backtick",
},
])
}),
)
references.live("materializes configured git references during init", () =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-reference-test")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const reference = yield* Reference.Service
yield* githubBase(
`file://${remoteRoot}/`,
Effect.gen(function* () {
yield* reference.init()
yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
}),
)
expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true)
expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n")
const resolved = yield* reference.get("docs")
expect(resolved?.kind).toBe("git")
if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
}),
{
config: {
reference: {
docs: "opencode-reference-test/repo",
},
},
},
),
)
references.live("refreshes configured git references on new instance init", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
yield* githubBase(
`file://${remoteRoot}/`,
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
yield* reference.init()
yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
}),
{
config: {
reference: {
docs: "opencode-reference-refresh/repo",
},
},
},
),
)
const branch = yield* git(source, ["branch", "--show-current"])
yield* git(source, ["remote", "add", "origin", remoteRepo])
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "update readme"])
yield* git(source, ["push", "origin", `${branch}:${branch}`])
yield* githubBase(
`file://${remoteRoot}/`,
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
yield* reference.init()
yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
}),
{
config: {
reference: {
docs: "opencode-reference-refresh/repo",
},
},
},
),
)
}),
)
})

View File

@ -670,7 +670,7 @@ const scenarios: Scenario[] = [
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected.get("/reference", "reference.list").json(200, array),
http.protected.get("/api/reference", "v2.reference.list").json(200, object),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))

View File

@ -94,17 +94,13 @@ describe("PublicApi OpenAPI v2 errors", () => {
}
})
test("documents optional project reference aliases for filesystem reads and lists", () => {
test("documents references separately from filesystem routes", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const path of ["/api/fs/read", "/api/fs/list"]) {
expect(spec.paths[path]?.get?.parameters, path).toContainEqual({
in: "query",
name: "reference",
required: false,
schema: { type: "string" },
})
expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
}
expect(spec.paths["/api/reference"]?.get).toBeDefined()
})
test("preserves required request bodies for v2 mutations", () => {

View File

@ -11,7 +11,7 @@ afterEach(async () => {
})
describe("reference HttpApi", () => {
test("lists presentation-safe references resolved in the server workspace", async () => {
test("lists usable references resolved in the server workspace", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
@ -24,30 +24,31 @@ describe("reference HttpApi", () => {
},
})
const response = await Server.Default().app.request("/reference", {
const response = await Server.Default().app.request("/api/reference", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual([
{
name: "docs",
kind: "local",
path: path.join(tmp.path, "docs"),
},
{
name: "effect",
kind: "git",
repository: "Effect-TS/effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
branch: "main",
},
{
name: "bad",
kind: "invalid",
repository: "not-a-repo",
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
},
])
const body = await response.json()
expect(body).toMatchObject({ location: { directory: tmp.path } })
expect(body.data).toEqual([
{
name: "docs",
path: path.join(tmp.path, "docs"),
source: {
type: "local",
path: path.join(tmp.path, "docs"),
},
},
{
name: "effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
source: {
type: "git",
repository: "Effect-TS/effect",
branch: "main",
},
},
])
})
})

View File

@ -50,8 +50,6 @@ import { Truncate } from "@/tool/truncate"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { TestInstance } from "../fixture/fixture"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
@ -191,9 +189,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
@ -219,7 +215,6 @@ function makePrompt(input?: { processor?: "blocking" }) {
return SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
@ -2021,92 +2016,6 @@ noLLMServer.instance(
{ config: cfg },
)
noLLMServer.instance(
"resolves configured reference mentions to one root directory attachment",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const docs = path.join(dir, "external-docs")
yield* ensureDir(path.join(docs, "guide"))
yield* ensureDir(path.join(dir, "docs"))
yield* writeText(path.join(docs, "README.md"), "reference readme")
yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro")
yield* writeText(path.join(dir, "docs", "README.md"), "workspace readme")
const prompt = yield* SessionPrompt.Service
const parts = yield* prompt.resolvePromptParts(
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
)
const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file")
const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent")
const text = parts.find((part): part is SessionV1.TextPartInput => part.type === "text" && !part.synthetic)
expect(text?.text).toContain("@docs")
expect(files).toHaveLength(1)
expect(files[0]).toMatchObject({
filename: "docs",
mime: "application/x-directory",
source: { type: "file", path: "docs", text: { value: "@docs" } },
})
expect(fileURLToPath(files[0].url)).toBe(docs)
expect(agents.map((agent) => agent.name)).toEqual(["build"])
}),
{
config: {
...cfg,
reference: {
docs: "./external-docs",
},
},
},
)
noLLMServer.instance(
"stores raw reference mentions alongside directory attachments",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const docs = path.join(dir, "external-docs")
yield* ensureDir(docs)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const message = yield* prompt.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: "text", text: "Use @docs for context" }],
})
const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const synthetic = stored.parts.filter(
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
)
const files = stored.parts.filter((part): part is SessionV1.FilePart => part.type === "file")
const text = stored.parts.find((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic)
expect(text?.text).toBe("Use @docs for context")
expect(synthetic.some((part) => part.text.includes(JSON.stringify({ filePath: docs })))).toBe(true)
expect(files).toHaveLength(1)
expect(files[0]).toMatchObject({
filename: "docs",
mime: "application/x-directory",
source: { type: "file", path: "docs", text: { value: "@docs", start: 4, end: 9 } },
})
expect(fileURLToPath(files[0].url)).toBe(docs)
yield* sessions.remove(session.id)
}),
{
config: {
...cfg,
reference: {
docs: "./external-docs",
},
},
},
)
// Special characters in filenames
noLLMServer.instance(

View File

@ -59,8 +59,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { RuntimeFlags } from "@/effect/runtime-flags"
const mcp = Layer.succeed(
@ -136,9 +134,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
@ -164,7 +160,6 @@ function makeHttp() {
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),

View File

@ -12,8 +12,6 @@ import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
@ -21,13 +19,6 @@ import { Filesystem } from "@/util/filesystem"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
@ -36,11 +27,9 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
referenceLayer(flags),
)
const it = testEffect(toolLayer())
const references = testEffect(toolLayer({ experimentalReferences: true }))
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const ctx = {
@ -145,44 +134,4 @@ describe("tool.glob", () => {
}),
)
references.instance(
"does not ask for external_directory permission inside configured git references",
() =>
Effect.gen(function* () {
yield* TestInstance
const fs = yield* FSUtil.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add source"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const { items, next } = asks()
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
)
expect(result.metadata.count).toBe(1)
expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts")))
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
}),
{
config: {
reference: {
docs: "opencode-glob-reference/repo",
},
},
},
)
})

View File

@ -14,8 +14,6 @@ import { Agent } from "../../src/agent/agent"
import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
import { Config } from "@/config/config"
@ -23,13 +21,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
@ -38,11 +29,9 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
referenceLayer(flags),
)
const it = testEffect(toolLayer())
const references = testEffect(toolLayer({ experimentalReferences: true }))
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
const ctx = {
@ -215,52 +204,4 @@ describe("tool.grep", () => {
}),
)
references.instance(
"does not ask for external_directory permission inside configured git references",
() =>
Effect.gen(function* () {
yield* TestInstance
const appfs = yield* FSUtil.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add notes"])
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
requests.push(req)
}),
}
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
)
expect(result.metadata.matches).toBe(1)
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
}),
{
config: {
reference: {
docs: "opencode-grep-reference/repo",
},
},
},
)
})

View File

@ -25,8 +25,6 @@ import {
tmpdirScoped,
} from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@ -45,13 +43,6 @@ const ctx = {
ask: () => Effect.void,
}
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
@ -59,13 +50,11 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
referenceLayer(flags),
Search.defaultLayer,
Truncate.defaultLayer,
)
const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
const references = testEffect(Layer.mergeAll(readLayer({ experimentalReferences: true }), testInstanceStoreLayer))
const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool
@ -266,43 +255,6 @@ describe("tool.read external_directory permission", () => {
}),
)
references.live("does not ask for external_directory permission when reading configured references", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "opencode-read-reference")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* put(path.join(source, "notes.md"), "reference notes")
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add notes"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const dir = yield* tmpdirScoped({
git: true,
config: {
reference: {
docs: "opencode-read-reference/repo",
},
},
})
const { items, next } = asks()
const result = yield* githubBase(
`file://${remoteRoot}/`,
exec(dir, { filePath: path.join(cache, "notes.md") }, next),
)
const ext = items.find((item) => item.permission === "external_directory")
expect(result.output).toContain("reference notes")
expect(ext).toBeUndefined()
}),
)
})
describe("tool.read env file permissions", () => {

View File

@ -29,8 +29,6 @@ import { Format } from "@/format"
import { Search } from "@opencode-ai/core/filesystem/search"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
@ -60,8 +58,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
Layer.provide(Session.defaultLayer),
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
Layer.provide(Provider.defaultLayer),
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
Layer.provide(Reference.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(FSUtil.defaultLayer),

View File

@ -172,8 +172,6 @@ import type {
QuestionReplyErrors,
QuestionReplyResponses,
QuestionV2Reply,
ReferenceListErrors,
ReferenceListResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@ -288,6 +286,8 @@ import type {
V2ProviderListResponses,
V2QuestionRequestListErrors,
V2QuestionRequestListResponses,
V2ReferenceListErrors,
V2ReferenceListResponses,
V2SessionCompactErrors,
V2SessionCompactResponses,
V2SessionContextErrors,
@ -3335,38 +3335,6 @@ export class Provider extends HeyApiClient {
}
}
export class Reference extends HeyApiClient {
/**
* List configured references
*
* List configured references resolved in the current workspace.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ReferenceListResponses, ReferenceListErrors, ThrowOnError>({
url: "/reference",
...options,
...params,
})
}
}
export class Session2 extends HeyApiClient {
/**
* List sessions
@ -5580,7 +5548,6 @@ export class Fs extends HeyApiClient {
workspace?: string
}
path: string
reference?: string
},
options?: Options<never, ThrowOnError>,
) {
@ -5591,7 +5558,6 @@ export class Fs extends HeyApiClient {
args: [
{ in: "query", key: "location" },
{ in: "query", key: "path" },
{ in: "query", key: "reference" },
],
},
],
@ -5615,7 +5581,6 @@ export class Fs extends HeyApiClient {
workspace?: string
}
path?: string
reference?: string
},
options?: Options<never, ThrowOnError>,
) {
@ -5626,7 +5591,6 @@ export class Fs extends HeyApiClient {
args: [
{ in: "query", key: "location" },
{ in: "query", key: "path" },
{ in: "query", key: "reference" },
],
},
],
@ -5746,6 +5710,30 @@ export class Question3 extends HeyApiClient {
}
}
export class Reference extends HeyApiClient {
/**
* List references
*
* List references available in the requested location.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ReferenceListResponses, V2ReferenceListErrors, ThrowOnError>({
url: "/api/reference",
...options,
...params,
})
}
}
export class V2 extends HeyApiClient {
private _health?: Health
get health(): Health {
@ -5801,6 +5789,11 @@ export class V2 extends HeyApiClient {
get question(): Question3 {
return (this._question ??= new Question3({ client: this.client }))
}
private _reference?: Reference
get reference(): Reference {
return (this._reference ??= new Reference({ client: this.client }))
}
}
export class OpencodeClient extends HeyApiClient {
@ -5921,11 +5914,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._provider ??= new Provider({ client: this.client }))
}
private _reference?: Reference
get reference(): Reference {
return (this._reference ??= new Reference({ client: this.client }))
}
private _session?: Session2
get session(): Session2 {
return (this._session ??= new Session2({ client: this.client }))

View File

@ -57,6 +57,7 @@ export type Event =
| EventAccountSwitched
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventReferenceUpdated
| EventFileWatcherUpdated
| EventPtyCreated
| EventPtyUpdated
@ -633,7 +634,6 @@ export type Prompt = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
}
export type Pty = {
@ -1296,6 +1296,13 @@ export type GlobalEvent = {
reply: PermissionV2Reply
}
}
| {
id: string
type: "reference.updated"
properties: {
[key: string]: unknown
}
}
| {
id: string
type: "file.watcher.updated"
@ -2590,26 +2597,6 @@ export type ProviderAuthError1 = {
}
}
export type ReferenceDescriptor =
| {
name: string
kind: "local"
path: string
}
| {
name: string
kind: "git"
repository: string
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository?: string
message: string
}
export type NotFoundError = {
name: "NotFoundError"
data: {
@ -2986,18 +2973,6 @@ export type PromptAgentAttachment = {
source?: PromptSource
}
export type PromptReferenceAttachment = {
name: string
kind: "local" | "git" | "invalid"
uri?: string
repository?: string
branch?: string
target?: string
targetUri?: string
problem?: string
source?: PromptSource
}
export type SessionErrorUnknown = {
type: "unknown"
message: string
@ -3896,7 +3871,6 @@ export type SessionMessageUser = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
type: "user"
}
@ -4212,6 +4186,23 @@ export type QuestionV2Reply = {
answers: Array<QuestionV2Answer>
}
export type ReferenceLocalSource = {
type: "local"
path: string
}
export type ReferenceGitSource = {
type: "git"
repository: string
branch?: string
}
export type ReferenceInfo = {
name: string
path: string
source: ReferenceLocalSource | ReferenceGitSource
}
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
@ -4933,6 +4924,14 @@ export type EventPermissionV2Replied = {
}
}
export type EventReferenceUpdated = {
id: string
type: "reference.updated"
properties: {
[key: string]: unknown
}
}
export type EventFileWatcherUpdated = {
id: string
type: "file.watcher.updated"
@ -7640,34 +7639,6 @@ export type ProviderOauthCallbackResponses = {
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
export type ReferenceListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/reference"
}
export type ReferenceListErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ReferenceListError = ReferenceListErrors[keyof ReferenceListErrors]
export type ReferenceListResponses = {
/**
* Resolved configured references
*/
200: Array<ReferenceDescriptor>
}
export type ReferenceListResponse = ReferenceListResponses[keyof ReferenceListResponses]
export type SessionListData = {
body?: never
path?: never
@ -10098,7 +10069,6 @@ export type V2FsReadData = {
workspace?: string
}
path: string
reference?: string
}
url: "/api/fs/read"
}
@ -10137,7 +10107,6 @@ export type V2FsListData = {
workspace?: string
}
path?: string
reference?: string
}
url: "/api/fs/list"
}
@ -10384,6 +10353,43 @@ export type V2SessionQuestionRejectResponses = {
export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses]
export type V2ReferenceListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/reference"
}
export type V2ReferenceListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors]
export type V2ReferenceListResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Array<ReferenceInfo>
}
}
export type V2ReferenceListResponse = V2ReferenceListResponses[keyof V2ReferenceListResponses]
export type PtyConnectData = {
body?: never
path: {

View File

@ -5400,64 +5400,6 @@
]
}
},
"/reference": {
"get": {
"tags": ["reference"],
"operationId": "reference.list",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Resolved configured references",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReferenceDescriptor"
},
"description": "Resolved configured references"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "List configured references resolved in the current workspace.",
"summary": "List configured references",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.reference.list({\n ...\n})"
}
]
}
},
"/session": {
"get": {
"tags": ["session"],
@ -11414,14 +11356,6 @@
"type": "string"
},
"required": true
},
{
"name": "reference",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"security": [],
@ -11515,14 +11449,6 @@
"type": "string"
},
"required": false
},
{
"name": "reference",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"security": [],
@ -12062,6 +11988,87 @@
]
}
},
"/api/reference": {
"get": {
"tags": ["reference"],
"operationId": "v2.reference.list",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspace": {
"type": "string"
}
},
"additionalProperties": false
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/LocationInfo"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReferenceInfo"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "List references available in the requested location.",
"summary": "List references",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.reference.list({\n ...\n})"
}
]
}
},
"/pty/{ptyID}/connect": {
"get": {
"tags": ["pty"],
@ -14068,12 +14075,6 @@
"items": {
"$ref": "#/components/schemas/PromptAgentAttachment"
}
},
"references": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptReferenceAttachment"
}
}
},
"required": ["text"],
@ -20041,70 +20042,6 @@
"required": ["name", "data"],
"additionalProperties": false
},
"ReferenceDescriptor": {
"anyOf": [
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["name", "kind", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["git"]
},
"repository": {
"type": "string"
},
"path": {
"type": "string"
},
"branch": {
"type": "string"
}
},
"required": ["name", "kind", "repository", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["invalid"]
},
"repository": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["name", "kind", "message"],
"additionalProperties": false
}
]
},
"NotFoundError": {
"type": "object",
"required": ["name", "data"],
@ -21498,41 +21435,6 @@
"required": ["name"],
"additionalProperties": false
},
"PromptReferenceAttachment": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["local", "git", "invalid"]
},
"uri": {
"type": "string"
},
"repository": {
"type": "string"
},
"branch": {
"type": "string"
},
"target": {
"type": "string"
},
"targetUri": {
"type": "string"
},
"problem": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/PromptSource"
}
},
"required": ["name", "kind"],
"additionalProperties": false
},
"SessionErrorUnknown": {
"type": "object",
"properties": {
@ -24339,12 +24241,6 @@
"$ref": "#/components/schemas/PromptAgentAttachment"
}
},
"references": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptReferenceAttachment"
}
},
"type": {
"type": "string",
"enum": ["user"]
@ -25214,6 +25110,60 @@
"required": ["answers"],
"additionalProperties": false
},
"ReferenceLocalSource": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["type", "path"],
"additionalProperties": false
},
"ReferenceGitSource": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["git"]
},
"repository": {
"type": "string"
},
"branch": {
"type": "string"
}
},
"required": ["type", "repository"],
"additionalProperties": false
},
"ReferenceInfo": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"source": {
"anyOf": [
{
"$ref": "#/components/schemas/ReferenceLocalSource"
},
{
"$ref": "#/components/schemas/ReferenceGitSource"
}
]
}
},
"required": ["name", "path", "source"],
"additionalProperties": false
},
"EventModels-devRefreshed": {
"type": "object",
"properties": {
@ -28767,10 +28717,6 @@
"name": "provider",
"description": "Experimental HttpApi provider routes."
},
{
"name": "reference",
"description": "Configured reference routes."
},
{
"name": "session",
"description": "Experimental HttpApi session routes."
@ -28835,6 +28781,10 @@
"name": "session questions",
"description": "Experimental session question routes."
},
{
"name": "reference",
"description": "Location-scoped project references."
},
{
"name": "pty",
"description": "PTY websocket route."

View File

@ -12,6 +12,7 @@ import { EventGroup } from "./groups/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
export const Api = HttpApi.make("server")
@ -27,6 +28,7 @@ export const Api = HttpApi.make("server")
.add(SkillGroup)
.add(EventGroup)
.add(QuestionGroup)
.add(ReferenceGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",

View File

@ -8,13 +8,11 @@ import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./locat
const ReadQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath,
reference: Schema.String.pipe(Schema.optional),
})
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export const FileSystemGroup = HttpApiGroup.make("server.fs")

View File

@ -0,0 +1,28 @@
import { Location } from "@opencode-ai/core/location"
import { Reference } from "@opencode-ai/core/reference"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
export const ReferenceGroup = HttpApiGroup.make("server.reference")
.add(
HttpApiEndpoint.get("reference.list", "/api/reference", {
query: LocationQuery,
success: Location.response(Schema.Array(Reference.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.reference.list",
summary: "List references",
description: "List references available in the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "reference",
description: "Location-scoped project references.",
}),
)
.middleware(LocationMiddleware)

View File

@ -16,6 +16,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
export const handlers = Layer.mergeAll(
@ -31,6 +32,7 @@ export const handlers = Layer.mergeAll(
SkillHandler,
EventHandler,
QuestionHandler,
ReferenceHandler,
).pipe(
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),

View File

@ -0,0 +1,8 @@
import { Reference } from "@opencode-ai/core/reference"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
)

View File

@ -9,6 +9,7 @@ import { useEditorContext } from "../../context/editor"
import { useProject } from "../../context/project"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useSyncV2 } from "../../context/sync-v2"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config"
@ -19,7 +20,6 @@ import { Locale } from "../../util/locale"
import type { PromptInfo } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import type { ReferenceDescriptor } from "@opencode-ai/sdk/v2"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
function removeLineRange(input: string) {
@ -85,6 +85,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const syncV2 = useSyncV2()
const project = useProject()
const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack()
@ -272,37 +273,12 @@ export function Autocomplete(props: {
}
}
function referencePromptText(reference: ReferenceDescriptor) {
const problem = reference.kind === "invalid" ? reference.message : undefined
return [
`Referenced configured reference @${reference.name}.`,
...(reference.kind === "local" ? ["Kind: local directory"] : []),
...(reference.kind === "git" ? ["Kind: git repository"] : []),
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
...(problem
? [`Problem: ${problem}`]
: ["Inspect the configured reference with Read, Glob, and Grep when useful."]),
].join("\n")
}
const [references] = createResource(
() => project.workspace.current(),
async (workspace) => {
const result = await sdk.client.reference.list({ workspace })
return result.data ?? []
},
{ initialValue: [] },
)
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
return references().find((item) => item.name === alias)
return syncV2.data.reference.find((item) => item.name === alias)
})
function normalizeMentionPath(filePath: string) {
@ -435,29 +411,21 @@ export function Autocomplete(props: {
})
const referenceAliases = createMemo(() =>
references().map(
syncV2.data.reference.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: reference.kind === "invalid" ? reference.message : " dir",
description: " dir",
onSelect: () => {
if (reference.kind !== "invalid") {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
return
}
insertPart(reference.name, {
type: "text",
text: referencePromptText(reference),
synthetic: true,
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),

View File

@ -1,6 +1,7 @@
import { useEvent } from "./event"
import type {
Event,
ReferenceInfo,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@ -10,6 +11,8 @@ import type {
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useProject } from "./project"
import { createEffect } from "solid-js"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
@ -60,12 +63,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
messages: {
[sessionID: string]: SessionMessage[]
}
reference: ReferenceInfo[]
}>({
messages: {},
reference: [],
})
const event = useEvent()
const sdk = useSDK()
const project = useProject()
const applied = new Set<string>()
const buffering = new Map<string, Event[]>()
const syncing = new Map<string, Promise<void>>()
@ -117,6 +123,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
return result
}
async function syncReferences(workspace = project.workspace.current()) {
const result = await sdk.client.v2.reference.list({ location: { workspace } })
if (workspace !== project.workspace.current()) return
setStore("reference", reconcile(result.data?.data ?? []))
}
createEffect(() => {
project.workspace.current()
void syncReferences()
})
function apply(event: Event) {
switch (event.type) {
case "session.next.agent.switched":
@ -147,7 +164,6 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
references: event.properties.prompt.references,
time: { created: event.properties.timestamp },
})
})
@ -163,7 +179,6 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
references: event.properties.prompt.references,
time: { created: event.properties.timeCreated },
})
})
@ -418,6 +433,9 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
})
break
case "reference.updated":
void syncReferences()
break
}
}

View File

@ -27,6 +27,52 @@ function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event)
events.emit(event)
}
test("sync v2 refreshes references after updates", async () => {
const events = createEventSource()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/reference") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
})
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await wait(() => requests === 1)
events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} }))
await wait(() => sync.data.reference.length === 1)
expect(sync.data.reference[0]?.name).toBe("docs")
} finally {
app.renderer.destroy()
}
})
test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()

View File

@ -60,6 +60,7 @@ export function createFetch(override?: FetchHandler) {
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/project/current") return json({ id: "proj_test" })
if (url.pathname === "/api/reference") return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })