diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 7f07577f8..b0f7d5944 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -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": { diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 81e0804b4..22c766499 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -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, }), ) } diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts index 040169855..4518eb4f8 100644 --- a/packages/core/src/config/reference.ts +++ b/packages/core/src/config/reference.ts @@ -5,10 +5,14 @@ import { Schema } from "effect" export class Git extends Schema.Class("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("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]) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index a0578066a..2bf32fd75 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -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()("@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()(" 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()(" 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: [ diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index b8020b3c7..ebfb096f1 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace" export * as Location from "./location" -export const Ref = Schema.Struct({ +export class Ref extends Schema.Class("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("Location.Info")({ directory: AbsolutePath, diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 99b112d9a..1c1cbdf3c 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -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. diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 9c354f55c..66eb160eb 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -9,21 +9,19 @@ import { RepositoryCache } from "./repository-cache" import { AbsolutePath } from "./schema" import { State } from "./state" -export class Info extends Schema.Class("Reference.Info")({ - name: Schema.String, - path: AbsolutePath, - source: Schema.suspend(() => Source), -}) {} - export class LocalSource extends Schema.Class("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("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("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 } @@ -71,7 +77,16 @@ export const layer = Layer.effect( const seen = new Map() 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", { diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts new file mode 100644 index 000000000..f56726476 --- /dev/null +++ b/packages/core/src/reference/guidance.ts @@ -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) => + [ + "Project references provide additional directories that can be accessed when relevant.", + "", + ...references.flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined ? [] : [` ${reference.description}`]), + " ", + ]), + "", + ].join("\n") + +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@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 diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 18b7b2987..822c80458 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -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 ?? ".", diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 88ba79098..02a1eb3fe 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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, diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index cea9d3454..5c520846d 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -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))) })), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 417e0217e..19bf48aa7 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -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] }, ), diff --git a/packages/core/src/v1/config/reference.ts b/packages/core/src/v1/config/reference.ts deleted file mode 100644 index 2e562b9f3..000000000 --- a/packages/core/src/v1/config/reference.ts +++ /dev/null @@ -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 - -export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" }) -export type Info = Schema.Schema.Type diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 04e1c062f..5f62cbce6 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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" } }, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 1a348891d..ecffef099 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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) diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts new file mode 100644 index 000000000..5e1aba192 --- /dev/null +++ b/packages/core/test/reference-guidance.test.ts @@ -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("") + expect(generation.baseline).toContain("docs") + expect(generation.baseline).toContain("/docs") + expect(generation.baseline).toContain("Use for product documentation") + }).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 })), + ), + ) +}) diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index 58b28bf06..dfa8a202a 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -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), + ), + ) }) diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index c91d1dd7c..d7efe2a2e 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -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]()), ), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index fc136d960..e8da56a3a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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)) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 73ff5f47f..af17de175 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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)) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 86dc417ca..142440087 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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( 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" diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 5ad31985d..21447ba46 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -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 = (effect: Effect.Effect) => 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), ) diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index aab701898..74288529d 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -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), ), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index fae43d10c..556edcd5e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -18,7 +18,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { 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) })), + ), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index d8f9e55a0..0058c66b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -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* (effect: Effect.Effect) { 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* (effect: Effect.Effect) { 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) })), + ), ) }) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index cee669d14..74401779d 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -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()}`, ``, ].join("\n"), - ] + references.length === 0 + ? undefined + : [ + "Project references provide additional directories that can be accessed when relevant.", + "", + ...references + .toSorted((a, b) => a.name.localeCompare(b.name)) + .flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined + ? [] + : [` ${reference.description}`]), + " ", + ]), + "", + ].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" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 3683229fc..67071bce7 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -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 = {}) => Agent.layer.pipe( @@ -22,6 +23,7 @@ const agentLayer = (flags: Partial = {}) => 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()) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 242127571..4c894b0a0 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -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 })), ) diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index 0c939bb70..dcb260588 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -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, }, }, ]) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 28b1bcac8..69cec7bdc 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2b4677e40..9e9c74c6e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1685,26 +1685,6 @@ export type ServerConfig = { cors?: Array } -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 urls?: Array } - reference?: ReferenceConfig + references?: { + [key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal + } watcher?: { ignore?: Array } @@ -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 } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 162e7d495..3bb92d700 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": [ { diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts index 6a5815ca2..4ee082c71 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/groups/location.ts @@ -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( diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index e98c0db9d..7306cf76b 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -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, + }), + ), ), ) }), diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 1c177f886..b543d7c38 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -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[] => { diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 78b358781..48d6f3c55 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -264,6 +264,7 @@ export default defineConfig({ "mcp-servers", "acp", "skills", + "references", "custom-tools", ], }, diff --git a/packages/web/src/content/docs/references.mdx b/packages/web/src/content/docs/references.mdx new file mode 100644 index 000000000..2df002c33 --- /dev/null +++ b/packages/web/src/content/docs/references.mdx @@ -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. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index cd668a3be..a03797b30 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -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