705f792e87
Move the Global module from packages/opencode/src/global to packages/core/src/global to provide a unified location for managing XDG directories and application paths. This eliminates duplicate path definitions across packages and ensures consistent access to data, config, cache, state, log, and bin directories throughout the codebase.
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
import os from "os"
|
|
import path from "path"
|
|
import { Effect, Layer, Context } from "effect"
|
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
import { Config } from "@/config"
|
|
import { InstanceState } from "@/effect"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { Log } from "../util"
|
|
import type { MessageV2 } from "./message-v2"
|
|
import type { MessageID } from "./schema"
|
|
|
|
const log = Log.create({ service: "instruction" })
|
|
|
|
const FILES = [
|
|
"AGENTS.md",
|
|
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
|
"CONTEXT.md", // deprecated
|
|
]
|
|
|
|
function globalFiles() {
|
|
const files = []
|
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
|
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
|
}
|
|
files.push(path.join(Global.Path.config, "AGENTS.md"))
|
|
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
|
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
|
}
|
|
return files
|
|
}
|
|
|
|
function extract(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 interface Interface {
|
|
readonly clear: (messageID: MessageID) => Effect.Effect<void>
|
|
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
|
|
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
|
|
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
|
|
readonly resolve: (
|
|
messages: MessageV2.WithParts[],
|
|
filepath: string,
|
|
messageID: MessageID,
|
|
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
|
|
|
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
|
|
Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const cfg = yield* Config.Service
|
|
const fs = yield* AppFileSystem.Service
|
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
|
|
|
const state = yield* InstanceState.make(
|
|
Effect.fn("Instruction.state")(() =>
|
|
Effect.succeed({
|
|
// Track which instruction files have already been attached for a given assistant message.
|
|
claims: new Map<MessageID, Set<string>>(),
|
|
}),
|
|
),
|
|
)
|
|
|
|
const relative = Effect.fnUntraced(function* (instruction: string) {
|
|
const ctx = yield* InstanceState.context
|
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
return yield* fs
|
|
.globUp(instruction, ctx.directory, ctx.worktree)
|
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
}
|
|
if (!Flag.OPENCODE_CONFIG_DIR) {
|
|
log.warn(
|
|
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
|
)
|
|
return []
|
|
}
|
|
return yield* fs
|
|
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
|
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
})
|
|
|
|
const read = Effect.fnUntraced(function* (filepath: string) {
|
|
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
|
|
})
|
|
|
|
const fetch = Effect.fnUntraced(function* (url: string) {
|
|
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
|
|
Effect.timeout(5000),
|
|
Effect.catch(() => Effect.succeed(null)),
|
|
)
|
|
if (!res) return ""
|
|
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
|
|
return new TextDecoder().decode(body)
|
|
})
|
|
|
|
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
|
const s = yield* InstanceState.get(state)
|
|
s.claims.delete(messageID)
|
|
})
|
|
|
|
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
|
const config = yield* cfg.get()
|
|
const ctx = yield* InstanceState.context
|
|
const paths = new Set<string>()
|
|
|
|
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
for (const file of FILES) {
|
|
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
|
|
if (matches.length > 0) {
|
|
matches.forEach((item) => paths.add(path.resolve(item)))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const file of globalFiles()) {
|
|
if (yield* fs.existsSafe(file)) {
|
|
paths.add(path.resolve(file))
|
|
break
|
|
}
|
|
}
|
|
|
|
if (config.instructions) {
|
|
for (const raw of config.instructions) {
|
|
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
|
|
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
|
|
const matches = yield* (
|
|
path.isAbsolute(instruction)
|
|
? fs.glob(path.basename(instruction), {
|
|
cwd: path.dirname(instruction),
|
|
absolute: true,
|
|
include: "file",
|
|
})
|
|
: relative(instruction)
|
|
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
matches.forEach((item) => paths.add(path.resolve(item)))
|
|
}
|
|
}
|
|
|
|
return paths
|
|
})
|
|
|
|
const system = Effect.fn("Instruction.system")(function* () {
|
|
const config = yield* cfg.get()
|
|
const paths = yield* systemPaths()
|
|
const urls = (config.instructions ?? []).filter(
|
|
(item) => item.startsWith("https://") || item.startsWith("http://"),
|
|
)
|
|
|
|
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
|
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
|
|
|
return [
|
|
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
|
|
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
|
|
]
|
|
})
|
|
|
|
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
|
for (const file of FILES) {
|
|
const filepath = path.resolve(path.join(dir, file))
|
|
if (yield* fs.existsSafe(filepath)) return filepath
|
|
}
|
|
})
|
|
|
|
const resolve = Effect.fn("Instruction.resolve")(function* (
|
|
messages: MessageV2.WithParts[],
|
|
filepath: string,
|
|
messageID: MessageID,
|
|
) {
|
|
const sys = yield* systemPaths()
|
|
const already = extract(messages)
|
|
const results: { filepath: string; content: string }[] = []
|
|
const s = yield* InstanceState.get(state)
|
|
const root = path.resolve(yield* InstanceState.directory)
|
|
|
|
const target = path.resolve(filepath)
|
|
let current = path.dirname(target)
|
|
|
|
// Walk upward from the file being read and attach nearby instruction files once per message.
|
|
while (current.startsWith(root) && current !== root) {
|
|
const found = yield* find(current)
|
|
if (!found || found === target || sys.has(found) || already.has(found)) {
|
|
current = path.dirname(current)
|
|
continue
|
|
}
|
|
|
|
let set = s.claims.get(messageID)
|
|
if (!set) {
|
|
set = new Set()
|
|
s.claims.set(messageID, set)
|
|
}
|
|
if (set.has(found)) {
|
|
current = path.dirname(current)
|
|
continue
|
|
}
|
|
|
|
set.add(found)
|
|
const content = yield* read(found)
|
|
if (content) {
|
|
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
|
}
|
|
|
|
current = path.dirname(current)
|
|
}
|
|
|
|
return results
|
|
})
|
|
|
|
return Service.of({ clear, systemPaths, system, find, resolve })
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(
|
|
Layer.provide(Config.defaultLayer),
|
|
Layer.provide(AppFileSystem.defaultLayer),
|
|
Layer.provide(FetchHttpClient.layer),
|
|
)
|
|
|
|
export function loaded(messages: MessageV2.WithParts[]) {
|
|
return extract(messages)
|
|
}
|
|
|
|
export * as Instruction from "./instruction"
|