Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/debug/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) {
callID: Identifier.ascending("part"),
agent: agent.name,
abort: new AbortController().signal,
messages: [],
metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
Expand Down
25 changes: 22 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1705,10 +1705,29 @@ function Glob(props: ToolProps<typeof GlobTool>) {
}

function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme()
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
if (props.part.state.time.compacted) return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
return (
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<>
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
↳ Loaded {normalizePath(filepath)}
</text>
</box>
)}
</For>
</>
)
}

Expand Down
164 changes: 164 additions & 0 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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 {
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) {
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)) {
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
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ export namespace MessageV2 {
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
}),
async (input) => {
async (input): Promise<WithParts> => {
return {
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
parts: await parts(input.messageID),
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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 { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
Expand Down Expand Up @@ -386,6 +387,7 @@ export namespace SessionPrompt {
abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
async metadata(input) {
await Session.updatePart({
...part,
Expand Down Expand Up @@ -561,6 +563,7 @@ export namespace SessionPrompt {
tools: lastUser.tools,
processor,
bypassAgentCheck,
messages: msgs,
})

if (step === 1) {
Expand Down Expand Up @@ -598,7 +601,7 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
Expand Down Expand Up @@ -650,6 +653,7 @@ export namespace SessionPrompt {
tools?: Record<string, boolean>
processor: SessionProcessor.Info
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
Expand All @@ -661,6 +665,7 @@ export namespace SessionPrompt {
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
agent: input.agent.name,
messages: input.messages,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
Expand Down Expand Up @@ -1008,6 +1013,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model },
messages: [],
metadata: async () => {},
ask: async () => {},
}
Expand Down Expand Up @@ -1069,6 +1075,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
messages: [],
metadata: async () => {},
ask: async () => {},
}
Expand Down
100 changes: 0 additions & 100 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { Log } from "../util/log"

import { Instance } from "../project/instance"
import path from "path"
import os from "os"

import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"

import PROMPT_CODEX from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag"

const log = Log.create({ service: "system-prompt" })

async function resolveRelativeInstruction(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 SystemPrompt {
export function instructions() {
Expand Down Expand Up @@ -72,81 +49,4 @@ export namespace SystemPrompt {
].join("\n"),
]
}

const LOCAL_RULE_FILES = [
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}

if (Flag.OPENCODE_CONFIG_DIR) {
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}

export async function custom() {
const config = await Config.get()
const paths = new Set<string>()

// Only scan local rule files when project discovery is enabled
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const localRuleFile of LOCAL_RULE_FILES) {
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((path) => paths.add(path))
break
}
}
}

for (const globalRuleFile of GLOBAL_RULE_FILES) {
if (await Bun.file(globalRuleFile).exists()) {
paths.add(globalRuleFile)
break
}
}

const urls: string[] = []
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
urls.push(instruction)
continue
}
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
}
let matches: string[] = []
if (path.isAbsolute(instruction)) {
matches = await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
} else {
matches = await resolveRelativeInstruction(instruction)
}
matches.forEach((path) => paths.add(path))
}
}

const foundFiles = Array.from(paths).map((p) =>
Bun.file(p)
.text()
.catch(() => "")
.then((x) => "Instructions from: " + p + "\n" + x),
)
const foundUrls = 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([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
}
}
Loading