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 { const state = Instance.state(() => { return { claims: new Map>(), } }) function isClaimed(messageID: string, filepath: string) { const claimed = state().claims.get(messageID) if (!claimed) return false return claimed.has(filepath) } function claim(messageID: string, filepath: string) { const current = state() let claimed = current.claims.get(messageID) if (!claimed) { claimed = new Set() current.claims.set(messageID, claimed) } claimed.add(filepath) } export function clear(messageID: string) { state().claims.delete(messageID) } 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, messageID: 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) && !isClaimed(messageID, found)) { claim(messageID, 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 } }