instruction.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import path from "path"
  2. import os from "os"
  3. import { Global } from "../global"
  4. import { Filesystem } from "../util/filesystem"
  5. import { Config } from "../config/config"
  6. import { Instance } from "../project/instance"
  7. import { Flag } from "@/flag/flag"
  8. import { Log } from "../util/log"
  9. import type { MessageV2 } from "./message-v2"
  10. const log = Log.create({ service: "instruction" })
  11. const FILES = [
  12. "AGENTS.md",
  13. "CLAUDE.md",
  14. "CONTEXT.md", // deprecated
  15. ]
  16. function globalFiles() {
  17. const files = [path.join(Global.Path.config, "AGENTS.md")]
  18. if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
  19. files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
  20. }
  21. if (Flag.OPENCODE_CONFIG_DIR) {
  22. files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
  23. }
  24. return files
  25. }
  26. async function resolveRelative(instruction: string): Promise<string[]> {
  27. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
  28. return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
  29. }
  30. if (!Flag.OPENCODE_CONFIG_DIR) {
  31. log.warn(
  32. `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
  33. )
  34. return []
  35. }
  36. return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
  37. }
  38. export namespace InstructionPrompt {
  39. const state = Instance.state(() => {
  40. return {
  41. claims: new Map<string, Set<string>>(),
  42. }
  43. })
  44. function isClaimed(messageID: string, filepath: string) {
  45. const claimed = state().claims.get(messageID)
  46. if (!claimed) return false
  47. return claimed.has(filepath)
  48. }
  49. function claim(messageID: string, filepath: string) {
  50. const current = state()
  51. let claimed = current.claims.get(messageID)
  52. if (!claimed) {
  53. claimed = new Set()
  54. current.claims.set(messageID, claimed)
  55. }
  56. claimed.add(filepath)
  57. }
  58. export function clear(messageID: string) {
  59. state().claims.delete(messageID)
  60. }
  61. export async function systemPaths() {
  62. const config = await Config.get()
  63. const paths = new Set<string>()
  64. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
  65. for (const file of FILES) {
  66. const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
  67. if (matches.length > 0) {
  68. matches.forEach((p) => paths.add(path.resolve(p)))
  69. break
  70. }
  71. }
  72. }
  73. for (const file of globalFiles()) {
  74. if (await Bun.file(file).exists()) {
  75. paths.add(path.resolve(file))
  76. break
  77. }
  78. }
  79. if (config.instructions) {
  80. for (let instruction of config.instructions) {
  81. if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
  82. if (instruction.startsWith("~/")) {
  83. instruction = path.join(os.homedir(), instruction.slice(2))
  84. }
  85. const matches = path.isAbsolute(instruction)
  86. ? await Array.fromAsync(
  87. new Bun.Glob(path.basename(instruction)).scan({
  88. cwd: path.dirname(instruction),
  89. absolute: true,
  90. onlyFiles: true,
  91. }),
  92. ).catch(() => [])
  93. : await resolveRelative(instruction)
  94. matches.forEach((p) => paths.add(path.resolve(p)))
  95. }
  96. }
  97. return paths
  98. }
  99. export async function system() {
  100. const config = await Config.get()
  101. const paths = await systemPaths()
  102. const files = Array.from(paths).map(async (p) => {
  103. const content = await Bun.file(p)
  104. .text()
  105. .catch(() => "")
  106. return content ? "Instructions from: " + p + "\n" + content : ""
  107. })
  108. const urls: string[] = []
  109. if (config.instructions) {
  110. for (const instruction of config.instructions) {
  111. if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
  112. urls.push(instruction)
  113. }
  114. }
  115. }
  116. const fetches = urls.map((url) =>
  117. fetch(url, { signal: AbortSignal.timeout(5000) })
  118. .then((res) => (res.ok ? res.text() : ""))
  119. .catch(() => "")
  120. .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
  121. )
  122. return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
  123. }
  124. export function loaded(messages: MessageV2.WithParts[]) {
  125. const paths = new Set<string>()
  126. for (const msg of messages) {
  127. for (const part of msg.parts) {
  128. if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
  129. if (part.state.time.compacted) continue
  130. const loaded = part.state.metadata?.loaded
  131. if (!loaded || !Array.isArray(loaded)) continue
  132. for (const p of loaded) {
  133. if (typeof p === "string") paths.add(p)
  134. }
  135. }
  136. }
  137. }
  138. return paths
  139. }
  140. export async function find(dir: string) {
  141. for (const file of FILES) {
  142. const filepath = path.resolve(path.join(dir, file))
  143. if (await Bun.file(filepath).exists()) return filepath
  144. }
  145. }
  146. export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
  147. const system = await systemPaths()
  148. const already = loaded(messages)
  149. const results: { filepath: string; content: string }[] = []
  150. let current = path.dirname(path.resolve(filepath))
  151. const root = path.resolve(Instance.directory)
  152. while (current.startsWith(root)) {
  153. const found = await find(current)
  154. if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
  155. claim(messageID, found)
  156. const content = await Bun.file(found)
  157. .text()
  158. .catch(() => undefined)
  159. if (content) {
  160. results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
  161. }
  162. }
  163. if (current === root) break
  164. current = path.dirname(current)
  165. }
  166. return results
  167. }
  168. }