feat(core): add project reference guidance (#31601)

This commit is contained in:
Dax 2026-06-10 00:08:26 -04:00 committed by GitHub
parent 0fc33e2a06
commit 8a2cfc00c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 753 additions and 165 deletions

View File

@ -2,8 +2,15 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
"references": {
"effect": {
"repository": "github.com/Effect-TS/effect-smol",
"description": "Use for Effect v4 and effect-smol implementation details",
},
"opencode-local": {
"path": "~/.local/share/opencode",
"description": "Contains opencode logs and data",
},
},
"mcp": {},
"tools": {

View File

@ -33,11 +33,15 @@ export const Plugin = {
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}

View File

@ -5,10 +5,14 @@ import { Schema } from "effect"
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])

View File

@ -1,4 +1,4 @@
import { Layer, LayerMap } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
@ -23,6 +23,7 @@ import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { FileMutation } from "./file-mutation"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
@ -45,6 +46,9 @@ import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
@ -74,6 +78,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
@ -89,10 +94,21 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
)
return Layer.mergeAll(services, image, mutation, resources, todos, questions, model, runner, builtInTools).pipe(
Layer.fresh,
)
return Layer.mergeAll(
boot,
services,
image,
mutation,
resources,
todos,
questions,
model,
runner,
builtInTools,
referenceGuidance,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [

View File

@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export const Ref = Schema.Struct({
export class Ref extends Schema.Class<Ref>("Location.Ref")({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}) {}
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,

View File

@ -73,6 +73,19 @@ Every field is optional.
"urls": ["https://example.com/.well-known/skills/"]
},
"references": {
"docs": {
"path": "../docs",
"description": "Use for product behavior and documentation conventions"
},
"sdk": {
"repository": "owner/sdk",
"branch": "main",
"description": "Use for SDK implementation details",
"hidden": true
}
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
@ -136,6 +149,7 @@ Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
@ -172,6 +186,38 @@ Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## References
References make local directories and Git repositories outside the active
project available as supporting context. Configure them under `references`,
keyed by the alias used in `@` autocomplete:
```json
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details"
}
}
}
```
Local `path` values may be relative to the declaring config, absolute, or use
`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub
`owner/repo` shorthand; `branch` is optional. Both forms support optional
`description` and `hidden` fields.
- Only references with a `description` are advertised to agents in system context.
- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path.
- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply.
- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories.
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.

View File

@ -9,21 +9,19 @@ 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,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
@ -33,6 +31,14 @@ export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
source: Source,
}) {}
type Data = {
sources: Map<string, Source>
}
@ -71,7 +77,16 @@ export const layer = Layer.effect(
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 }))
materialized.set(
name,
new Info({
name,
path: source.path,
description: source.description,
hidden: source.hidden,
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
@ -86,7 +101,16 @@ export const layer = Layer.effect(
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 }))
materialized.set(
name,
new Info({
name,
path: AbsolutePath.make(target),
description: source.description,
hidden: source.hidden,
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {

View File

@ -0,0 +1,69 @@
export * as ReferenceGuidance from "./guidance"
import { Context, Effect, Layer, Schema } from "effect"
import { PluginBoot } from "../plugin/boot"
import { Reference } from "../reference"
import { SystemContext } from "../system-context/index"
const Summary = Schema.Struct({
name: Schema.String,
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
})
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
[
"Project references provide additional directories that can be accessed when relevant.",
"<available_references>",
...references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
]),
"</available_references>",
].join("\n")
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const boot = yield* PluginBoot.Service
const references = yield* Reference.Service
return Service.of({
load: Effect.fn("ReferenceGuidance.load")(function* () {
yield* boot.wait()
const available = (yield* references.list())
.filter((reference) => reference.description !== undefined)
.map((reference) => ({
name: reference.name,
path: reference.path,
description: reference.description,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
if (available.length === 0) return SystemContext.empty
return SystemContext.make({
key: SystemContext.Key.make("core/reference-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,
update: (_previous, current) =>
[
"The available project references have changed. This list supersedes the previous reference list.",
render(current),
].join("\n"),
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
})
}),
})
}),
)
export const locationLayer = layer

View File

@ -161,10 +161,10 @@ export const layer = Layer.effect(
args: [
"--no-config",
"--files",
"--glob=!**/.git/**",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) =>
@ -195,10 +195,10 @@ export const layer = Layer.effect(
args: [
"--no-config",
"--files",
"--glob=!**/.git/**",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
@ -226,9 +226,9 @@ export const layer = Layer.effect(
"--no-config",
"--json",
"--hidden",
"--glob=!**/.git/**",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",

View File

@ -19,6 +19,7 @@ import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
@ -98,6 +99,7 @@ export const layer = Layer.effect(
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
@ -166,9 +168,9 @@ export const layer = Layer.effect(
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe(
Effect.map(SystemContext.combine),
)
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded",
}).pipe(Effect.map(SystemContext.combine))
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,

View File

@ -3,6 +3,7 @@ export * as ConfigV1 from "./config"
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigExperimental } from "../../config/experimental"
import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment"
import { ConfigCommandV1 } from "./command"
@ -13,7 +14,6 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigPluginV1 } from "./plugin"
import { ConfigProviderV1 } from "./provider"
import { ConfigReferenceV1 } from "./reference"
import { ConfigServerV1 } from "./server"
import { ConfigSkillsV1 } from "./skills"
@ -42,7 +42,7 @@ export const Info = Schema.Struct({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }),
reference: Schema.optional(ConfigReferenceV1.Info).annotate({
references: Schema.optional(ConfigReference.Info).annotate({
description: "Named git or local directory references",
}),
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),

View File

@ -12,7 +12,6 @@ const keys = new Set([
"logLevel",
"server",
"command",
"reference",
"snapshot",
"plugin",
"autoshare",
@ -63,7 +62,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: info.command,
instructions: info.instructions,
references: info.reference,
references: info.references,
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),

View File

@ -1,24 +0,0 @@
export * as ConfigReferenceV1 from "./reference"
import { Schema } from "effect"
const Git = Schema.Struct({
repository: Schema.String.annotate({
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
}),
branch: Schema.optional(Schema.String).annotate({
description: "Branch or ref to clone and inspect",
}),
})
const Local = Schema.Struct({
path: Schema.String.annotate({
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
}),
})
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
export type Entry = Schema.Schema.Type<typeof Entry>
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
export type Info = Schema.Schema.Type<typeof Info>

View File

@ -464,7 +464,9 @@ describe("Config", () => {
["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
],
skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
reference: { docs: { path: "../docs" } },
references: {
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
},
attachment: { image: { auto_resize: false, max_width: 1200 } },
provider: {
custom: {
@ -540,7 +542,9 @@ describe("Config", () => {
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
})
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.providers?.custom).toMatchObject({
request: { body: { apiKey: "secret" } },

View File

@ -1,10 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Effect, Equal, Hash, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -44,6 +45,16 @@ const it = testEffect(
)
describe("LocationServiceMap", () => {
it.effect("compares equivalent location refs by value", () =>
Effect.sync(() => {
const directory = AbsolutePath.make("/project")
expect(Equal.equals(Location.Ref.make({ directory }), Location.Ref.make({ directory }))).toBe(true)
expect(Hash.hash(Location.Ref.make({ directory }))).toBe(
Hash.hash(Location.Ref.make({ directory, workspaceID: undefined })),
)
}),
)
it.live("isolates location state while sharing location policy with catalog", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
@ -79,7 +90,10 @@ describe("LocationServiceMap", () => {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
}
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
}).pipe(
Effect.scoped,
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
const blockedState = yield* update(blocked.path)
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)

View File

@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { SystemContext } from "@opencode-ai/core/system-context/index"
import { it } from "./lib/effect"
describe("ReferenceGuidance", () => {
it.effect("lists available references in the system context", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toContain("<available_references>")
expect(generation.baseline).toContain("<name>docs</name>")
expect(generation.baseline).toContain("<path>/docs</path>")
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
source: new Reference.LocalSource({
type: "local",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
}),
}),
]),
}),
),
Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })),
),
)
it.effect("omits guidance when no references are available", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })),
Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })),
),
)
it.effect("omits references without descriptions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
source: new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/docs") }),
}),
]),
}),
),
Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })),
),
)
})

View File

@ -19,10 +19,16 @@ describe("Reference", () => {
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 })))
const source = new Reference.LocalSource({
type: "local",
path,
description: "Use for API documentation",
hidden: true,
})
yield* update((editor) => editor.add("docs", source))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }),
new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
])
yield* Scope.close(scope, Exit.void)
@ -58,4 +64,33 @@ describe("Reference", () => {
Effect.provide(Global.defaultLayer),
),
)
it.effect("preserves configured Git descriptions", () =>
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",
description: "Use for SDK implementation details",
})
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)),
description: "Use for SDK implementation details",
source,
}),
])
}).pipe(
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
)
})

View File

@ -10,7 +10,27 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Ripgrep.defaultLayer)
describe("Ripgrep", () => {
it.live("allows caller globs to re-include git metadata", () =>
it.live("keeps ignored files out of catch-all find results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "node_modules", "pkg"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
yield* Effect.promise(() => Bun.$`git init -q ${tmp.path}`)
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".gitignore"), "node_modules/\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "node_modules", "pkg", "index.js"), "ignored\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "index.js"), "included\n"))
const files = yield* (yield* Ripgrep.Service).find({ cwd: tmp.path, pattern: "*", limit: 10 })
expect(files.map((item) => item.path)).toContain(RelativePath.make("src/index.js"))
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("node_modules/pkg/index.js"))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("never includes git metadata", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@ -23,7 +43,7 @@ describe("Ripgrep", () => {
const files = yield* ripgrep.find({ cwd: tmp.path, pattern: "**/*", limit: 10 })
expect(files.map((item) => item.path)).toContain(RelativePath.make(".opencode/config"))
expect(files.map((item) => item.path)).toContain(RelativePath.make(".git/config"))
expect(files.map((item) => item.path)).not.toContain(RelativePath.make(".git/config"))
const observed: string[] = []
const limited = yield* ripgrep.find({
@ -36,7 +56,7 @@ describe("Ripgrep", () => {
const matches = yield* ripgrep.grep({ cwd: tmp.path, pattern: "needle", include: "config", limit: 10 })
expect(matches.map((item) => item.entry.path)).toContain(RelativePath.make(".opencode/config"))
expect(matches.map((item) => item.entry.path)).toContain(RelativePath.make(".git/config"))
expect(matches.map((item) => item.entry.path)).not.toContain(RelativePath.make(".git/config"))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),

View File

@ -25,6 +25,7 @@ import { Location } from "@opencode-ai/core/location"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@ -70,6 +71,7 @@ const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
@ -82,6 +84,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))

View File

@ -48,6 +48,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -215,6 +216,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, {
: SystemContext.empty,
),
})
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@ -243,6 +245,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))

View File

@ -24,9 +24,13 @@ import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/core/location"
export const Info = Schema.Struct({
name: Schema.String,
@ -89,15 +93,21 @@ export const layer = Layer.effect(
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const locations = yield* LocationServiceMap
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const referenceDirs = yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
...referenceDirs.map((dir) => path.join(dir, "*")),
]
const readonlyExternalDirectory = {
"*": "ask",
@ -429,8 +439,18 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)
export const node = LayerNode.make(layer, [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node])
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
export const node = LayerNode.make(layer, [
Config.node,
Auth.node,
Plugin.node,
Skill.node,
Provider.node,
locationServiceMapNode,
])
export * as Agent from "./agent"

View File

@ -2,13 +2,14 @@ import { EOL } from "os"
import { Effect } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))),
Effect.provide(LocationServiceMap.layer),
)

View File

@ -2,6 +2,7 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
@ -37,9 +38,11 @@ export const V2Command = effectCmd({
}).pipe(
Effect.withSpan("Cli.debug.v2"),
Effect.provide(
LocationServiceMap.get({
directory: AbsolutePath.make(process.cwd()),
}),
LocationServiceMap.get(
Location.Ref.make({
directory: AbsolutePath.make(process.cwd()),
}),
),
),
Effect.provide(LocationServiceMap.layer),
),

View File

@ -18,7 +18,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
Effect.provide(
locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
),
)
})

View File

@ -7,6 +7,7 @@ import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
@ -41,13 +42,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
Effect.provide(
locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
),
)
})
@ -158,13 +161,15 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
Effect.provide(
locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
),
)
})

View File

@ -16,6 +16,11 @@ import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { Skill } from "@/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
@ -44,10 +49,15 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
const locations = yield* LocationServiceMap
return Service.of({
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
const ctx = yield* InstanceState.context
const references = yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
@ -60,7 +70,25 @@ export const layer = Layer.effect(
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
references.length === 0
? undefined
: [
"Project references provide additional directories that can be accessed when relevant.",
"<available_references>",
...references
.toSorted((a, b) => a.name.localeCompare(b.name))
.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined
? []
: [` <description>${reference.description}</description>`]),
" </reference>",
]),
"</available_references>",
].join("\n"),
].filter((part): part is string => part !== undefined)
}),
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
@ -80,8 +108,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer))
export const node = LayerNode.make(layer, [Skill.node])
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode])
export * as SystemPrompt from "./system"

View File

@ -14,6 +14,7 @@ import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
import { Truncate } from "../../src/tool/truncate"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Agent.layer.pipe(
@ -22,6 +23,7 @@ const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(RuntimeFlags.layer(flags)),
)
@ -119,7 +121,7 @@ it.instance(
}),
{
config: {
reference: {
references: {
effect: "github.com/effect/effect-smol",
effectFull: {
repository: "Effect-TS/effect",
@ -601,6 +603,25 @@ description: Permission skill.
{ git: true },
)
it.instance(
"project reference directories are allowed for external_directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const build = yield* load((svc) => svc.get("build"))
const target = path.resolve(test.directory, "../docs/reference/notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
}),
{
git: true,
config: {
references: {
docs: "../docs",
},
},
},
)
it.instance("defaultAgent returns build when no default_agent config", () =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultAgent())

View File

@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path"
@ -43,6 +44,7 @@ const agentLayer = Agent.layer.pipe(
Layer.provide(SkillTest.empty),
Layer.provide(provider.layer),
Layer.provide(pluginLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)

View File

@ -16,7 +16,7 @@ describe("reference HttpApi", () => {
config: {
formatter: false,
lsp: false,
reference: {
references: {
docs: "./docs",
effect: { repository: "Effect-TS/effect", branch: "main" },
bad: "not-a-repo",
@ -35,18 +35,26 @@ describe("reference HttpApi", () => {
{
name: "docs",
path: path.join(tmp.path, "docs"),
description: null,
hidden: null,
source: {
type: "local",
path: path.join(tmp.path, "docs"),
description: null,
hidden: null,
},
},
{
name: "effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
description: null,
hidden: null,
source: {
type: "git",
repository: "Effect-TS/effect",
branch: "main",
description: null,
hidden: null,
},
},
])

View File

@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { Skill } from "../../src/skill"
import { Permission } from "../../src/permission"
import { SystemPrompt } from "../../src/session/system"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { testEffect } from "../lib/effect"
const skills: Skill.Info[] = [
@ -42,6 +43,7 @@ const build: Agent.Info = {
const it = testEffect(
SystemPrompt.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(
Layer.succeed(
Skill.Service,

View File

@ -1685,26 +1685,6 @@ export type ServerConfig = {
cors?: Array<string>
}
export type ReferenceConfigEntry =
| string
| {
/**
* Git repository URL, host/path reference, or GitHub owner/repo shorthand
*/
repository: string
branch?: string
}
| {
/**
* Absolute path, ~/ path, or workspace-relative path to a local reference directory
*/
path: string
}
export type ReferenceConfig = {
[key: string]: ReferenceConfigEntry
}
export type PermissionActionConfig = "ask" | "allow" | "deny"
export type PermissionObjectConfig = {
@ -1948,7 +1928,9 @@ export type Config = {
paths?: Array<string>
urls?: Array<string>
}
reference?: ReferenceConfig
references?: {
[key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal
}
watcher?: {
ignore?: Array<string>
}
@ -3723,6 +3705,19 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type ConfigV2ReferenceGit = {
repository: string
branch?: string
description?: string
hidden?: boolean
}
export type ConfigV2ReferenceLocal = {
path: string
description?: string
hidden?: boolean
}
export type PolicyEffect = "allow" | "deny"
export type ConfigV2ExperimentalPolicy = {
@ -4174,17 +4169,23 @@ export type QuestionV2Reply = {
export type ReferenceLocalSource = {
type: "local"
path: string
description?: string
hidden?: boolean
}
export type ReferenceGitSource = {
type: "git"
repository: string
branch?: string
description?: string
hidden?: boolean
}
export type ReferenceInfo = {
name: string
path: string
description?: string
hidden?: boolean
source: ReferenceLocalSource | ReferenceGitSource
}

View File

@ -17628,44 +17628,6 @@
"additionalProperties": false,
"description": "Server configuration for opencode serve and web commands"
},
"ReferenceConfigEntry": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"repository": {
"type": "string",
"description": "Git repository URL, host/path reference, or GitHub owner/repo shorthand"
},
"branch": {
"type": "string"
}
},
"required": ["repository"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path, ~/ path, or workspace-relative path to a local reference directory"
}
},
"required": ["path"],
"additionalProperties": false
}
]
},
"ReferenceConfig": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/ReferenceConfigEntry"
}
},
"PermissionActionConfig": {
"type": "string",
"enum": ["ask", "allow", "deny"]
@ -18256,8 +18218,21 @@
},
"additionalProperties": false
},
"reference": {
"$ref": "#/components/schemas/ReferenceConfig"
"references": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"$ref": "#/components/schemas/ConfigV2ReferenceGit"
},
{
"$ref": "#/components/schemas/ConfigV2ReferenceLocal"
}
]
}
},
"watcher": {
"type": "object",
@ -23918,6 +23893,41 @@
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"ConfigV2ReferenceGit": {
"type": "object",
"properties": {
"repository": {
"type": "string"
},
"branch": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["repository"],
"additionalProperties": false
},
"ConfigV2ReferenceLocal": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["path"],
"additionalProperties": false
},
"PolicyEffect": {
"type": "string",
"enum": ["allow", "deny"]
@ -25190,6 +25200,12 @@
},
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "path"],
@ -25207,6 +25223,12 @@
},
"branch": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "repository"],
@ -25221,6 +25243,12 @@
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"source": {
"anyOf": [
{

View File

@ -57,12 +57,12 @@ export class LocationMiddleware extends HttpApiMiddleware.Service<
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
return {
return Location.Ref.make({
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
}
})
}
export const layer = Layer.effect(

View File

@ -1,5 +1,6 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
@ -54,10 +55,12 @@ export const sessionLocationLayer = Layer.effect(
return yield* effect.pipe(
Effect.provide(
locations.get({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),

View File

@ -278,7 +278,7 @@ export function Autocomplete(props: {
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
return syncV2.data.reference.find((item) => item.name === alias)
return syncV2.data.reference.find((item) => !item.hidden && item.name === alias)
})
function normalizeMentionPath(filePath: string) {
@ -411,25 +411,27 @@ export function Autocomplete(props: {
})
const referenceAliases = createMemo(() =>
syncV2.data.reference.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: " dir",
onSelect: () => {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
syncV2.data.reference
.filter((reference) => !reference.hidden)
.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
onSelect: () => {
insertPart(reference.name, {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),
),
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),
),
)
const commands = createMemo((): AutocompleteOption[] => {

View File

@ -264,6 +264,7 @@ export default defineConfig({
"mcp-servers",
"acp",
"skills",
"references",
"custom-tools",
],
},

View File

@ -0,0 +1,157 @@
---
title: References
description: Add local directories and Git repositories as project references.
---
References give OpenCode access to directories outside the current project. Use them to make documentation, shared libraries, examples, or another repository available while you work.
References are configured by alias in `opencode.json` or `opencode.jsonc`.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and documentation conventions",
},
"sdk": {
"repository": "anomalyco/opencode-sdk-js",
"branch": "main",
"description": "Use for JavaScript SDK implementation details",
},
},
}
```
---
## Local directories
Use `path` to reference a local directory.
```jsonc title="opencode.jsonc"
{
"references": {
"docs": {
"path": "../docs",
},
},
}
```
Paths can be:
- Relative to the config file that defines the reference
- Absolute, such as `/home/user/docs`
- Relative to your home directory, such as `~/docs`
You can also use a string shorthand:
```jsonc title="opencode.jsonc"
{
"references": {
"docs": "../docs",
},
}
```
---
## Git repositories
Use `repository` to reference a Git repository. OpenCode materializes the repository in its local repository cache and makes the checked-out source available as a reference directory.
```jsonc title="opencode.jsonc"
{
"references": {
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
},
},
}
```
`repository` accepts Git URLs, host/path references, and GitHub `owner/repo` shorthand. The optional `branch` field selects a branch or ref. Without `branch`, OpenCode uses the repository's default branch.
You can use string shorthand when you do not need a branch, description, or other options:
```jsonc title="opencode.jsonc"
{
"references": {
"effect": "Effect-TS/effect",
},
}
```
:::note
Git references are refreshed asynchronously. A newly configured repository may take a moment to finish cloning or updating.
:::
---
## Describe usage
Add `description` to explain when an agent should use a reference.
```jsonc title="opencode.jsonc"
{
"references": {
"design-system": {
"path": "../design-system",
"description": "Use when implementing UI components or design tokens",
},
},
}
```
OpenCode includes references with descriptions in agent context. Descriptions should be short and specific enough to distinguish references with similar content. References without descriptions remain available through autocomplete and direct use, but are not advertised to agents.
---
## Hide autocomplete entries
Set `hidden` to `true` to omit a reference from `@` autocomplete in the TUI.
```jsonc title="opencode.jsonc"
{
"references": {
"internal": {
"path": "../internal",
"description": "Use for internal implementation details",
"hidden": true,
},
},
}
```
`hidden` only affects autocomplete. A hidden reference with a description remains included in agent context.
---
## Use references
Configured references appear in TUI `@` autocomplete. Type `@alias` to attach the reference root, or `@alias/` to search for files inside it.
```text
Compare this implementation with @sdk/src/client.ts
```
Agents also receive the resolved paths and descriptions of configured references that have descriptions in their system context, so they can inspect a reference when it is relevant without you attaching it manually.
OpenCode automatically allows reference directories through its external-directory permission boundary. Normal tool permissions still apply; for example, an agent that cannot edit files does not gain edit access because a directory is configured as a reference.
---
## Configure fields
| Field | Local | Git | Description |
| ------------- | ----- | --- | ------------------------------------------------ |
| `path` | Yes | No | Local reference directory |
| `repository` | No | Yes | Git URL, host/path, or GitHub `owner/repo` value |
| `branch` | No | Yes | Optional Git branch or ref |
| `description` | Yes | Yes | Guidance describing when to use the reference |
| `hidden` | Yes | Yes | Hide the reference from TUI `@` autocomplete |
Reference aliases cannot be empty or contain `/`, whitespace, backticks, or commas.

View File

@ -41,7 +41,7 @@ How is auth handled in @packages/functions/src/api/index.ts?
The content of the file is added to the conversation automatically.
Configured references also appear in `@` autocomplete. Type `@alias` to add the reference root as context, or type `@alias/` to autocomplete files inside that reference.
Configured [references](/docs/references) also appear in `@` autocomplete. Type `@alias` to add the reference root as context, or type `@alias/` to autocomplete files inside that reference.
```text "@docs/README.md"
Compare our setup with @docs/README.md