Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 155 additions & 140 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import path from "path"
import os from "os"
import path from "path"
import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
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 { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import type { MessageID } from "./schema"

const log = Log.create({ service: "instruction" })

Expand All @@ -32,19 +34,6 @@ function globals() {
return files
}

function relative(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 Promise.resolve([])
}
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
}

function extract(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
Expand All @@ -64,132 +53,156 @@ function extract(messages: MessageV2.WithParts[]) {

export namespace Instruction {
export interface Interface {
readonly clear: (messageID: string) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>>
readonly system: () => Effect.Effect<string[]>
readonly find: (dir: string) => Effect.Effect<string | undefined>
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: string,
) => Effect.Effect<{ filepath: string; content: string }[]>
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}

export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Instruction") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service

const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
claims: new Map<string, Set<string>>(),
}),
),
)

const clear = Effect.fn("Instruction.clear")(function* (messageID: string) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})

const systemPaths = Effect.fnUntraced(function* () {
const config = yield* cfg.get()
const paths = new Set<string>()

if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* Effect.promise(() =>
Filesystem.findUp(file, Instance.directory, Instance.worktree),
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) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Instance.directory, Instance.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`,
)
if (matches.length > 0) {
matches.forEach((p) => paths.add(path.resolve(p)))
break
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.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 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, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
}

for (const file of globals()) {
if (yield* Effect.promise(() => Filesystem.exists(file))) {
paths.add(path.resolve(file))
break
for (const file of globals()) {
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* Effect.promise(() =>
path.isAbsolute(instruction)
? Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
}).catch(() => [])
: relative(instruction),
)
matches.forEach((p) => paths.add(path.resolve(p)))
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
})
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 system = Effect.fnUntraced(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })

const files = Array.from(paths).map(async (p) => {
const content = await Filesystem.readText(p).catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
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 urls: string[] = []
if (config.instructions) {
for (const instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
urls.push(instruction)
}
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 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 yield* Effect.promise(() => Promise.all([...files, ...fetches]).then((r) => r.filter(Boolean)))
})
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 target = path.resolve(filepath)
const root = path.resolve(Instance.directory)
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
}

const find = Effect.fnUntraced(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* Effect.promise(() => Filesystem.exists(filepath))) return filepath
}
})

const resolve = Effect.fnUntraced(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: string,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)

const target = path.resolve(filepath)
let current = path.dirname(target)
const root = path.resolve(Instance.directory)

while (current.startsWith(root) && current !== root) {
const found = yield* find(current)

if (found && found !== target && !sys.has(found) && !already.has(found)) {
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
Expand All @@ -199,27 +212,32 @@ export namespace Instruction {
current = path.dirname(current)
continue
}

set.add(found)
const content = yield* Effect.promise(() => Filesystem.readText(found).catch(() => undefined))
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}

current = path.dirname(current)
}
current = path.dirname(current)
}

return results
})
return results
})

return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)

export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)

const { runPromise } = makeRuntime(Service, defaultLayer)

export function clear(messageID: string) {
export function clear(messageID: MessageID) {
return runPromise((svc) => svc.clear(messageID))
}

Expand All @@ -239,10 +257,7 @@ export namespace Instruction {
return runPromise((svc) => svc.find(dir))
}

export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
}

/** @deprecated Use `Instruction` instead */
export const InstructionPrompt = Instruction
8 changes: 4 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +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 { Instruction } from "./instruction"
import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
Expand Down Expand Up @@ -979,7 +979,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
variant,
}

yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))
yield* Effect.addFinalizer(() => InstanceState.withALS(() => Instruction.clear(info.id)))

type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
Expand Down Expand Up @@ -1490,7 +1490,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Promise.all([
SystemPrompt.skills(agent),
SystemPrompt.environment(model),
InstructionPrompt.system(),
Instruction.system(),
MessageV2.toModelMessages(msgs, model),
]),
)
Expand Down Expand Up @@ -1542,7 +1542,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
Effect.fnUntraced(function* (exit) {
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
yield* InstanceState.withALS(() => Instruction.clear(handle.message.id))
}),
)
if (outcome === "break") break
Expand Down
Loading
Loading