| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- 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<string[]> {
- 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<string, Set<string>>(),
- }
- })
- 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<string>()
- 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<string>()
- 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
- }
- }
|