diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index d1236ff40..fe3003485 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) { callID: Identifier.ascending("part"), agent: agent.name, abort: new AbortController().signal, + messages: [], metadata: () => {}, async ask(req: Omit) { for (const pattern of req.patterns) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b3228c847..5f47562d2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1693,10 +1693,29 @@ function Glob(props: ToolProps) { } function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => { + if (props.part.state.status !== "completed") return [] + if (props.part.state.time.compacted) return [] + const value = props.metadata.loaded + if (!value || !Array.isArray(value)) return [] + return value.filter((p): p is string => typeof p === "string") + }) return ( - - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - + <> + + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + ) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts new file mode 100644 index 000000000..d413e80f6 --- /dev/null +++ b/packages/opencode/src/session/instruction.ts @@ -0,0 +1,164 @@ +import path from "path" +import os from "os" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" +import { Config } from "../config/config" +import { Instance } from "../project/instance" +import { Flag } from "@/flag/flag" +import { Log } from "../util/log" +import type { MessageV2 } from "./message-v2" + +const log = Log.create({ service: "instruction" }) + +const FILES = [ + "AGENTS.md", + "CLAUDE.md", + "CONTEXT.md", // deprecated +] + +function globalFiles() { + const files = [path.join(Global.Path.config, "AGENTS.md")] + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { + files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) + } + if (Flag.OPENCODE_CONFIG_DIR) { + files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + } + return files +} + +async function resolveRelative(instruction: string): Promise { + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => []) + } + if (!Flag.OPENCODE_CONFIG_DIR) { + log.warn( + `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, + ) + return [] + } + return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => []) +} + +export namespace InstructionPrompt { + export async function systemPaths() { + const config = await Config.get() + const paths = new Set() + + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of FILES) { + const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) + if (matches.length > 0) { + matches.forEach((p) => paths.add(path.resolve(p))) + break + } + } + } + + for (const file of globalFiles()) { + if (await Bun.file(file).exists()) { + paths.add(path.resolve(file)) + break + } + } + + if (config.instructions) { + for (let instruction of config.instructions) { + if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue + if (instruction.startsWith("~/")) { + instruction = path.join(os.homedir(), instruction.slice(2)) + } + const matches = path.isAbsolute(instruction) + ? await Array.fromAsync( + new Bun.Glob(path.basename(instruction)).scan({ + cwd: path.dirname(instruction), + absolute: true, + onlyFiles: true, + }), + ).catch(() => []) + : await resolveRelative(instruction) + matches.forEach((p) => paths.add(path.resolve(p))) + } + } + + return paths + } + + export async function system() { + const config = await Config.get() + const paths = await systemPaths() + + const files = Array.from(paths).map(async (p) => { + const content = await Bun.file(p) + .text() + .catch(() => "") + return content ? "Instructions from: " + p + "\n" + content : "" + }) + + const urls: string[] = [] + if (config.instructions) { + for (const instruction of config.instructions) { + if (instruction.startsWith("https://") || instruction.startsWith("http://")) { + urls.push(instruction) + } + } + } + const fetches = urls.map((url) => + fetch(url, { signal: AbortSignal.timeout(5000) }) + .then((res) => (res.ok ? res.text() : "")) + .catch(() => "") + .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), + ) + + return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean)) + } + + export function loaded(messages: MessageV2.WithParts[]) { + const paths = new Set() + for (const msg of messages) { + for (const part of msg.parts) { + if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") { + if (part.state.time.compacted) continue + const loaded = part.state.metadata?.loaded + if (!loaded || !Array.isArray(loaded)) continue + for (const p of loaded) { + if (typeof p === "string") paths.add(p) + } + } + } + } + return paths + } + + export async function find(dir: string) { + for (const file of FILES) { + const filepath = path.resolve(path.join(dir, file)) + if (await Bun.file(filepath).exists()) return filepath + } + } + + export async function resolve(messages: MessageV2.WithParts[], filepath: string) { + const system = await systemPaths() + const already = loaded(messages) + const results: { filepath: string; content: string }[] = [] + + let current = path.dirname(path.resolve(filepath)) + const root = path.resolve(Instance.directory) + + while (current.startsWith(root)) { + const found = await find(current) + if (found && !system.has(found) && !already.has(found)) { + const content = await Bun.file(found) + .text() + .catch(() => undefined) + if (content) { + results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) + } + } + if (current === root) break + current = path.dirname(current) + } + + return results + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 83ca72add..7c8689037 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -631,7 +631,7 @@ export namespace MessageV2 { sessionID: Identifier.schema("session"), messageID: Identifier.schema("message"), }), - async (input) => { + async (input): Promise => { return { info: await Storage.read(["message", input.sessionID, input.messageID]), parts: await parts(input.messageID), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3f11b335..23ca47354 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -15,6 +15,7 @@ import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" +import { InstructionPrompt } from "./instruction" import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -386,6 +387,7 @@ export namespace SessionPrompt { abort, callID: part.callID, extra: { bypassAgentCheck: true }, + messages: msgs, async metadata(input) { await Session.updatePart({ ...part, @@ -561,6 +563,7 @@ export namespace SessionPrompt { tools: lastUser.tools, processor, bypassAgentCheck, + messages: msgs, }) if (step === 1) { @@ -598,7 +601,7 @@ export namespace SessionPrompt { agent, abort, sessionID, - system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())], + system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())], messages: [ ...MessageV2.toModelMessages(sessionMessages, model), ...(isLastStep @@ -650,6 +653,7 @@ export namespace SessionPrompt { tools?: Record processor: SessionProcessor.Info bypassAgentCheck: boolean + messages: MessageV2.WithParts[] }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -661,6 +665,7 @@ export namespace SessionPrompt { callID: options.toolCallId, extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, agent: input.agent.name, + messages: input.messages, metadata: async (val: { title?: string; metadata?: any }) => { const match = input.processor.partFromToolCall(options.toolCallId) if (match && match.state.status === "running") { @@ -1008,6 +1013,7 @@ export namespace SessionPrompt { agent: input.agent!, messageID: info.id, extra: { bypassCwdCheck: true, model }, + messages: [], metadata: async () => {}, ask: async () => {}, } @@ -1069,6 +1075,7 @@ export namespace SessionPrompt { agent: input.agent!, messageID: info.id, extra: { bypassCwdCheck: true }, + messages: [], metadata: async () => {}, ask: async () => {}, } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b055bd10e..d34a086fe 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,37 +1,14 @@ import { Ripgrep } from "../file/ripgrep" -import { Global } from "../global" -import { Filesystem } from "../util/filesystem" -import { Config } from "../config/config" -import { Log } from "../util/log" import { Instance } from "../project/instance" -import path from "path" -import os from "os" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" -import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_CODEX from "./prompt/codex_header.txt" import type { Provider } from "@/provider/provider" -import { Flag } from "@/flag/flag" - -const log = Log.create({ service: "system-prompt" }) - -async function resolveRelativeInstruction(instruction: string): Promise { - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => []) - } - if (!Flag.OPENCODE_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, - ) - return [] - } - return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => []) -} export namespace SystemPrompt { export function instructions() { @@ -72,81 +49,4 @@ export namespace SystemPrompt { ].join("\n"), ] } - - const LOCAL_RULE_FILES = [ - "AGENTS.md", - "CLAUDE.md", - "CONTEXT.md", // deprecated - ] - const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")] - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - - if (Flag.OPENCODE_CONFIG_DIR) { - GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) - } - - export async function custom() { - const config = await Config.get() - const paths = new Set() - - // Only scan local rule files when project discovery is enabled - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const localRuleFile of LOCAL_RULE_FILES) { - const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree) - if (matches.length > 0) { - matches.forEach((path) => paths.add(path)) - break - } - } - } - - for (const globalRuleFile of GLOBAL_RULE_FILES) { - if (await Bun.file(globalRuleFile).exists()) { - paths.add(globalRuleFile) - break - } - } - - const urls: string[] = [] - if (config.instructions) { - for (let instruction of config.instructions) { - if (instruction.startsWith("https://") || instruction.startsWith("http://")) { - urls.push(instruction) - continue - } - if (instruction.startsWith("~/")) { - instruction = path.join(os.homedir(), instruction.slice(2)) - } - let matches: string[] = [] - if (path.isAbsolute(instruction)) { - matches = await Array.fromAsync( - new Bun.Glob(path.basename(instruction)).scan({ - cwd: path.dirname(instruction), - absolute: true, - onlyFiles: true, - }), - ).catch(() => []) - } else { - matches = await resolveRelativeInstruction(instruction) - } - matches.forEach((path) => paths.add(path)) - } - } - - const foundFiles = Array.from(paths).map((p) => - Bun.file(p) - .text() - .catch(() => "") - .then((x) => "Instructions from: " + p + "\n" + x), - ) - const foundUrls = urls.map((url) => - fetch(url, { signal: AbortSignal.timeout(5000) }) - .then((res) => (res.ok ? res.text() : "")) - .catch(() => "") - .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), - ) - return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean)) - } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 3b1484cbc..028a007cc 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" +import { InstructionPrompt } from "../session/instruction" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -59,6 +60,8 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } + const instructions = await InstructionPrompt.resolve(ctx.messages, filepath) + // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet" @@ -72,6 +75,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview: msg, truncated: false, + ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), }, attachments: [ { @@ -133,12 +137,17 @@ export const ReadTool = Tool.define("read", { LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) + if (instructions.length > 0) { + output += `\n\n\n${instructions.map((i) => i.content).join("\n\n")}\n` + } + return { title, output, metadata: { preview, truncated, + ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), }, } }, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af..3d17ea192 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -20,6 +20,7 @@ export namespace Tool { abort: AbortSignal callID?: string extra?: { [key: string]: any } + messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts new file mode 100644 index 000000000..2c44a266e --- /dev/null +++ b/packages/opencode/test/session/instruction.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { InstructionPrompt } from "../../src/session/instruction" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("InstructionPrompt.resolve", () => { + test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions") + await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const system = await InstructionPrompt.systemPaths() + expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + + const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts")) + expect(results).toEqual([]) + }, + }) + }) + + test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") + await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const system = await InstructionPrompt.systemPaths() + expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + + const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts")) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index de48b074f..a08e23588 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -11,6 +11,7 @@ const baseCtx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193..454293c8f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -12,6 +12,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index b21f6a971..33c5e2c73 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -11,6 +11,7 @@ const baseCtx: Omit = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, } diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a79d93157..e774580df 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index c11b5ed6a..4a436186d 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -9,6 +9,7 @@ const ctx = { callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7250bd2fd..afa14bc6c 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -14,6 +14,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } @@ -330,3 +331,26 @@ root_type Monster;` }) }) }) + +describe("tool.read loaded instructions", () => { + test("loads AGENTS.md from parent directory and includes in metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.") + await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx) + expect(result.output).toContain("test content") + expect(result.output).toContain("system-reminder") + expect(result.output).toContain("Test Instructions") + expect(result.metadata.loaded).toBeDefined() + expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md")) + }, + }) + }) +}) diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 3a170019a..26e6de906 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills When opencode starts, it looks for rule files in this order: -1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`) +1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`) 2. **Global file** at `~/.config/opencode/AGENTS.md` 3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)