mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(core): load configured instructions (#55539)
Co-authored-by: vegerot <5297725+vegerot@users.noreply.github.com>
This commit is contained in:
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { sameDirectory } from "@opencode/util/path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import path, { dirname, join } from "path"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
import { Instructions } from "../../instructions/index.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
@@ -18,17 +19,19 @@ type Loaded =
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
// Nothing this plugin watches or loads can contribute when both scopes
|
||||
// are disabled; skip the resolves, the watcher fiber, and the transform.
|
||||
if (!discovery.project && !discovery.global) return
|
||||
// Activate even when both discovery scopes are disabled: configured `instructions`
|
||||
// entries are explicit requests, not discovery.
|
||||
yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
@@ -37,6 +40,10 @@ export const Plugin = define({
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
|
||||
sources: [],
|
||||
remote: new Map(),
|
||||
}
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
// The ancestor walk can reach the global file when the location sits
|
||||
@@ -49,15 +56,15 @@ export const Plugin = define({
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
for (const file of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
|
||||
const content = yield* fs.readFileStringSafe(filepath)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
@@ -78,6 +85,83 @@ export const Plugin = define({
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
// Relative patterns search upward from the location to the project root. Without
|
||||
// project discovery they resolve against the global config directory instead.
|
||||
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
|
||||
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
|
||||
const options = { absolute: true, include: "file", dot: true } as const
|
||||
if (path.isAbsolute(expanded))
|
||||
return yield* fs
|
||||
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
if (project) return yield* fs.globUp(expanded, start, root)
|
||||
if (!discovery.global) return []
|
||||
return yield* fs
|
||||
.scan(expanded, { ...options, cwd: global.config })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
})
|
||||
|
||||
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
|
||||
const files: InstructionDiscovery.File[] = []
|
||||
const watched = new Set<string>()
|
||||
for (const source of configured.sources) {
|
||||
if (isUrl(source)) {
|
||||
const file = configured.remote.get(source)
|
||||
if (file) files.push(file)
|
||||
continue
|
||||
}
|
||||
for (const item of (yield* match(source)).toSorted()) {
|
||||
const resolved = yield* fs.resolve(item)
|
||||
if (watched.has(resolved)) continue
|
||||
watched.add(resolved)
|
||||
const file = yield* read(resolved)
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
|
||||
for (const file of watched) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
})
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
// Remote instructions are fetched only when config loads, not on every file rescan.
|
||||
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
|
||||
// never makes the whole instruction set unavailable.
|
||||
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
|
||||
function* () {
|
||||
const entries = yield* config.entries()
|
||||
const sources = Array.from(
|
||||
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
|
||||
)
|
||||
const fetched = yield* Effect.forEach(
|
||||
sources.filter(isUrl),
|
||||
(url) =>
|
||||
HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
|
||||
Effect.as(configured.remote.get(url)),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
configured.sources = sources
|
||||
configured.remote = new Map(
|
||||
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
|
||||
)
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -92,10 +176,11 @@ export const Plugin = define({
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
configured: isolate("configured", configuredSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
|
||||
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
@@ -115,6 +200,13 @@ export const Plugin = define({
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Subscribe before the first config read so an update in between still triggers a reload.
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconfigure()
|
||||
yield* refresh()
|
||||
yield* discovery.transform((editor) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (sameDirectory(start, stop)) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
|
||||
function isUrl(source: string) {
|
||||
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { createPatch } from "diff"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instructions } from "./instructions/index.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
// An absolute file path, or the URL of a configured remote instruction.
|
||||
path: Schema.String,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Event = {
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
files: Map<string, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
|
||||
list: () => Array.from(editor.files.values()),
|
||||
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = editor.files.get(AbsolutePath.make(path))
|
||||
const current = editor.files.get(path)
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
current.path = path
|
||||
},
|
||||
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
|
||||
remove: (path) => editor.files.delete(path),
|
||||
unavailable: () => {
|
||||
editor.available = false
|
||||
},
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/http"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -26,13 +30,25 @@ const instructionLayer = (input: {
|
||||
home?: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
instructions?: string[]
|
||||
http?: HttpClient.HttpClient
|
||||
project?: boolean
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
LayerNode.group([
|
||||
InstructionDiscovery.node,
|
||||
Bus.node,
|
||||
Config.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Location.node,
|
||||
Watcher.node,
|
||||
]),
|
||||
[
|
||||
Config.node.replace(config),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
||||
Global.node.replace(
|
||||
input.config || input.home
|
||||
@@ -48,20 +64,34 @@ const instructionLayer = (input: {
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
config,
|
||||
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
const start = Effect.fnUntraced(function* (ctx = host()) {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
|
||||
|
||||
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
|
||||
|
||||
const respond = (handler: (url: string) => Response) =>
|
||||
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
|
||||
|
||||
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* triggerAndWait(watcher.emit(update))
|
||||
})
|
||||
}
|
||||
|
||||
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
@@ -69,7 +99,7 @@ function emitAndWait(update: Watcher.Update) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* trigger
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
@@ -250,6 +280,133 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const agents = path.join(project, "AGENTS.md")
|
||||
const first = path.join(project, "rules", "a.md")
|
||||
const second = path.join(project, "rules", "b.md")
|
||||
const absolute = path.join(tmp.path, "absolute.md")
|
||||
const homeFile = path.join(tmp.path, "home.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const requests: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(first), { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Promise.all([
|
||||
fs.writeFile(agents, "agents"),
|
||||
fs.writeFile(first, "first"),
|
||||
fs.writeFile(second, "second"),
|
||||
fs.writeFile(absolute, "absolute"),
|
||||
fs.writeFile(homeFile, "home"),
|
||||
])
|
||||
})
|
||||
|
||||
const discovery = yield* start()
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${agents}\nagents`,
|
||||
`Instructions from: ${first}\nfirst`,
|
||||
`Instructions from: ${second}\nsecond`,
|
||||
`Instructions from: ${absolute}\nabsolute`,
|
||||
`Instructions from: ${homeFile}\nhome`,
|
||||
`Instructions from: ${remote}\nremote`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(first, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: first })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
`The instructions changed:\nInstructions from: ${first}\nchanged`,
|
||||
)
|
||||
// File rescans reuse fetched URLs instead of requesting them again.
|
||||
expect(requests).toHaveLength(2)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
home: tmp.path,
|
||||
instructions: [
|
||||
"rules/*.md",
|
||||
absolute,
|
||||
"~/home.md",
|
||||
remote,
|
||||
"missing.md",
|
||||
"https://example.test/missing.md",
|
||||
],
|
||||
http: respond((url) => {
|
||||
requests.push(url)
|
||||
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
|
||||
}),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const first = path.join(tmp.path, "first.md")
|
||||
const second = path.join(tmp.path, "second.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const responses = [new Response("remote"), new Response(null, { status: 500 })]
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(first, "first")
|
||||
await fs.writeFile(second, "second")
|
||||
})
|
||||
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
|
||||
const config = yield* Config.Test
|
||||
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
expect(initialized.text).toBe(
|
||||
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* config.setEntries([configured([second, remote])])
|
||||
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
instructions: [first, remote],
|
||||
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps an empty AGENTS.md as available context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -453,8 +453,8 @@ See the [commands guide](/commands) for arguments, models, agents, and file-base
|
||||
|
||||
### Instructions
|
||||
|
||||
Declare additional instruction files, globs, or URLs. OpenCode accepts this
|
||||
field but does not load its entries; use `AGENTS.md` for instructions.
|
||||
Declare additional instruction files, globs, or HTTP(S) URLs. OpenCode loads
|
||||
them alongside discovered `AGENTS.md` files.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
|
||||
@@ -68,7 +68,7 @@ The selected agent or provider system prompt is sent first. OpenCode then assemb
|
||||
1. Agent or provider system prompt
|
||||
2. Built-in environment and date context
|
||||
3. Code Mode tool guidance, when enabled
|
||||
4. Global and project AGENTS.md files
|
||||
4. Global and project AGENTS.md files, then configured instructions
|
||||
5. Available skill, reference, and MCP guidance
|
||||
6. Session-specific instruction entries supplied through the API
|
||||
```
|
||||
@@ -100,13 +100,31 @@ Instruction values remain privileged. Clients can see which sources changed, but
|
||||
|
||||
## Configuration
|
||||
|
||||
The V2 config schema accepts an `instructions` array, but V2 does not currently resolve its files, glob patterns, or URLs. This configuration does not add instructions to the model yet:
|
||||
Use the `instructions` array to add files, glob patterns, or HTTP(S) URLs alongside discovered `AGENTS.md` files:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/instructions.md"],
|
||||
"instructions": [
|
||||
"CONTRIBUTING.md",
|
||||
"docs/guidelines/*.md",
|
||||
"~/rules/opencode.md",
|
||||
"https://example.com/instructions.md",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Use `AGENTS.md` for active V2 instructions. See [Config](/config) for configuration locations and precedence.
|
||||
Configured instructions load after `AGENTS.md` files, in the order they are listed. Entries from every config document are combined.
|
||||
|
||||
| Entry | Resolution |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| Relative path or glob | Searched in each directory from the workspace up to the project root |
|
||||
| `~/` path | Resolved from your home directory |
|
||||
| Absolute path | Used as written; the file name may be a glob |
|
||||
| `http://` or `https://` | Fetched when config loads, with a five-second timeout |
|
||||
|
||||
- Local files update like `AGENTS.md` when they change. New files matching a glob load on the next config change.
|
||||
- Missing files are skipped. A URL that cannot be fetched is skipped, or keeps its last fetched content.
|
||||
- With `OPENCODE_DISABLE_PROJECT_CONFIG=1`, or when the workspace is outside the project, relative entries resolve against the global config directory.
|
||||
|
||||
See [Config](/config) for configuration locations and precedence.
|
||||
|
||||
Reference in New Issue
Block a user